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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ Tally does not encode complete activities into share URLs. A local activity can

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, 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.
`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, secret-peppered request throttling, a byte-weighted project circuit breaker for validated Live creation, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly, and new database functions do not inherit browser execution by default.

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 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.
The RPC validates every value, applies the existing secret-peppered request throttle, consumes a project-wide event budget only after validation, 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.

## Feedback boundary

Expand Down
8 changes: 8 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ When someone chooses **Share live**, the activity name, participant names, expen

Anyone with the complete live URL can read and edit the activity. Share it only with intended participants. Removing a shortcut from one browser does not delete the backend activity for everyone.

## Optional AI expense drafts

Manual expense entry remains the default. When someone deliberately chooses text or voice AI, Tally sends the expense description or recording through OpenRouter to a selected model provider. The request also includes the activity currency, app language, participant IDs and names, the activity member selected as the current user, and any answers to follow-up questions. This context is required to resolve phrases such as “I paid” and participant names correctly.

Every AI request opts out of provider data collection and is restricted to OpenRouter endpoints marked for Zero Data Retention. Tally does not store descriptions, recordings, model responses, or unfinished drafts in its backend, and no expense is added to an activity until the person reviews and confirms it. Audio is held only long enough to process that request.

AI providers and models can change as availability and cost change, but the zero-retention routing requirement remains. Avoid including regulated or highly sensitive financial, medical, identity, or payment-card information in an AI description or recording.

## Abuse protection and analytics

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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Every push and pull request must pass:
- ESLint with TypeScript and React Hooks rules and zero warnings;
- component and helper tests;
- Playwright integration tests against the production GitHub Pages build;
- pgTAP contracts for live-activity, analytics, and feedback access control, graph validation, revocation, privacy, retention, and per-client/project-wide rate limits;
- pgTAP contracts for live-activity, analytics, and feedback access control, fail-closed function defaults, graph validation, revocation, privacy, retention, and per-client/project-wide budgets;
- 100% statement, branch, function, and line coverage;
- a production static build.

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

Anyone with a complete live URL can read, edit, and end that activity. **End live sharing** deletes the canonical backend record immediately; browsers that previously opened it retain only their local recovery copies. There are no accounts, participant-level permissions, token rotation, 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. AI calls have separate per-client text/voice quotas plus server-only project-wide daily ceilings. Live activity payloads are validated against the same bounded graph, reference, amount, settlement, timestamp, and snapshot-size rules in the browser and database. Production builds allow connections only to the configured HTTPS Supabase origin. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page.
Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes client rate-limit budget without being stored. Validated Live creation is additionally bounded by a server-only, byte-weighted project budget, and validated analytics has an independent project event ceiling. AI calls have separate per-client text/voice quotas plus server-only project-wide daily ceilings, and each OpenRouter request requires a Zero Data Retention endpoint. Future database functions are closed to browser execution by default and must opt in through an explicit grant. Live activity payloads are validated against the same bounded graph, reference, amount, settlement, timestamp, and snapshot-size rules in the browser and database. Production builds allow connections only to the configured HTTPS Supabase origin. 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/AI_EXPENSE_PREVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Production enables the feature only when the Edge Function secrets are configure
2. The OpenRouter key exists only in the Supabase Edge Function.
3. Tiny, clearly incomplete category-only descriptions receive a deterministic, localized clarification before any provider request or quota consumption.
4. Substantive descriptions in any language, dialect, shorthand, or mixed language go to the model; language-specific regexes never block them.
5. The Edge Function accepts a publishable client request, checks a server-only rate limit, prefers the cheapest model that meets a three-second p90 latency target, and requests no provider data collection.
5. The Edge Function accepts a publishable client request, checks a server-only rate limit, prefers the cheapest model that meets a three-second p90 latency target, opts out of provider data collection, and requires an OpenRouter Zero Data Retention endpoint.
6. A strict JSON Schema constrains every model-generated expense without imposing a fixed expense-count limit.
7. Titles and clarification questions follow the description's language, with the interface locale used only as a fallback.
8. Zod and deterministic business rules reject unknown members, invalid cents, duplicate participants, and exact splits that do not equal the total.
Expand All @@ -31,7 +31,7 @@ This does not use RAG: there is no external knowledge to retrieve. Reliability c

## Model and cost control

Typed descriptions use the candidates `google/gemma-4-26b-a4b-it:free` and `google/gemini-2.5-flash-lite`. OpenRouter prefers the cheapest eligible route whose recent p90 latency is at most three seconds, while keeping slower routes available as fallbacks. Voice goes directly to the audio-capable `google/gemini-2.5-flash-lite`, avoiding a separate transcription request. Override it with `OPENROUTER_VOICE_MODEL` only after running the same multilingual voice and browser checks.
Typed descriptions use the candidates `google/gemma-4-26b-a4b-it:free` and `google/gemini-2.5-flash-lite`. OpenRouter prefers the cheapest eligible zero-retention route whose recent p90 latency is at most three seconds, while keeping slower eligible routes available as fallbacks. Voice goes directly to the audio-capable `google/gemini-2.5-flash-lite`, avoiding a separate transcription request. Override either model only after confirming it appears in OpenRouter's current ZDR catalog and running the same multilingual voice and browser checks.

A successful provider response that does not satisfy the expense contract is treated as an incomplete conversation: the user receives a localized prompt to restate the amount, payer, and participants. A genuine upstream failure is logged without the expense text and shown as a model-specific retry/manual-entry message. The request has a bounded timeout so an unavailable route cannot leave the user waiting indefinitely.

Expand Down
2 changes: 1 addition & 1 deletion docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Do not add arbitrary metadata to this contract. Analytics must never receive URL

`src/analytics.ts` sends events as non-blocking `fetch` requests with `keepalive`, omitted credentials, and no referrer. Failed analytics requests are ignored and never affect local or live workflows.

`public.record_analytics_event` is the only browser-callable database entry point. It validates the event, surface, locale, and session-token shape; applies hashed-IP throttling; hashes the session token; and inserts into `private.analytics_events`. Browser roles cannot read or write that table directly and cannot read `private.analytics_daily`, `private.analytics_hourly`, or `private.analytics_locale_daily`.
`public.record_analytics_event` is the only browser-callable database entry point. It validates the event, surface, locale, and session-token shape; applies hashed-IP throttling; consumes one unit from a server-only 5,000-event rolling daily project budget; hashes the session token; and inserts into `private.analytics_events`. Invalid events still consume their client throttle but do not consume project capacity. Browser roles cannot read or write the event or budget tables directly and cannot read `private.analytics_daily`, `private.analytics_hourly`, or `private.analytics_locale_daily`.

Opening the app records its initial surface. Successful product actions are measured only after their local state update or live revision save succeeds. A failed expense or settlement save does not produce a success event. Currency selection is intentionally an interaction event: it records a deliberate change in either currency selector, even if the person later cancels activity creation or a live update cannot be saved.

Expand Down
5 changes: 3 additions & 2 deletions docs/LIVE_SHARING_EXPERIMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The link is intentionally a bearer capability: anyone who has the full link can
- **PostgREST RPCs** provide create, lightweight revision polling, full snapshot loading, revision-checked updates, and explicit capability revocation.
- The storage table and privileged functions live in the non-exposed `private` schema.
- Narrow security-definer `public` wrappers are callable with the project's publishable key. Browser roles cannot query private tables or execute private functions directly.
- RLS, validated JSON constraints, hashed-IP request throttling, statement timeouts, and 90-day sliding expiration provide defense in depth.
- RLS, validated JSON constraints, hashed-IP request throttling, a server-only five-MiB rolling daily snapshot budget, statement timeouts, and 90-day sliding expiration provide defense in depth. Only validated snapshots consume the shared byte budget.
- TanStack Query owns the in-memory live record, visibility-aware polling, reconnect/focus refreshes, and revision-checked mutations.
- Zod schemas validate versioned snapshots, references, settlements, and size limits before data enters or leaves the client.

Expand Down Expand Up @@ -58,6 +58,7 @@ An update sends `expectedRevision`. A conditional database update compares the c
- Unknown code or invalid token: SQLSTATE `P0002`, surfaced as `not-found` without revealing which part was wrong.
- Invalid snapshot or revision: SQLSTATE `22023`, surfaced as `invalid-input`.
- Too many requests from one network: HTTP `429`, surfaced as `rate-limit`.
- Valid Live creation beyond the project-wide snapshot budget: HTTP `429`, surfaced through the same retryable `rate-limit` experience.
- Valid capability + **End live sharing**: atomically delete the canonical record; later loads and saves receive the same `not-found` response as an unknown code or token.

The UI immediately loads the latest record, keeps the editor open, and asks the person to review and save again. Visible live-activity tabs poll a lightweight revision-only RPC every 15 seconds and fetch the full snapshot only when that revision changes. They also check immediately when they regain focus or reconnect. **Refresh latest** remains available as a manual fallback. Automatic field-level merging should wait until we have evidence that whole-activity optimistic concurrency is too disruptive.
Expand Down Expand Up @@ -98,4 +99,4 @@ The recovery-copy and revocation features are additive. Existing `#live=` capabi

- Vitest enforces 100% statement, branch, function, and line coverage, including happy paths and failure states.
- Playwright covers isolated creator, editor, observer, and pre-upgrade bookmark-only browser sessions, including additive mirror backfilling, latest-state synchronization, offline read-only behavior, explicit duplication, revocation propagation, and preserved recovery copies. Component and integration tests cover a failed first upgrade connection, stale-save recovery, and continuing locally after confirmed expiration.
- pgTAP verifies the SQL capability, privacy, strict graph-validation, revocation, and optimistic-concurrency contract.
- pgTAP verifies the SQL capability, privacy, fail-closed function defaults, strict graph validation, project-wide storage budgeting, revocation, and optimistic-concurrency contract.
1 change: 1 addition & 0 deletions src/features/aiExpense/aiExpensePrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('OpenRouter expense prompt', () => {
preferred_max_latency: { p90: 3 },
require_parameters: true,
sort: { by: 'price', partition: 'none' },
zdr: true,
})
expect(built).not.toHaveProperty('reasoning')
expect(built.max_tokens).toBe(700)
Expand Down
1 change: 1 addition & 0 deletions src/features/aiExpense/aiExpensePrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function buildOpenRouterRequest(
preferred_max_latency: { p90: 3 },
require_parameters: true,
sort: { by: 'price', partition: 'none' },
zdr: true,
},
temperature: 0,
max_tokens: batchMode ? 8_000 : 700,
Expand Down
6 changes: 5 additions & 1 deletion src/features/aiExpense/parseExpenseHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,11 @@ describe('parse expense Edge Function handler', () => {
expect(body.models).toEqual([DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FALLBACK_MODEL])
expect(JSON.parse(body.messages[1].content)).toMatchObject({ currentMemberId: 'me' })
expect(body).not.toHaveProperty('model')
expect(body.provider).toMatchObject({ allow_fallbacks: true, data_collection: 'deny' })
expect(body.provider).toMatchObject({
allow_fallbacks: true,
data_collection: 'deny',
zdr: true,
})
expect(init.headers).toMatchObject({ authorization: 'Bearer secret-key' })
expect(init.signal).toBeInstanceOf(AbortSignal)
})
Expand Down
Loading