From 0ab55c40d12726f7dd4779b14775f97fd1e34c31 Mon Sep 17 00:00:00 2001 From: PengfanZ Date: Sat, 8 Aug 2026 22:30:52 -0700 Subject: [PATCH] fix: harden live sharing and AI limits --- README.md | 7 +- SECURITY.md | 4 +- docs/AI_EXPENSE_PREVIEW.md | 4 +- docs/DEPLOYMENT.md | 9 +- docs/LIVE_SHARING_EXPERIMENT.md | 14 +- e2e/activity-lifecycle.spec.ts | 37 +- index.html | 2 +- package-lock.json | 6 +- package.json | 2 +- playwright.ai.config.ts | 1 + playwright.config.ts | 1 + src/App.test.tsx | 130 +++- src/App.tsx | 17 +- src/features/activity/ActivityDashboard.tsx | 4 +- src/features/aiExpense/aiExpenseApi.test.ts | 1 + src/features/aiExpense/aiExpenseApi.ts | 2 +- .../aiExpense/parseExpenseHandler.test.ts | 25 +- src/features/aiExpense/parseExpenseHandler.ts | 18 +- src/features/changelog/changelog.test.ts | 7 +- src/features/changelog/changelog.ts | 18 + .../liveSharing/liveActivityApi.test.ts | 14 +- src/features/liveSharing/liveActivityApi.ts | 13 + .../liveSharing/liveActivityConfig.ts | 7 +- .../liveSharing/useLiveActivitySession.ts | 40 +- .../sharing/ShareActivityMenu.test.tsx | 8 +- src/features/sharing/ShareActivityMenu.tsx | 11 +- .../sharing/sharedActivitySchema.test.ts | 41 +- src/features/sharing/sharedActivitySchema.ts | 26 +- src/i18n/localization.ts | 21 +- src/security/contentSecurityPolicy.test.ts | 12 + src/security/contentSecurityPolicy.ts | 14 + src/styles.css | 9 + supabase/functions/parse-expense/index.ts | 7 +- ..._harden_snapshot_revocation_ai_budgets.sql | 563 ++++++++++++++++++ supabase/tests/ai_expense.sql | 91 ++- supabase/tests/shared_activities.sql | 160 ++++- vite.config.ts | 10 +- 37 files changed, 1283 insertions(+), 73 deletions(-) create mode 100644 supabase/migrations/20260809010545_harden_snapshot_revocation_ai_budgets.sql diff --git a/README.md b/README.md index 258d5d7..61d06f9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Production uses privacy-preserving first-party analytics through Supabase for bo Tally supports two deliberately different sharing choices: -- **Start live activity** 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. The Live invite can be copied, opened from a QR code, or sent through the device share sheet. +- **Start live activity** 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. The Live invite can be copied, opened from a QR code, or sent through the device share sheet. Any holder of the complete link can explicitly end that capability; previously opened browsers keep their last recovery copy. - **Share balances only** exports a PNG summary with totals and suggested payments without granting access to the activity. - If Safari opens a Live link outside the installed PWA, **Join activity** safely transfers the copied link into the existing Tally app session. @@ -41,6 +41,7 @@ For launch copy, a privacy-safe demo storyboard, and channel guidance, see the [ - Persist data in the browser and synchronize changes across open tabs. - Install Tally as a PWA and reopen the local app shell without a network connection. - Collaborate across browsers through short, revision-checked live activity links that automatically load newer changes while visible. +- End a Live link immediately without deleting the last synced recovery copies on participating devices. - Continue a Safari-opened shared link in an installed Tally PWA without abandoning the existing app session. - Measure anonymous local and live feature usage without sending activity data or secret URLs to analytics. - Use the responsive interface on desktop or mobile. @@ -48,7 +49,7 @@ For launch copy, a privacy-safe demo storyboard, and channel guidance, see the [ ## Important data note -Local activities remain in browser `localStorage` and can be viewed after the installed app shell has been cached for offline use. Live activities are stored in Supabase and are editable by anyone with the full capability link while connected. Each participating browser also keeps the latest full Live snapshot locally: if the connection is unavailable, that recovery copy is read-only so offline changes cannot silently conflict. A person can explicitly duplicate it into an independent editable activity, or continue it locally after the backend confirms that the Live session has ended. There are no user accounts or participant-level permissions. Read [PRIVACY.md](PRIVACY.md) before deploying or sharing real activity data. +Local activities remain in browser `localStorage` and can be viewed after the installed app shell has been cached for offline use. Live activities are stored in Supabase and are editable—and can be ended—by anyone with the full capability link while connected. Each participating browser also keeps the latest full Live snapshot locally: if the connection is unavailable, that recovery copy is read-only so offline changes cannot silently conflict. A person can explicitly duplicate it into an independent editable activity, or continue it locally after the backend confirms that the Live session has ended. There are no user accounts or participant-level permissions. Read [PRIVACY.md](PRIVACY.md) before deploying or sharing real activity data. Currency selection controls display only. Tally does not convert amounts or support mixed currencies inside one activity. @@ -151,7 +152,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 and analytics access control, validation, privacy, retention, and rate limits; +- pgTAP contracts for live-activity and analytics access control, graph validation, revocation, privacy, retention, and per-client/project-wide rate limits; - 100% statement, branch, function, and line coverage; - a production static build. diff --git a/SECURITY.md b/SECURITY.md index 3662d68..3283718 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,8 +14,8 @@ Include the affected flow, reproduction steps, impact, and any suggested mitigat Local activities and identities are stored in browser `localStorage`. Live activities are stored in a private Supabase schema and accessed only through capability-checked public RPC wrappers. The edit token remains in the URL fragment and only its SHA-256 hash is stored by the backend. -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. +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. Live activity payloads are validated against bounded input and snapshot sizes before storage. 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 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. 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. diff --git a/docs/AI_EXPENSE_PREVIEW.md b/docs/AI_EXPENSE_PREVIEW.md index b647c71..a31ce02 100644 --- a/docs/AI_EXPENSE_PREVIEW.md +++ b/docs/AI_EXPENSE_PREVIEW.md @@ -35,7 +35,7 @@ Typed descriptions use the candidates `google/gemma-4-26b-a4b-it:free` and `goog 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. -The server maintains separate cost budgets per normalized client identifier. Text allows 30 requests per 10 minutes and 100 per day; voice allows 10 per 10 minutes and 25 per day. Counters are consumed before the provider call, including provider failures, and the stricter limit wins. OpenRouter account limits remain the hard cost ceiling. Use a preview-only key with a deliberately small limit, but leave enough unused budget for OpenRouter to authorize one worst-case voice request; an almost-exhausted `$0.01` key can reject a recording before the model runs. Never reuse a broad personal key. +The server maintains separate cost budgets per normalized client identifier. Text allows 30 requests per 10 minutes and 100 per day; voice allows 10 per 10 minutes and 25 per day. A second, server-only project ceiling defaults to 500 text and 100 voice provider calls per rolling day, stopping distributed traffic that no single-client quota would catch. Administrators can lower a ceiling or disable one mode in `private.ai_expense_budget_limits`. Counters are consumed before the provider call, including provider failures, and the stricter limit wins. OpenRouter account limits remain the final hard cost ceiling. Use a preview-only key with a deliberately small limit, but leave enough unused budget for OpenRouter to authorize one worst-case voice request; an almost-exhausted `$0.01` key can reject a recording before the model runs. Never reuse a broad personal key. Each browser also keeps an activity-scoped participant selection. The selected member ID is sent with text and voice requests as `viewerMemberId`, allowing first-person phrases such as “I paid” or “我付的” to resolve to an existing activity member. This selection stays in local storage and is never written into the shared activity snapshot, so collaborators can choose independently on each browser. @@ -85,7 +85,7 @@ OPENROUTER_FALLBACK_MODEL=google/gemini-2.5-flash-lite OPENROUTER_VOICE_MODEL=google/gemini-2.5-flash-lite ``` -Keep the production project reference out of the preview deployment environment. The database function `consume_ai_expense_quota` is executable only by the service role used inside the Edge Function; browser clients cannot call it directly. +Keep the production project reference out of the preview deployment environment. The database function `consume_ai_expense_quota_v2` is executable only by the service role used inside the Edge Function; browser clients cannot call it directly or read the private budget table. If voice recording stops normally but the app reports that its AI budget was reached, check the preview key—not only the account balance—in OpenRouter's **API Keys** page. The key's own cumulative credit limit may be lower than the account balance. Raise that preview-only cap intentionally, then run one short voice request and confirm its cost in OpenRouter Logs. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b77687e..7e41abc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -9,7 +9,7 @@ The production workflow verifies the frontend and database, builds with producti ## Production scope -This is a production-ready trusted-group MVP. Live links are bearer capabilities: anyone with the complete URL can read and edit that activity. Visible live tabs poll for newer revisions every 15 seconds, but there are no accounts, participant-level permissions, audit trail, realtime subscription, or token revocation yet. Do not use this release for regulated, highly sensitive, or adversarial financial data. +This is a production-ready trusted-group MVP. Live links are bearer capabilities: anyone with the complete URL can read, edit, and end that activity. Visible live tabs poll for newer revisions every 15 seconds, but there are no accounts, participant-level permissions, audit trail, realtime subscription, or per-participant revocation. Do not use this release for regulated, highly sensitive, or adversarial financial data. ## One-time setup @@ -92,6 +92,7 @@ The workflow can also be started manually from `main` with **Run workflow**. - Open the link in a private browser, add an expense, and confirm the first visible browser updates automatically within 15 seconds. - Confirm the recipient receives a persistent `Live · CODE` activity, then go offline and verify that its last synced snapshot remains visible but read-only. - Choose **Duplicate and edit** while offline and confirm the new independent local copy is editable without changing the Live activity. +- Choose **End live sharing**, confirm the old URL becomes unavailable in another browser, and verify both browsers retain their last synced read-only recovery copy with **Continue locally**. - Create one local activity and one live activity, then confirm their allowlisted events appear separately in `private.analytics_daily` and `private.analytics_hourly`, and their resolved UI locale appears in `private.analytics_locale_daily`, without URL or activity fields. - Create one text AI draft and one voice AI draft, then confirm the requested and ready events appear in the **SplitBill - AI Entry Usage** Home report without prompts, audio, or expense fields. - Run Supabase Security Advisor and Performance Advisor after the first migration. @@ -102,11 +103,11 @@ The workflow can also be started manually from `main` with **Run workflow**. - 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, 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). -- AI text and voice have separate database quotas. Keep the OpenRouter key limit as the hard cost ceiling and monitor request, clarification, ready, and failure frequency through the privacy-safe Home report. +- AI text and voice have separate per-client database quotas plus server-only rolling project ceilings (500 text and 100 voice provider calls by default). Administrators can lower a ceiling or disable one mode in `private.ai_expense_budget_limits`; keep the OpenRouter key limit as the final hard cost ceiling and monitor request, clarification, ready, and failure frequency through the privacy-safe Home report. - 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. -- The build derives the exact Supabase `connect-src` origin from `VITE_SUPABASE_URL`. Missing or unsafe values fall back to the production origin, so preview deployments can use an isolated Supabase project without weakening the policy. +- If a capability URL leaks, use **End live sharing** immediately and create a new Live session from a trusted recovery copy. Tally still has no participant-specific revocation or token rotation. +- The production build derives the exact HTTPS Supabase `connect-src` origin from `VITE_SUPABASE_URL`; missing or unsafe values fall back to the production origin. Local and test endpoints are added only by the explicit browser-test build flag and are absent from release artifacts. ## Rollback diff --git a/docs/LIVE_SHARING_EXPERIMENT.md b/docs/LIVE_SHARING_EXPERIMENT.md index 2e4b52f..fb4c998 100644 --- a/docs/LIVE_SHARING_EXPERIMENT.md +++ b/docs/LIVE_SHARING_EXPERIMENT.md @@ -25,7 +25,7 @@ The link is intentionally a bearer capability: anyone who has the full link can - **GitHub Pages** continues to host the React app. - **Supabase Postgres** stores the canonical JSON snapshot, hashed edit token, revision, timestamps, and sliding expiration. -- **PostgREST RPCs** provide create, lightweight revision polling, full snapshot loading, and revision-checked update operations. +- **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. @@ -58,15 +58,16 @@ 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 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. ## Remaining trusted-group limitations - Decide whether separate read-only and edit tokens are useful. -- Add token rotation, explicit backend deletion, and participant-level revocation. +- Decide whether token rotation and participant-level revocation are useful beyond whole-session revocation. - Configure production alerts from API/database logs and tune request limits from observed traffic. -- Replace the broad `*.supabase.co` CSP connection source if a dedicated custom API domain is introduced. +- If a dedicated custom API domain is introduced, add only that exact HTTPS origin to the production CSP. - Enable Realtime only after defining how capability-token clients are authorized to subscribe. ## Implemented frontend @@ -79,6 +80,7 @@ The UI immediately loads the latest record, keeps the editor open, and asks the - Every mutation sends the last loaded revision. A stale save loads the current activity with a visible conflict message instead of overwriting someone else's work. - Newer revisions load automatically while the live activity is visible. Polling pauses for hidden, offline, or actively-saving tabs and backs off to at most one request per minute after failures. - **Refresh latest** manually loads the current revision, and **Show QR** reopens the same live link for sharing. +- **End live sharing** invalidates the current capability immediately. Every browser that already synchronized it keeps the last recovery snapshot and can explicitly continue as a separate local activity. - While connected, Supabase is authoritative and all edits target the same revision-checked Live session. The local recovery copy is refreshed after every successful load or save. - If the browser is offline or the service cannot be reached, Tally shows the last synced copy read-only. Editing becomes available only after reconnecting or after the person explicitly chooses **Duplicate and edit**, which creates an independent local branch and leaves the Live session untouched. - If the backend confirms that the remembered Live activity no longer exists, Tally offers **Continue locally**. The recovered activity becomes a normal editable local activity and can start a new Live session with a new capability code and expiration window. @@ -90,10 +92,10 @@ Each browser's shortcut, capability, and latest full recovery snapshot are store ### Upgrade compatibility -The recovery-copy feature is additive. Existing `#live=` capability URLs, `tally:frontend:v2` activity data, and `tally:live-activity-bookmarks:v1` bookmarks keep their existing formats. After an upgraded browser successfully reconnects to a valid remembered Live activity, Tally writes the new full recovery mirror automatically and keeps the same code, edit token, revision checks, and backend RPC contract. If that first connection is temporarily unavailable, the old bookmark remains intact and **Try again** reconnects it without creating a replacement session. +The recovery-copy and revocation features are additive. Existing `#live=` capability URLs, `tally:frontend:v2` activity data, and `tally:live-activity-bookmarks:v1` bookmarks keep their existing formats. After an upgraded browser successfully reconnects to a valid remembered Live activity, Tally writes the new full recovery mirror automatically and keeps the same code, edit token, revision checks, and existing RPC contracts. Older clients continue loading and editing valid sessions; after a participant ends one, their next request receives the same established `not-found` behavior. If a first upgrade connection is temporarily unavailable, the old bookmark remains intact and **Try again** reconnects it without creating a replacement session. ## Verification - 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, and explicit duplication into an editable local branch. 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, validation, and optimistic-concurrency contract. +- 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. diff --git a/e2e/activity-lifecycle.spec.ts b/e2e/activity-lifecycle.spec.ts index 504dc5b..623f3aa 100644 --- a/e2e/activity-lifecycle.spec.ts +++ b/e2e/activity-lifecycle.spec.ts @@ -562,6 +562,7 @@ test('shares one editable backend activity across isolated browser sessions', as const editToken = 'a'.repeat(64) let revision = 1 let snapshot: unknown + let ended = false const handleLiveBackend = async (route: Route) => { const functionName = new URL(route.request().url()).pathname.split('/').at(-1) @@ -573,14 +574,28 @@ test('shares one editable backend activity across isolated browser sessions', as } if (functionName === 'create_shared_activity') { snapshot = body.p_snapshot + ended = false await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ code, edit_token: editToken, revision, snapshot, updated_at: '2026-07-14T01:00:00.000Z' }]) }) return } + if (functionName === 'end_shared_activity') { + ended = true + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ code }]) }) + return + } if (functionName === 'load_shared_activity') { + if (ended) { + await route.fulfill({ status: 404, contentType: 'application/json', body: '[]' }) + return + } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ code, revision, snapshot, updated_at: '2026-07-14T01:00:00.000Z' }]) }) return } if (functionName === 'poll_shared_activity') { + if (ended) { + await route.fulfill({ status: 404, contentType: 'application/json', body: '[]' }) + return + } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ code, revision, updated_at: '2026-07-14T01:00:00.000Z' }]) }) return } @@ -781,8 +796,28 @@ test('shares one editable backend activity across isolated browser sessions', as await expect(editor.getByRole('heading', { name: 'Shared cabin copy' })).toBeVisible() await expect(editor.getByRole('button', { name: 'Add expense' })).toBeVisible() await expect(editor.getByText('Firewood', { exact: true })).toBeVisible() - expect(browserErrors).toEqual([]) + + await page.bringToFront() + await page.getByRole('button', { name: 'Share', exact: true }).click() + await page.getByRole('dialog', { name: 'Share activity' }).getByRole('button', { name: 'End live' }).click() + const endConfirmation = page.getByRole('dialog', { name: 'End live sharing?' }) + await expect(endConfirmation).toContainText('Everyone will immediately lose access') + await endConfirmation.getByRole('button', { name: 'End live sharing' }).click() + await expect(page.getByText('Live sharing has ended')).toBeVisible() + await expect(page.getByText('Cabin fee', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Add expense' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Continue locally' })).toBeVisible() + + await observer.bringToFront() + await observer.evaluate(() => window.dispatchEvent(new Event('focus'))) + await expect(observer.getByText('Live sharing has ended')).toBeVisible() + await expect(observer.getByText('Cabin fee', { exact: true })).toBeVisible() + await expect(observer.getByRole('button', { name: 'Add expense' })).toHaveCount(0) + + const expectedRevocationErrors = browserErrors.filter(message => message === 'Failed to load resource: the server responded with a status of 404 (Not Found)') + expect(expectedRevocationErrors.length).toBeGreaterThan(0) + expect(browserErrors).toEqual(expectedRevocationErrors) await legacyContext.close() await editorContext.close() await observerContext.close() diff --git a/index.html b/index.html index fe7ab17..0541c85 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ - + Tally — Group Expense Splitter diff --git a/package-lock.json b/package-lock.json index a29d4e1..1a98e1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7684,9 +7684,9 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 50fc01f..d2c9dc9 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "brace-expansion": "5.0.9", "ejs": "6.0.1", "fast-uri": "3.1.5", - "nanoid": "3.3.15", + "nanoid": "3.3.17", "postcss": "8.5.25", "sharp": "0.35.3", "undici": "7.29.0" diff --git a/playwright.ai.config.ts b/playwright.ai.config.ts index 582f6f5..6facb85 100644 --- a/playwright.ai.config.ts +++ b/playwright.ai.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ VITE_AI_EXPENSE_ENABLED: 'true', VITE_SUPABASE_URL: aiPreviewURL, VITE_SUPABASE_PUBLISHABLE_KEY: 'test-publishable-key', + TALLY_INCLUDE_DEV_CSP: 'true', }, url: baseURL, reuseExistingServer: false, diff --git a/playwright.config.ts b/playwright.config.ts index 1312aaf..34cb55a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ ...process.env, VITE_SUPABASE_URL: 'https://live-sharing.test', VITE_SUPABASE_PUBLISHABLE_KEY: 'test-publishable-key', + TALLY_INCLUDE_DEV_CSP: 'true', }, url: baseURL, reuseExistingServer: false, diff --git a/src/App.test.tsx b/src/App.test.tsx index 7c17f6e..7f9bacc 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -38,7 +38,7 @@ const expense = (overrides: Partial = {}): Expense => ({ payerId: 'me', splitMethod: 'equal', shares: { me: 10, maya: 10, jordan: 10 }, - createdAt: 'Just now', + createdAt: '2026-07-14T01:00:00.000Z', ...overrides, }) @@ -442,6 +442,10 @@ describe('small UI building blocks', () => { expect(screen.getByText('No expenses match your search.')).toBeVisible() rerender() expect(screen.getByText((_, node) => node?.textContent === 'You paidSplit equally · 1 person')).toBeVisible() + rerender() + expect(screen.getByText('Time not recorded')).toBeVisible() + rerender() + expect(screen.getByText('Today')).toBeVisible() rerender() expect(screen.getByText('No expenses yet. Add the first one when you’re ready.')).toBeVisible() @@ -1427,6 +1431,130 @@ describe('complete app workflows', () => { expect(window.location.hash).toContain(`${LIVE_ACTIVITY_HASH_PREFIX}A1B2C3D4E5.`) }) + it('ends a Live capability while preserving the last synced recovery copy', async () => { + const user = userEvent.setup() + const credentials = { code: 'A1B2C3D4E5', editToken: 'a'.repeat(64) } + const snapshot = createSharedActivity(group, [CURRENT_USER, maya, jordan], [expense()]) + const end = vi.fn().mockResolvedValue(undefined) + const client = { + create: vi.fn(), + load: vi.fn().mockResolvedValue({ code: credentials.code, revision: 4, snapshot, updatedAt: '2026-07-14T01:00:00.000Z' }), + poll: vi.fn(), + update: vi.fn(), + end, + } satisfies LiveActivityClient + localStorage.setItem(ACTIVITY_IDENTITY_KEY, JSON.stringify({ [`live:${credentials.code}`]: 'me' })) + window.history.replaceState(null, '', new URL(buildLiveActivityUrl(credentials, 'https://pengfanz.github.io/splitbill/')).hash) + render() + + expect(await screen.findByText('Live · revision 4')).toBeVisible() + await chooseShareAction(user, 'End live') + const confirmation = screen.getByRole('dialog', { name: 'End live sharing?' }) + expect(within(confirmation).getByText(/Everyone will immediately lose access/)).toBeVisible() + await user.click(within(confirmation).getByRole('button', { name: 'End live sharing' })) + + await waitFor(() => expect(end).toHaveBeenCalledWith(credentials)) + expect(await screen.findByText('Live sharing has ended')).toBeVisible() + expect(screen.getByRole('status')).toHaveTextContent('last synced copy remains safe') + expect(screen.getByText('Dinner', { exact: true })).toBeVisible() + expect(screen.getByRole('button', { name: 'Continue locally' })).toBeVisible() + expect(screen.queryByRole('button', { name: 'Add expense' })).not.toBeInTheDocument() + }) + + it('opens another saved Live activity after ending the current one', async () => { + const user = userEvent.setup() + const endedCredentials = { code: 'A1B2C3D4E5', editToken: 'a'.repeat(64) } + const activeCredentials = { code: 'F6E5D4C3B2', editToken: 'b'.repeat(64) } + const cabin: ActivityGroup = { id: 'cabin', name: 'Cabin', emoji: '△', memberIds: ['me', 'maya'] } + const endedSnapshot = createSharedActivity(group, [CURRENT_USER, maya, jordan], [expense()]) + const activeSnapshot = createSharedActivity(cabin, [CURRENT_USER, maya], []) + const load = vi.fn().mockImplementation(async (credentials: typeof endedCredentials) => credentials.code === endedCredentials.code + ? { code: endedCredentials.code, revision: 4, snapshot: endedSnapshot, updatedAt: '2026-07-14T01:00:00.000Z' } + : { code: activeCredentials.code, revision: 7, snapshot: activeSnapshot, updatedAt: '2026-07-14T02:00:00.000Z' }) + const client = { + create: vi.fn(), + load, + poll: vi.fn(), + update: vi.fn(), + end: vi.fn().mockResolvedValue(undefined), + } satisfies LiveActivityClient + localStorage.setItem(STORAGE_KEY, JSON.stringify(storedState({ groups: [group, cabin] }))) + localStorage.setItem(LIVE_ACTIVITY_BOOKMARKS_KEY, JSON.stringify({ + [group.id]: endedCredentials, + [cabin.id]: activeCredentials, + })) + localStorage.setItem(ACTIVITY_IDENTITY_KEY, JSON.stringify({ + [`live:${endedCredentials.code}`]: 'me', + [`live:${activeCredentials.code}`]: 'me', + })) + window.history.replaceState(null, '', new URL(buildLiveActivityUrl(endedCredentials, 'https://pengfanz.github.io/splitbill/')).hash) + render() + + expect(await screen.findByText('Live · revision 4')).toBeVisible() + await chooseShareAction(user, 'End live') + await user.click(within(screen.getByRole('dialog', { name: 'End live sharing?' })).getByRole('button', { name: 'End live sharing' })) + expect(await screen.findByText('Live sharing has ended')).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'Open Cabin activity' })) + + expect(await screen.findByRole('heading', { name: 'Cabin' })).toBeVisible() + expect(await screen.findByText('Live · revision 7')).toBeVisible() + expect(load).toHaveBeenCalledWith(activeCredentials) + }) + + it.each([ + ['network', 'Could not reach the live activity service. Check your connection and try again.'], + ['invalid-input', 'One of the activity fields is too long or the amount is above the supported limit. Update it and try again.'], + ] as const)('keeps end confirmation open after a %s failure', async (kind, message) => { + const user = userEvent.setup() + const credentials = { code: 'A1B2C3D4E5', editToken: 'a'.repeat(64) } + const snapshot = createSharedActivity(group, [CURRENT_USER, maya, jordan], [expense()]) + const end = vi.fn().mockRejectedValue(new LiveActivityApiError(kind, 'failed')) + const client = { + create: vi.fn(), + load: vi.fn().mockResolvedValue({ code: credentials.code, revision: 4, snapshot, updatedAt: '2026-07-14T01:00:00.000Z' }), + poll: vi.fn(), + update: vi.fn(), + end, + } satisfies LiveActivityClient + localStorage.setItem(ACTIVITY_IDENTITY_KEY, JSON.stringify({ [`live:${credentials.code}`]: 'me' })) + window.history.replaceState(null, '', new URL(buildLiveActivityUrl(credentials, 'https://pengfanz.github.io/splitbill/')).hash) + render() + + expect(await screen.findByText('Live · revision 4')).toBeVisible() + await chooseShareAction(user, 'End live') + const confirmation = screen.getByRole('dialog', { name: 'End live sharing?' }) + await user.click(within(confirmation).getByRole('button', { name: 'End live sharing' })) + + expect(await screen.findByRole('status')).toHaveTextContent(message) + expect(screen.getByRole('dialog', { name: 'End live sharing?' })).toBeVisible() + expect(screen.getByText('Dinner', { exact: true })).toBeVisible() + }) + + it('treats an already-ended Live capability as an idempotent success', async () => { + const user = userEvent.setup() + const credentials = { code: 'A1B2C3D4E5', editToken: 'a'.repeat(64) } + const snapshot = createSharedActivity(group, [CURRENT_USER, maya, jordan], [expense()]) + const client = { + create: vi.fn(), + load: vi.fn().mockResolvedValue({ code: credentials.code, revision: 4, snapshot, updatedAt: '2026-07-14T01:00:00.000Z' }), + poll: vi.fn(), + update: vi.fn(), + end: vi.fn().mockRejectedValue(new LiveActivityApiError('not-found', 'already ended')), + } satisfies LiveActivityClient + localStorage.setItem(ACTIVITY_IDENTITY_KEY, JSON.stringify({ [`live:${credentials.code}`]: 'me' })) + window.history.replaceState(null, '', new URL(buildLiveActivityUrl(credentials, 'https://pengfanz.github.io/splitbill/')).hash) + render() + + expect(await screen.findByText('Live · revision 4')).toBeVisible() + await chooseShareAction(user, 'End live') + await user.click(within(screen.getByRole('dialog', { name: 'End live sharing?' })).getByRole('button', { name: 'End live sharing' })) + + expect(await screen.findByText('Live sharing has ended')).toBeVisible() + expect(screen.queryByRole('dialog', { name: 'End live sharing?' })).not.toBeInTheDocument() + expect(screen.getByText('Dinner', { exact: true })).toBeVisible() + }) + it('shows an offline Live mirror as read-only and creates an explicit editable branch', async () => { const user = userEvent.setup() const credentials = { code: 'A1B2C3D4E5', editToken: 'a'.repeat(64) } diff --git a/src/App.tsx b/src/App.tsx index 72745b9..9d8383b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,7 +58,7 @@ type AppProps = { type ConfirmationRequest = { confirmLabel: string description: string - onConfirm: () => void | Promise + onConfirm: () => boolean | void | Promise title: string } @@ -138,6 +138,7 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, liveActi ? selectedMembers.length : 0 const displayedLiveNotice = live.displayedNotice + const liveEnd = live.end const liveActivityCodes = live.activityCodes const bookmarkedLiveGroupId = live.bookmarkedGroupId const analyticsSurface: AnalyticsSurface = live.credentials ? 'live' : 'local' @@ -369,13 +370,22 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, liveActi const confirmRequest = async (request: ConfirmationRequest) => { setConfirmationBusy(true) try { - await request.onConfirm() - setConfirmation(null) + const confirmed = await request.onConfirm() + if (confirmed !== false) setConfirmation(null) } finally { setConfirmationBusy(false) } } + const endLiveActivity = (end: NonNullable) => { + setConfirmation({ + title: t('confirm.endLiveTitle'), + description: t('confirm.endLive'), + confirmLabel: t('confirm.endLiveAction'), + onConfirm: end, + }) + } + const deleteExpense = (expense: Expense) => { const label = isSettlementPayment(expense) ? t('confirm.deleteSettlementLabel') : t('confirm.deleteExpenseLabel', { title: expense.title }) setConfirmation({ @@ -500,6 +510,7 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, liveActi onCurrencyChange={live.editable ? changeActivityCurrency : undefined} onShareQr={live.editable && liveSession ? () => sharing.openCurrentLiveQr(liveSession) : undefined} onCopyShareLink={live.editable && liveSession ? () => sharing.copyCurrentLiveLink(liveSession) : undefined} + onEndLive={live.editable && liveEnd ? () => endLiveActivity(liveEnd) : undefined} onShareSummary={() => sharing.shareGroup(liveActivity.group, liveMembers, liveActivity.expenses, 'live')} onAddFriend={live.editable ? () => setModal('friend') : undefined} onAddExpense={live.editable ? openNewExpense : undefined} diff --git a/src/features/activity/ActivityDashboard.tsx b/src/features/activity/ActivityDashboard.tsx index 63138e2..cfdec88 100644 --- a/src/features/activity/ActivityDashboard.tsx +++ b/src/features/activity/ActivityDashboard.tsx @@ -156,7 +156,7 @@ export function MembersRail({ members, currentMemberId = 'me', readOnly = false, ) } -export function GroupDashboard({ group, members, expenses, query, activityFeedback, readOnly = false, readOnlyLabel, currentMemberId = 'me', currentUserLabel = 'You', statusLabel, onCurrentMemberChange, onCurrencyChange, onShareSummary, onShareQr, onShareLive, onCopyShareLink, onAddFriend, onAddExpense, onSettleUp, onEditExpense, onDeleteExpense }: { +export function GroupDashboard({ group, members, expenses, query, activityFeedback, readOnly = false, readOnlyLabel, currentMemberId = 'me', currentUserLabel = 'You', statusLabel, onCurrentMemberChange, onCurrencyChange, onShareSummary, onShareQr, onShareLive, onCopyShareLink, onEndLive, onAddFriend, onAddExpense, onSettleUp, onEditExpense, onDeleteExpense }: { group: ActivityGroup members: Member[] expenses: Expense[] @@ -173,6 +173,7 @@ export function GroupDashboard({ group, members, expenses, query, activityFeedba onShareQr?: () => void onShareLive?: () => void onCopyShareLink?: () => void + onEndLive?: () => void onAddFriend?: () => void onAddExpense?: () => void onSettleUp?: (settlement: Settlement) => void @@ -229,6 +230,7 @@ export function GroupDashboard({ group, members, expenses, query, activityFeedba onCopyLink={onCopyShareLink} onShowQr={onShareQr} onShareSummary={onShareSummary} + onEndLive={onEndLive} /> : null} ) diff --git a/src/features/aiExpense/aiExpenseApi.test.ts b/src/features/aiExpense/aiExpenseApi.test.ts index 1c3376b..33282ec 100644 --- a/src/features/aiExpense/aiExpenseApi.test.ts +++ b/src/features/aiExpense/aiExpenseApi.test.ts @@ -180,6 +180,7 @@ describe('AI expense API client', () => { ['provider_error', 'model-unavailable'], ['provider_unavailable', 'model-unavailable'], ['provider_payment_required', 'credits'], + ['ai_budget_exceeded', 'credits'], ] as const)('maps backend code %s to %s', async (code, kind) => { fetcher.mockResolvedValue(response({ code, message: 'Safe backend message' }, 503)) const client = createAiExpenseClient({ supabaseUrl: 'https://project.supabase.co', publishableKey: 'key' }, fetcher) diff --git a/src/features/aiExpense/aiExpenseApi.ts b/src/features/aiExpense/aiExpenseApi.ts index 68fc6da..81eac3e 100644 --- a/src/features/aiExpense/aiExpenseApi.ts +++ b/src/features/aiExpense/aiExpenseApi.ts @@ -41,7 +41,7 @@ function errorKind(status: number, payload: unknown): AiExpenseApiErrorKind { const code = isRecord(payload) && typeof payload.code === 'string' ? payload.code : '' if (status === 400 || status === 413 || status === 422 || code === 'invalid_model_response') return 'invalid-input' if (status === 429) return 'rate-limit' - if (code === 'provider_payment_required') return 'credits' + if (code === 'provider_payment_required' || code === 'ai_budget_exceeded') return 'credits' if (['model_unavailable', 'provider_error', 'provider_unavailable'].includes(code)) return 'model-unavailable' return 'unavailable' } diff --git a/src/features/aiExpense/parseExpenseHandler.test.ts b/src/features/aiExpense/parseExpenseHandler.test.ts index 345e1fc..36d3adb 100644 --- a/src/features/aiExpense/parseExpenseHandler.test.ts +++ b/src/features/aiExpense/parseExpenseHandler.test.ts @@ -98,7 +98,7 @@ function dependencies(overrides: Partial = {}) OPENROUTER_API_KEY: 'secret-key', } return { - consumeQuota: vi.fn().mockResolvedValue(true), + consumeQuota: vi.fn().mockResolvedValue('allowed'), fetcher: vi.fn().mockResolvedValue(providerResponse()), getEnvironment: vi.fn((name: string) => environment[name]), reportProviderFailure: vi.fn(), @@ -203,24 +203,37 @@ describe('parse expense Edge Function handler', () => { }) it('enforces the server quota using a normalized client identifier', async () => { - const consumeQuota = vi.fn().mockResolvedValue(false) + const consumeQuota = vi.fn().mockResolvedValue('client-limit') const response = await handleParseExpenseRequest(request(requestBody, { 'cf-connecting-ip': ' 203.0.113.8 ', }), dependencies({ consumeQuota })) expect(response.status).toBe(429) expect(consumeQuota).toHaveBeenCalledWith('203.0.113.8', 'text') - const forwardedQuota = vi.fn().mockResolvedValue(false) + const forwardedQuota = vi.fn().mockResolvedValue('client-limit') await handleParseExpenseRequest(request(requestBody, { 'x-forwarded-for': '198.51.100.4, 10.0.0.1' }), dependencies({ consumeQuota: forwardedQuota })) - expect(forwardedQuota).toHaveBeenCalledWith('198.51.100.4', 'text') + expect(forwardedQuota).toHaveBeenCalledWith('unknown-client', 'text') - const unknownQuota = vi.fn().mockResolvedValue(false) + const unknownQuota = vi.fn().mockResolvedValue('client-limit') await handleParseExpenseRequest(request(), dependencies({ consumeQuota: unknownQuota })) expect(unknownQuota).toHaveBeenCalledWith('unknown-client', 'text') - const blankQuota = vi.fn().mockResolvedValue(false) + const blankQuota = vi.fn().mockResolvedValue('client-limit') await handleParseExpenseRequest(request(requestBody, { 'cf-connecting-ip': ' ' }), dependencies({ consumeQuota: blankQuota })) expect(blankQuota).toHaveBeenCalledWith('unknown-client', 'text') + + const malformedQuota = vi.fn().mockResolvedValue('client-limit') + await handleParseExpenseRequest(request(requestBody, { 'cf-connecting-ip': 'forged-client' }), dependencies({ consumeQuota: malformedQuota })) + expect(malformedQuota).toHaveBeenCalledWith('unknown-client', 'text') + }) + + it('distinguishes a project budget ceiling from a single-client rate limit', async () => { + const response = await handleParseExpenseRequest(request(), dependencies({ + consumeQuota: vi.fn().mockResolvedValue('global-limit'), + })) + + expect(response.status).toBe(503) + expect(await response.json()).toMatchObject({ code: 'ai_budget_exceeded' }) }) it('fails closed when the quota service is unavailable', async () => { diff --git a/src/features/aiExpense/parseExpenseHandler.ts b/src/features/aiExpense/parseExpenseHandler.ts index fd8ca7b..28ed792 100644 --- a/src/features/aiExpense/parseExpenseHandler.ts +++ b/src/features/aiExpense/parseExpenseHandler.ts @@ -35,7 +35,7 @@ export const AI_EXPENSE_CORS_HEADERS = { type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise export type ParseExpenseHandlerDependencies = { - consumeQuota: (identifier: string, inputMode: 'text' | 'voice') => Promise + consumeQuota: (identifier: string, inputMode: 'text' | 'voice') => Promise fetcher?: Fetcher getEnvironment: (name: string) => string | undefined reportProviderFailure?: (failure: { @@ -45,6 +45,8 @@ export type ParseExpenseHandlerDependencies = { }) => void } +export type AiExpenseQuotaResult = 'allowed' | 'client-limit' | 'global-limit' + const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' const MAX_TEXT_REQUEST_BYTES = 32 * 1024 const MAX_VOICE_REQUEST_BYTES = 3 * 1024 * 1024 @@ -61,10 +63,10 @@ function isRecord(value: unknown): value is Record { } function requestIdentifier(request: Request) { - const forwarded = request.headers.get('cf-connecting-ip') - ?? request.headers.get('x-forwarded-for')?.split(',')[0] - ?? 'unknown-client' - return forwarded.trim().slice(0, 200) || 'unknown-client' + const candidate = request.headers.get('cf-connecting-ip')?.trim() ?? '' + return candidate.length <= 64 && /^[0-9a-f:.]+$/i.test(candidate) + ? candidate + : 'unknown-client' } function providerFailureResponse(status: number, errorType: string | null) { @@ -154,9 +156,13 @@ export async function handleParseExpenseRequest( } try { - if (!await dependencies.consumeQuota(requestIdentifier(request), parsedRequest.inputMode)) { + const quota = await dependencies.consumeQuota(requestIdentifier(request), parsedRequest.inputMode) + if (quota === 'client-limit') { return jsonError(429, 'rate_limit_exceeded', 'Too many AI requests. Try again in a few minutes.') } + if (quota === 'global-limit') { + return jsonError(503, 'ai_budget_exceeded', 'The project AI budget is temporarily unavailable.') + } } catch { return jsonError(503, 'rate_limit_unavailable', 'AI expense entry is temporarily unavailable.') } diff --git a/src/features/changelog/changelog.test.ts b/src/features/changelog/changelog.test.ts index 40b93e0..e138947 100644 --- a/src/features/changelog/changelog.test.ts +++ b/src/features/changelog/changelog.test.ts @@ -23,11 +23,10 @@ describe('changelog', () => { it('keeps the latest release first and gives every item a stable localized shape', () => { expect(LATEST_CHANGELOG_ID).toBe(CHANGELOG_ENTRIES[0].id) expect(CHANGELOG_ENTRIES[0]).toMatchObject({ - releasedOn: '2026-08-02', + releasedOn: '2026-08-08', items: [ - { icon: 'aiText' }, - { icon: 'aiVoice' }, - { icon: 'aiReview' }, + { icon: 'live' }, + { icon: 'polish' }, ], }) }) diff --git a/src/features/changelog/changelog.ts b/src/features/changelog/changelog.ts index 5d63c55..5af0a80 100644 --- a/src/features/changelog/changelog.ts +++ b/src/features/changelog/changelog.ts @@ -17,6 +17,24 @@ export type ChangelogEntry = { } export const CHANGELOG_ENTRIES: readonly ChangelogEntry[] = [ + { + id: '2026-08-live-controls', + releasedOn: '2026-08-08', + titleKey: 'changelog.release.liveControlsTitle', + summaryKey: 'changelog.release.liveControlsSummary', + items: [ + { + icon: 'live', + titleKey: 'changelog.item.endLiveTitle', + descriptionKey: 'changelog.item.endLiveDescription', + }, + { + icon: 'polish', + titleKey: 'changelog.item.saferSharingTitle', + descriptionKey: 'changelog.item.saferSharingDescription', + }, + ], + }, { id: '2026-08-ai-entry', releasedOn: '2026-08-02', diff --git a/src/features/liveSharing/liveActivityApi.test.ts b/src/features/liveSharing/liveActivityApi.test.ts index 8a6c5a8..874b729 100644 --- a/src/features/liveSharing/liveActivityApi.test.ts +++ b/src/features/liveSharing/liveActivityApi.test.ts @@ -36,18 +36,20 @@ describe('live activity API client', () => { fetcher.mockReset() }) - it('creates, loads, and revision-updates a shared backend activity', async () => { + it('creates, loads, revision-updates, and ends a shared backend activity', async () => { fetcher .mockResolvedValueOnce(response([row()])) .mockResolvedValueOnce(response([row({ edit_token: undefined })])) .mockResolvedValueOnce(response([row({ edit_token: undefined, snapshot: undefined })])) .mockResolvedValueOnce(response([row({ edit_token: undefined, revision: 2, conflicted: false })])) + .mockResolvedValueOnce(response([{ code: credentials.code }])) const client = createLiveActivityClient({ supabaseUrl: ' https://project.supabase.co/// ', publishableKey: ' publishable ' }, fetcher) await expect(client.create(snapshot)).resolves.toEqual({ ...credentials, revision: 1, snapshot, updatedAt }) await expect(client.load(credentials)).resolves.toEqual({ code: credentials.code, revision: 1, snapshot, updatedAt }) await expect(client.poll(credentials)).resolves.toEqual({ code: credentials.code, revision: 1, updatedAt }) await expect(client.update(credentials, snapshot, 1)).resolves.toEqual({ code: credentials.code, revision: 2, snapshot, updatedAt }) + await expect(client.end(credentials)).resolves.toBeUndefined() expect(fetcher).toHaveBeenNthCalledWith(1, 'https://project.supabase.co/rest/v1/rpc/create_shared_activity', expect.objectContaining({ method: 'POST', @@ -67,6 +69,8 @@ describe('live activity API client', () => { expect(JSON.parse(fetcher.mock.calls[2][1]?.body as string)).toEqual({ p_code: credentials.code, p_edit_token: credentials.editToken }) expect(fetcher.mock.calls[3][0]).toBe('https://project.supabase.co/rest/v1/rpc/update_shared_activity_v3') expect(JSON.parse(fetcher.mock.calls[3][1]?.body as string)).toMatchObject({ p_expected_revision: 1, p_snapshot: snapshot }) + expect(fetcher.mock.calls[4][0]).toBe('https://project.supabase.co/rest/v1/rpc/end_shared_activity') + expect(JSON.parse(fetcher.mock.calls[4][1]?.body as string)).toEqual({ p_code: credentials.code, p_edit_token: credentials.editToken }) }) it.each([ @@ -88,12 +92,20 @@ describe('live activity API client', () => { await expectApiError(client.create(invalidSnapshot), 'invalid-input') await expectApiError(client.load(invalidCredentials), 'invalid-input') await expectApiError(client.poll(invalidCredentials), 'invalid-input') + await expectApiError(client.end(invalidCredentials), 'invalid-input') await expectApiError(client.update(credentials, invalidSnapshot, 1), 'invalid-input') await expectApiError(client.update(credentials, snapshot, 0), 'invalid-input') await expectApiError(client.update(credentials, snapshot, 1.5), 'invalid-input') expect(fetcher).not.toHaveBeenCalled() }) + it('rejects a malformed end result', async () => { + fetcher.mockResolvedValue(response([{ code: 'B1C2D3E4F5' }])) + const client = createLiveActivityClient({ supabaseUrl: 'https://project.supabase.co', publishableKey: 'key' }, fetcher) + + await expectApiError(client.end(credentials), 'invalid-response') + }) + it.each([ ['40001', 400, 'conflict'], ['PT409', 409, 'conflict'], diff --git a/src/features/liveSharing/liveActivityApi.ts b/src/features/liveSharing/liveActivityApi.ts index d356f2f..3b50009 100644 --- a/src/features/liveSharing/liveActivityApi.ts +++ b/src/features/liveSharing/liveActivityApi.ts @@ -100,6 +100,12 @@ function parseRecord(value: unknown, requireToken: boolean): CreatedLiveActivity return requireToken ? { ...record, editToken: row.edit_token as string } : record } +function parseEndedActivity(value: unknown, credentials: LiveActivityCredentials) { + if (!isRecord(value) || value.code !== credentials.code) { + throw new LiveActivityApiError('invalid-response', 'The live activity service returned an invalid revocation result.') + } +} + function assertSnapshot(snapshot: SharedActivity) { if (!isSharedActivity(snapshot)) throw new LiveActivityApiError('invalid-input', 'A valid activity snapshot is required.') } @@ -203,5 +209,12 @@ export function createLiveActivityClient(configuration: ApiConfiguration, fetche } return record }, + async end(credentials: LiveActivityCredentials): Promise { + assertCredentials(credentials) + parseEndedActivity(await rpc('end_shared_activity', { + p_code: credentials.code, + p_edit_token: credentials.editToken, + }), credentials) + }, } } diff --git a/src/features/liveSharing/liveActivityConfig.ts b/src/features/liveSharing/liveActivityConfig.ts index 2904eb5..7d4fbd9 100644 --- a/src/features/liveSharing/liveActivityConfig.ts +++ b/src/features/liveSharing/liveActivityConfig.ts @@ -1,6 +1,11 @@ import { createLiveActivityClient } from './liveActivityApi' -export type LiveActivityClient = ReturnType +type ConfiguredLiveActivityClient = ReturnType + +// Keep injected preview/test clients created before revocation support usable. +// Configured production clients always include `end`. +export type LiveActivityClient = Omit + & Partial> type LiveActivityEnvironment = { VITE_SUPABASE_URL?: string diff --git a/src/features/liveSharing/useLiveActivitySession.ts b/src/features/liveSharing/useLiveActivitySession.ts index 23f1bb5..e612897 100644 --- a/src/features/liveSharing/useLiveActivitySession.ts +++ b/src/features/liveSharing/useLiveActivitySession.ts @@ -97,10 +97,17 @@ export function useLiveActivitySession({ }) => activeClient.load(activeCredentials), }) + const endMutation = useMutation({ + mutationFn: ({ activeClient, activeCredentials }: { + activeClient: Required> + activeCredentials: LiveActivityCredentials + }) => activeClient.end(activeCredentials), + }) + const queryKey = credentials ? liveActivityQueryKey(credentials) : ['live-activity', 'inactive'] as const const liveQuery = useQuery({ queryKey, - enabled: Boolean(client && credentials && browserOnline && !updateMutation.isPending), + enabled: Boolean(client && credentials && browserOnline && !liveEnded && !updateMutation.isPending), queryFn: client && credentials ? async () => { const cachedRecord = queryClient.getQueryData(liveActivityQueryKey(credentials)) @@ -200,6 +207,8 @@ export function useLiveActivitySession({ queryClient.removeQueries({ queryKey }) clearLiveActivityHash() setCredentials(null) + setConnectionBlocked(false) + setLiveEnded(false) setNotice(null) } @@ -210,6 +219,8 @@ export function useLiveActivitySession({ window.history.replaceState(null, '', buildLiveActivityUrl(bookmarkedCredentials)) setPersistedState(current => ({ ...current, selectedGroupId: groupId })) setCredentials(bookmarkedCredentials) + setConnectionBlocked(false) + setLiveEnded(false) setNotice(null) return true } @@ -304,6 +315,32 @@ export function useLiveActivitySession({ const ended = liveEnded || queryEnded const blocked = connectionBlocked || queryConnectionBlocked const editable = Boolean(session && browserOnline && !blocked && !ended) + const endActivity = client?.end + const end = endActivity && credentials && session && browserOnline && !blocked && !ended + ? async () => { + try { + await queryClient.cancelQueries({ queryKey: liveActivityQueryKey(credentials) }) + await endMutation.mutateAsync({ + activeClient: { end: endActivity }, + activeCredentials: credentials, + }) + setConnectionBlocked(false) + setLiveEnded(true) + setNotice(t('live.endedByUser')) + return true + } catch (error) { + if (error instanceof LiveActivityApiError && error.kind === 'not-found') { + setLiveEnded(true) + setNotice(t('live.endedByUser')) + return true + } else if (isConnectivityError(error)) { + setConnectionBlocked(true) + } + setNotice(liveActivityErrorMessage(error, t)) + return false + } + } + : undefined const connectionState: LiveActivityConnectionState | null = !credentials ? null : ended && mirror @@ -336,6 +373,7 @@ export function useLiveActivitySession({ credentials, displayedNotice, editable, + end, loading: Boolean(client && credentials && !session && liveQuery.isPending) || refreshMutation.isPending, members, mirror, diff --git a/src/features/sharing/ShareActivityMenu.test.tsx b/src/features/sharing/ShareActivityMenu.test.tsx index 609c7fe..2c38df8 100644 --- a/src/features/sharing/ShareActivityMenu.test.tsx +++ b/src/features/sharing/ShareActivityMenu.test.tsx @@ -33,14 +33,17 @@ describe('ShareActivityMenu', () => { const user = userEvent.setup() const onClose = vi.fn() const onCopyLink = vi.fn().mockResolvedValue(undefined) - const { container } = render() + const onEndLive = vi.fn().mockResolvedValue(undefined) + const { container } = render() expect(screen.getByText('Invite people to edit live')).toBeVisible() expect(screen.getByRole('button', { name: 'Copy live invite link' })).toBeVisible() + expect(screen.getByRole('button', { name: 'End live' })).toBeVisible() expect(screen.queryByText(/snapshot/i)).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Start live activity' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Show live QR' })).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Copy live invite link' })) + await user.click(screen.getByRole('button', { name: 'End live' })) await user.click(screen.getByRole('button', { name: 'Cancel' })) fireEvent.mouseDown(container.querySelector('.modal')!) fireEvent.mouseDown(container.querySelector('.modal-backdrop')!) @@ -48,7 +51,8 @@ describe('ShareActivityMenu', () => { fireEvent.keyDown(document, { key: 'Escape' }) expect(onCopyLink).toHaveBeenCalledOnce() - expect(onClose).toHaveBeenCalledTimes(4) + expect(onEndLive).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledTimes(5) }) it('renders available Live actions independently and hides the section when none exist', async () => { diff --git a/src/features/sharing/ShareActivityMenu.tsx b/src/features/sharing/ShareActivityMenu.tsx index 98c83c1..92a19a9 100644 --- a/src/features/sharing/ShareActivityMenu.tsx +++ b/src/features/sharing/ShareActivityMenu.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from 'react' -import { ChevronRight, Copy, QrCode, Radio, Share2 } from 'lucide-react' +import { ChevronRight, CircleStop, Copy, QrCode, Radio, Share2 } from 'lucide-react' import { Button } from '../../components/Button' import { ModalShell } from '../../components/Dialog' import { useLocalization } from '../../i18n/LocalizationContext' @@ -53,7 +53,7 @@ function ShareSummaryAction({ icon, title, description, onClick }: { ) } -export function ShareActivityMenu({ groupName, live = false, onClose, onCollaborateLive, onCopyLink, onShowQr, onShareSummary }: { +export function ShareActivityMenu({ groupName, live = false, onClose, onCollaborateLive, onCopyLink, onShowQr, onShareSummary, onEndLive }: { groupName: string live?: boolean onClose: () => void @@ -61,6 +61,7 @@ export function ShareActivityMenu({ groupName, live = false, onClose, onCollabor onCopyLink?: ShareAction onShowQr?: ShareAction onShareSummary?: ShareAction + onEndLive?: ShareAction }) { const { t } = useLocalization() const run = (action: ShareAction) => { @@ -100,6 +101,12 @@ export function ShareActivityMenu({ groupName, live = false, onClose, onCollabor ) : null} {onShareSummary ?
{t('shareMenu.otherTitle')}} title={t('shareMenu.summary')} description={t('shareMenu.summaryHelp')} onClick={() => run(onShareSummary)} />
: null} + {live && onEndLive ? ( +
+ {t('shareMenu.endLive')}{t('shareMenu.endLiveHelp')} + +
+ ) : null} ) diff --git a/src/features/sharing/sharedActivitySchema.test.ts b/src/features/sharing/sharedActivitySchema.test.ts index ab749ed..587774d 100644 --- a/src/features/sharing/sharedActivitySchema.test.ts +++ b/src/features/sharing/sharedActivitySchema.test.ts @@ -29,6 +29,10 @@ describe('shared activity schema', () => { ...activity, expenses: [{ ...expense, updatedAt: 'not-a-date' }], }).success).toBe(false) + expect(sharedActivitySchema.safeParse({ + ...activity, + expenses: [{ ...expense, createdAt: 'not-a-date' }], + }).success).toBe(false) expect(sharedActivitySchema.safeParse({ ...activity, group: { ...activity.group, memberIds: ['me', 'missing'] }, @@ -47,6 +51,41 @@ describe('shared activity schema', () => { }).success).toBe(false) }) + it('rejects ambiguous identities and participant sets', () => { + expect(sharedActivitySchema.safeParse({ + ...activity, + sender: { ...CURRENT_USER, id: 'someone-else' }, + }).success).toBe(false) + expect(sharedActivitySchema.safeParse({ + ...activity, + friends: [friend, { ...friend }], + }).success).toBe(false) + expect(sharedActivitySchema.safeParse({ + ...activity, + group: { ...activity.group, memberIds: ['me', 'maya', 'maya'] }, + }).success).toBe(false) + expect(sharedActivitySchema.safeParse({ + ...activity, + group: { ...activity.group, memberIds: ['me'] }, + }).success).toBe(false) + expect(sharedActivitySchema.safeParse({ + ...activity, + expenses: [expense, { ...expense }], + }).success).toBe(false) + }) + + it('rejects invalid amounts and incomplete split totals', () => { + const invalidExpenses = [ + { ...expense, amount: -1, shares: { me: -0.5, maya: -0.5 } }, + { ...expense, shares: {} }, + { ...expense, shares: { me: 4, maya: 5 } }, + { ...expense, shares: { me: 5, maya: -1 } }, + ] + invalidExpenses.forEach(invalidExpense => { + expect(sharedActivitySchema.safeParse({ ...activity, expenses: [invalidExpense] }).success).toBe(false) + }) + }) + it('accepts only well-formed settlement payments', () => { const settlement = { ...expense, @@ -85,7 +124,7 @@ describe('shared activity schema', () => { payerId: 'me', splitMethod: 'equal', shares: { me: 1 }, - createdAt: 'Today', + createdAt: '2026-07-29T01:00:00.000Z', })), } diff --git a/src/features/sharing/sharedActivitySchema.ts b/src/features/sharing/sharedActivitySchema.ts index be934a3..6cadd68 100644 --- a/src/features/sharing/sharedActivitySchema.ts +++ b/src/features/sharing/sharedActivitySchema.ts @@ -30,6 +30,7 @@ const groupSchema = z.object({ currency: z.enum(SUPPORTED_CURRENCIES).optional(), }).passthrough() const amountSchema = z.number().min(0).max(MAX_ACTIVITY_AMOUNT) +const activityTimestampSchema = z.iso.datetime({ offset: true }).max(120) const expenseSchema = z.object({ id: z.string().min(1).max(120), groupId: z.string().min(1).max(120), @@ -38,12 +39,16 @@ const expenseSchema = z.object({ payerId: memberIdSchema, splitMethod: z.enum(['equal', 'exact']), shares: z.record(memberIdSchema, amountSchema), - createdAt: z.string(), - updatedAt: z.string().refine(value => Number.isFinite(Date.parse(value))).optional(), + createdAt: activityTimestampSchema, + updatedAt: activityTimestampSchema.optional(), kind: z.enum(['expense', 'settlement']).optional(), }).passthrough().superRefine((expense, context) => { - if (expense.kind !== 'settlement') return const recipients = Object.entries(expense.shares) + const shareTotal = recipients.reduce((total, [, amount]) => total + amount, 0) + if (recipients.length === 0 || Math.abs(shareTotal - expense.amount) >= 0.005) { + context.addIssue({ code: 'custom', message: 'Expense shares must equal the expense amount' }) + } + if (expense.kind !== 'settlement') return if (expense.amount <= 0 || expense.splitMethod !== 'exact' || recipients.length !== 1 @@ -63,11 +68,18 @@ function validateActivityReferences( activity: z.infer>, context: z.RefinementCtx, ) { - const memberIds = new Set(['me', ...activity.friends.map(friend => friend.id)]) - const valid = activity.group.memberIds.every(memberId => memberIds.has(memberId)) + const allMemberIds = ['me', ...activity.friends.map(friend => friend.id)] + const memberIds = new Set(allMemberIds) + const groupMemberIds = new Set(activity.group.memberIds) + const expenseIds = new Set(activity.expenses.map(expense => expense.id)) + const valid = memberIds.size === allMemberIds.length + && groupMemberIds.size === activity.group.memberIds.length + && expenseIds.size === activity.expenses.length + && groupMemberIds.size === memberIds.size + && allMemberIds.every(memberId => groupMemberIds.has(memberId)) && activity.expenses.every(expense => expense.groupId === activity.group.id - && memberIds.has(expense.payerId) - && Object.keys(expense.shares).every(memberId => memberIds.has(memberId))) + && groupMemberIds.has(expense.payerId) + && Object.keys(expense.shares).every(memberId => groupMemberIds.has(memberId))) if (!valid) context.addIssue({ code: 'custom', message: 'Invalid activity references' }) } diff --git a/src/i18n/localization.ts b/src/i18n/localization.ts index ee700f6..fe34cfe 100644 --- a/src/i18n/localization.ts +++ b/src/i18n/localization.ts @@ -72,6 +72,12 @@ const en = { 'changelog.eyebrow': 'Latest updates', 'changelog.title': 'What’s new in Tally', 'changelog.confirm': 'Got it', + 'changelog.release.liveControlsTitle': 'More control over Live sharing', + 'changelog.release.liveControlsSummary': 'End an invite whenever you need to, without losing the last copy saved on devices that joined.', + 'changelog.item.endLiveTitle': 'End a Live activity', + 'changelog.item.endLiveDescription': 'Disable the current invite link for everyone while keeping each device’s recovery copy.', + 'changelog.item.saferSharingTitle': 'Safer shared activity data', + 'changelog.item.saferSharingDescription': 'Stronger checks now keep participant, expense, split, settlement, and timestamp data consistent.', 'changelog.release.aiTitle': 'Add expenses by typing or talking', 'changelog.release.aiSummary': 'Manual entry is still the default. When it is faster, Tally can now turn a description or short recording into one or several drafts for you to review.', 'changelog.item.aiTextTitle': 'Describe several expenses', @@ -268,6 +274,9 @@ const en = { 'shareMenu.otherTitle': 'Just need to send the result?', 'shareMenu.summary': 'Share balances only', 'shareMenu.summaryHelp': 'Send totals and who owes whom without sharing activity access.', + 'shareMenu.endLive': 'End live sharing', + 'shareMenu.endLiveHelp': 'The current invite link will stop working. Recovery copies stay on devices that opened it.', + 'shareMenu.endLiveAction': 'End live', 'live.label': 'Live activity', 'live.title': 'Live activity · {code}', 'live.opening': 'Opening live activity', @@ -313,6 +322,7 @@ const en = { 'live.updatedExpense': '{title} was updated. Splits and balances were recalculated.', 'live.deletedExpense': '{title} was deleted from the live activity.', 'live.creating': 'Creating a private live activity link…', + 'live.endedByUser': 'Live sharing ended. Your last synced copy remains safe on this device.', 'feedback.updatedExpense': '{title} was updated. Splits and balances were recalculated.', 'feedback.addedExpenses': '{count} expenses were added.', 'feedback.currencyChanged': 'Activity currency changed to {currency}.', @@ -337,6 +347,9 @@ const en = { 'confirm.deleteExpense': 'Delete {label}? This removes it from the activity and recalculates everyone’s balances.', 'confirm.deleteActivity': 'Delete “{name}”? This removes the activity and all its expenses from this browser. This cannot be undone.', 'confirm.reset': 'Reset every local activity, friend, and expense? This cannot be undone.', + 'confirm.endLiveTitle': 'End live sharing?', + 'confirm.endLive': 'Everyone will immediately lose access through this invite link. Saved recovery copies remain available on devices that already opened the activity.', + 'confirm.endLiveAction': 'End live sharing', 'friends.addedOne': '{people} was added to the activity.', 'friends.addedMany': '{people} were added to the activity.', 'friends.addedFutureOne': '{people} was added for future expenses. {count} earlier {expenseUnit} left unchanged.', @@ -413,17 +426,17 @@ const zhCN: Record = { 'identity.eyebrow': '你的本地身份', 'identity.settingsEyebrow': '偏好设置', 'identity.title': '怎么称呼你?', 'identity.settingsTitle': '设置', 'identity.displayName': '显示名称', 'identity.namePlaceholder': '例如:鹏帆', 'identity.storedLocally': '只保存在这个浏览器中', 'identity.explanation': '这个名字代表活动中的“你”,朋友打开分享链接时也能认出发送者。', 'identity.continue': '继续', 'identity.saveName': '保存', 'activityIdentity.choose': '选择你的身份', 'activityIdentity.current': '当前身份:{name}', 'activityIdentity.compact': '我 · {name}', 'activityIdentity.menu': '选择活动成员', 'activityIdentity.title': '你在这个活动中是谁?', 'activityIdentity.description': '选择当前浏览器中代表你的活动成员。', 'activityIdentity.localOnly': '只保存在当前浏览器', 'activityIdentity.aiReason': 'Tally 会用它来理解 AI 输入中的“我”。', 'activityIdentity.required': '请先选择你的身份,这样 Tally 才知道“我”是谁。', 'settings.language': '语言', 'settings.english': 'English', 'settings.chinese': '简体中文', 'settings.chooseLanguage': '语言:{language}', 'settings.languageMenu': '选择语言', 'settings.regionTitle': '语言与本地时间', 'settings.timeZone': '时间将按照 {timeZone} 显示。', - 'changelog.eyebrow': '最近更新', 'changelog.title': 'Tally 最近更新', 'changelog.confirm': '知道了', 'changelog.release.aiTitle': '打字或说话,都能快速添加支出', 'changelog.release.aiSummary': '手动填写仍然是默认方式。需要更快时,Tally 现在可以把一段文字或录音整理成一笔或多笔草稿,保存前由你确认。', 'changelog.item.aiTextTitle': '一次描述多笔支出', 'changelog.item.aiTextDescription': '用你习惯的语言自然描述,Tally 会一次整理成多笔支出草稿。', 'changelog.item.aiVoiceTitle': '不想打字就直接说', 'changelog.item.aiVoiceDescription': '录音最长 60 秒,Tally 会把里面的支出信息整理成草稿。', 'changelog.item.aiReviewTitle': '最终由你确认', 'changelog.item.aiReviewDescription': '添加到活动前,每一笔草稿都可以检查、修改或删除。', 'changelog.release.liveTitle': '分享更清楚,也更安心', 'changelog.release.liveSummary': '现在可以更直观地选择分享方式,实时协作更可靠,在不同设备上使用也更顺手。', 'changelog.item.liveTitle': '更可靠的实时协作', 'changelog.item.liveDescription': '在线时大家可以一起编辑,每台打开过活动的设备都会保留一份恢复副本。', 'changelog.item.shareTitle': '分享方式一目了然', 'changelog.item.shareDescription': '邀请朋友进入同一个 Live 活动一起编辑,或只发送余额总结,不开放活动访问权限。', 'changelog.item.settleTitle': '一起记录结算', 'changelog.item.settleDescription': '支持部分还款或全部结清,在 Live 活动中也会同步更新。', 'changelog.item.polishTitle': '常用操作更顺手', 'changelog.item.polishDescription': '优化了货币与语言菜单、手机端弹窗,以及支出时间的显示。', + 'changelog.eyebrow': '最近更新', 'changelog.title': 'Tally 最近更新', 'changelog.confirm': '知道了', 'changelog.release.liveControlsTitle': '实时共享更可控', 'changelog.release.liveControlsSummary': '需要时可以随时结束邀请链接,已经加入过的设备仍会保留最后同步的恢复副本。', 'changelog.item.endLiveTitle': '结束实时活动', 'changelog.item.endLiveDescription': '让当前邀请链接对所有人立即失效,同时保留每台设备上的恢复副本。', 'changelog.item.saferSharingTitle': '共享数据更稳妥', 'changelog.item.saferSharingDescription': '更严格地检查参与者、支出、分摊、结算和时间信息,避免活动数据不一致。', 'changelog.release.aiTitle': '打字或说话,都能快速添加支出', 'changelog.release.aiSummary': '手动填写仍然是默认方式。需要更快时,Tally 现在可以把一段文字或录音整理成一笔或多笔草稿,保存前由你确认。', 'changelog.item.aiTextTitle': '一次描述多笔支出', 'changelog.item.aiTextDescription': '用你习惯的语言自然描述,Tally 会一次整理成多笔支出草稿。', 'changelog.item.aiVoiceTitle': '不想打字就直接说', 'changelog.item.aiVoiceDescription': '录音最长 60 秒,Tally 会把里面的支出信息整理成草稿。', 'changelog.item.aiReviewTitle': '最终由你确认', 'changelog.item.aiReviewDescription': '添加到活动前,每一笔草稿都可以检查、修改或删除。', 'changelog.release.liveTitle': '分享更清楚,也更安心', 'changelog.release.liveSummary': '现在可以更直观地选择分享方式,实时协作更可靠,在不同设备上使用也更顺手。', 'changelog.item.liveTitle': '更可靠的实时协作', 'changelog.item.liveDescription': '在线时大家可以一起编辑,每台打开过活动的设备都会保留一份恢复副本。', 'changelog.item.shareTitle': '分享方式一目了然', 'changelog.item.shareDescription': '邀请朋友进入同一个 Live 活动一起编辑,或只发送余额总结,不开放活动访问权限。', 'changelog.item.settleTitle': '一起记录结算', 'changelog.item.settleDescription': '支持部分还款或全部结清,在 Live 活动中也会同步更新。', 'changelog.item.polishTitle': '常用操作更顺手', 'changelog.item.polishDescription': '优化了货币与语言菜单、手机端弹窗,以及支出时间的显示。', 'group.newEyebrow': '新活动', 'group.newTitle': '这次要一起分摊什么?', 'group.name': '活动名称', 'group.namePlaceholder': '例如:周末海边', 'group.currency': '活动币种', 'group.currencyHelp': '此活动中的所有支出使用同一种币种。', 'group.chooseCurrency': '活动币种:{currency}', 'group.currencyMenu': '选择活动币种', 'group.addFriends': '添加朋友', 'group.addFriendsHelp': '用逗号分隔多个名字,之后也可以继续添加。', 'group.addFriendsPlaceholder': '小明,小红', 'group.included': '你会自动加入这个活动。', 'group.create': '创建活动', 'friend.eyebrow': '添加成员', 'friend.title': '谁要加入?', 'friend.names': '朋友姓名', 'friend.namesHelp': '多个名字请用逗号分隔。', 'friend.namesPlaceholder': '小明,小红', 'friend.futureOnly': '只影响之后的支出', 'friend.existingOne': '已有的 1 笔支出不会改变。', 'friend.existingMany': '已有的 {count} 笔支出不会改变。', 'friend.add': '添加朋友', 'settlement.title': '记录还款', 'settlement.pays': '付款', 'settlement.receives': '收款', 'settlement.parties': '{from} 支付给 {to}', 'settlement.amount': '还款金额', 'settlement.suggestedAmount': '建议金额:{amount}', 'settlement.invalid': '请输入 {minimum} 到 {amount} 之间的金额。', 'settlement.note': '这会记录全部或部分还款,并重新计算剩余欠款,但不会增加活动总支出。', 'settlement.record': '记录还款', 'expense.entryMethod': '支出录入方式', 'expense.aiTab': '文字 AI', 'expense.manualTab': '手动填写', 'expense.voiceTab': '语音 AI', 'expense.aiTitle': '告诉 Tally 发生了什么', 'expense.aiHelp': '一次描述一笔或多笔支出,Tally 会生成草稿供你确认。', 'expense.aiPrompt': '支出描述', 'expense.aiPlaceholder': '我付了 120 元午餐,小明付了 230 元买菜,两笔都由大家平分', 'expense.aiExample': '请说明每笔支出由谁付款、金额,以及哪些人参与分摊。', 'expense.aiGenerate': '生成草稿', 'expense.aiWorking': '正在生成草稿…', 'expense.aiPrivacy': '这段描述只会发送给当前配置的 AI 服务来生成草稿;确认之前不会保存任何支出。', 'expense.aiClarification': '还需要确认一下', 'expense.aiAnswer': '你的回答', 'expense.aiContinue': '更新草稿', 'expense.aiError': '免费 AI 模型暂时不可用,你可以稍后重试或改为手动填写。', 'expense.aiRateLimit': '免费 AI 模型现在比较忙,请稍后再试或改为手动填写。', 'expense.aiModelUnavailable': '免费 AI 模型和低成本备用模型都没有成功响应。请稍后重试,或改为手动填写。', 'expense.aiCredits': 'Tally 的 AI 额度暂时不可用。请稍后重试,或改为手动填写。', 'expense.aiInvalid': 'Tally 无法生成可靠的草稿。请重新说明每笔支出的金额、付款人和参与分摊的人,或改为手动填写。', 'expense.aiTryAgain': '知道了', 'expense.aiDraftReady': 'AI 草稿已生成', 'expense.aiDraftReview': '保存前请检查每一项信息。', 'expense.batchReady': '已生成 {count} 笔支出草稿', 'expense.batchReview': '保存全部支出前,你可以逐笔检查、修改或删除。', 'expense.batchPaidBy': '{payer} 付款', 'expense.batchPeople': '{count} 人参与', 'expense.batchEdit': '编辑“{title}”', 'expense.batchRemove': '删除“{title}”', 'expense.batchNothingSaved': '点击保存全部之前,不会添加任何支出。', 'expense.batchSave': '保存 {count} 笔支出', 'expense.batchEditing': '正在编辑第 {current}/{total} 笔草稿', 'expense.batchEditingHelp': '修改后会回到草稿列表,确认无误再一起保存。', 'expense.batchBack': '返回草稿列表', 'expense.batchUpdate': '更新草稿', 'expense.voiceTitle': '直接说出支出', 'expense.voiceHelp': '一次说出一笔或多笔支出,包括每笔由谁付款、金额和参与分摊的人。', 'expense.voiceStart': '开始录音', 'expense.voiceStop': '停止录音', 'expense.voiceCancelRequest': '取消麦克风请求', 'expense.voiceListening': '正在听…点击停止', 'expense.voiceProcessing': '正在把录音整理成草稿…', 'expense.voiceRequesting': '正在启动麦克风…', 'expense.voiceLimit': '最长 60 秒', 'expense.voicePermission': '没有获得麦克风权限。请在浏览器设置中允许访问,或改为手动填写。', 'expense.voiceStartTimeout': '所选麦克风未能启动。请在浏览器或系统输入设置中选择可用麦克风,然后刷新重试。文字 AI 和手动填写仍可使用。', 'expense.voiceUnsupported': '这个浏览器暂不支持语音录入,你仍可使用文字 AI 或手动填写。', 'expense.voiceEmpty': '没有听到足够清晰的内容。请简短清楚地再说一次,或改为手动填写。', 'expense.voiceError': '语音录入暂时不可用,请重试或改为手动填写。', 'expense.voiceNetwork': 'Tally 无法连接语音 AI 服务。请检查网络后重试,或改为手动填写。', 'expense.voiceRateLimit': '语音录入次数暂时已用完,文字 AI 和手动填写仍然可用。', 'expense.voiceModelUnavailable': '语音模型暂时没有响应。请稍后重试,或改用文字 AI、手动填写。', 'expense.voiceCredits': '语音 AI 已达到当前预算上限,文字 AI 和手动填写仍然可用。', 'expense.voiceInvalid': 'Tally 无法从这段录音生成可靠草稿。请重新说清每笔支出的金额、付款人和参与分摊的人,或改为手动填写。', 'expense.voicePrivacy': '录音只会发送给当前配置的 AI 服务来生成草稿,Tally 不会保存音频。', 'expense.voiceClarificationHelp': '这个补充问题可以直接输入文字,Tally 会保留录音和之前回答中的信息。', 'expense.addTitle': '添加共同支出', 'expense.editTitle': '编辑支出', 'expense.description': '说明', 'expense.descriptionPlaceholder': '例如:买菜', 'expense.amount': '金额', 'expense.paidBy': '付款人', 'expense.splitMethod': '分摊方式', 'expense.equally': '平均分摊', 'expense.exactAmounts': '指定金额', 'expense.splitBetween': '参与分摊的人', 'expense.selectedCount': '已选择 {selected}/{total} 人', 'expense.includeMember': '让 {name} 参与平均分摊', 'expense.eachShare': '每位已选成员的份额', 'expense.selectOne': '请至少选择一位参与分摊的人。', 'expense.enterShares': '输入每个人的份额', 'expense.left': '还差 {amount}', 'expense.over': '超出 {amount}', 'expense.memberShare': '{name} 的份额', 'expense.editEqualNote': '保存后,这笔支出会按照当前选择的成员重新平均分摊。', 'expense.editExactNote': '保存后,这笔支出会使用活动中当前的全部 {count} 位成员重新分摊。', 'expense.saveChanges': '保存修改', 'expense.save': '保存支出', 'expense.createdAt': '创建于 {date}', 'expense.editedAt': '编辑于 {date}', 'expense.timeUnavailable': '未记录时间', 'dashboard.totalSpent': '总支出', 'dashboard.paid': '{name} 已付款', 'dashboard.yourBalance': '你的余额', 'dashboard.memberBalance': '{name} 的余额', 'dashboard.memberIsOwed': '{name} 应收', 'dashboard.memberOwesBalance': '{name} 应付', 'dashboard.whoOwes': '谁欠谁', 'dashboard.suggestedSettlements': '建议结算方式', 'dashboard.youOwe': '你欠', 'dashboard.memberOwes': '{name} 欠', 'dashboard.owesPerson': '{from} 欠 {to}', 'dashboard.suggestedPayment': '建议付款', 'dashboard.settleUp': '结算', 'dashboard.everyoneSettled': '大家已经结清', 'dashboard.addExpensePrompt': '添加支出后,Tally 会计算谁应该付给谁。', 'dashboard.expenses': '支出记录', 'dashboard.entry': '条记录', 'dashboard.entries': '条记录', 'dashboard.settlementPayment': '还款记录', 'dashboard.paidPerson': '{payer} 支付给 {recipient}', 'dashboard.paidLabel': '{payer} 付款', 'dashboard.splitEqually': '平均分摊', 'dashboard.exactSplit': '指定金额', 'dashboard.editExpense': '编辑支出:{title}', 'dashboard.deleteExpense': '删除支出:{title}', 'dashboard.deletePayment': '删除 {payer} 给 {recipient} 的还款', 'dashboard.editExpenseTitle': '编辑支出', 'dashboard.deleteExpenseTitle': '删除支出', 'dashboard.deleteSettlementTitle': '删除还款', 'dashboard.noMatches': '没有符合搜索条件的支出。', 'dashboard.noExpenses': '还没有支出,添加第一笔吧。', 'dashboard.emptyTitle': '还没有支出', 'dashboard.emptyText': '添加第一笔支出后即可计算余额。', 'dashboard.people': '成员', 'dashboard.currentIdentity': '当前本地身份', 'dashboard.sharedRole': '共享成员', 'dashboard.howTitle': '分摊方式', 'dashboard.howText': '选择付款人,然后在选中的成员间平均分摊,或输入每个人的具体金额。Tally 会自动更新所有余额。', 'dashboard.activityTotal': '活动总额', 'dashboard.activityGroup': '活动群组', 'dashboard.sharing': '{count} {unit}一起分摊支出。', 'dashboard.readOnly': '只读', 'dashboard.editingPaused': '编辑已暂停', 'dashboard.share': '分享', 'dashboard.shareQr': '分享二维码', 'dashboard.shareLive': '实时共享', 'dashboard.shareSummary': '分享总结', 'dashboard.addFriend': '添加朋友', 'dashboard.addExpense': '添加支出', 'dashboard.showQr': '显示二维码', 'dashboard.creator': '活动创建者', 'dashboard.liveRevision': '实时 · 版本 {revision}', 'dashboard.savedRevision': '已保存 · 版本 {revision}', - 'shareMenu.title': '分享活动', 'shareMenu.description': '邀请朋友一起编辑{name},或只发送余额总结。', 'shareMenu.liveBadge': '可编辑 · 自动同步', 'shareMenu.liveTitle': '一起实时编辑', 'shareMenu.currentLiveTitle': '邀请朋友实时编辑', 'shareMenu.liveHelp': '对方可以添加或修改支出,所有人都会看到最新版本。', 'shareMenu.startLive': '创建实时活动', 'shareMenu.copyLive': '复制实时邀请链接', 'shareMenu.liveQr': '显示实时二维码', 'shareMenu.otherTitle': '只想发送结算结果?', 'shareMenu.summary': '仅分享余额总结', 'shareMenu.summaryHelp': '发送总额和谁欠谁,不开放活动访问权限。', - 'live.label': '实时活动', 'live.title': '实时活动 · {code}', 'live.opening': '正在打开实时活动', 'live.saving': '正在保存修改…', 'live.loadingLatest': '正在加载最新版本…', 'live.everyoneCanEdit': '任何拥有这个私密链接的人都可以编辑。', 'live.back': '返回我的活动', 'live.syncedTitle': '实时同步中 · {code}', 'live.syncedText': '所有人都在编辑同一个活动,本设备也会保存一份恢复副本。', 'live.reconnectingTitle': '实时连接已暂停', 'live.offlineTitle': '当前处于离线状态', 'live.cachedText': '这是最后一次同步的副本。重新连接前无法编辑,你也可以创建一个独立副本继续修改。', 'live.endedTitle': '实时共享已结束', 'live.endedText': '最后同步的副本已保存在本设备。你可以继续在本地编辑,需要时再创建新的实时活动。', 'live.unavailableTitle': '无法打开实时活动', 'live.unavailableText': 'Tally 无法打开这个实时活动,并且本设备上没有已保存的副本。', 'live.refresh': '刷新最新内容', 'live.retry': '重试连接', 'live.duplicate': '复制并编辑', 'live.continueLocally': '继续在本地编辑', 'live.copyName': '{name}(副本)', 'live.copyEyebrow': '独立副本', 'live.copyTitle': '你是副本中的哪位成员?', 'live.copyExplanation': '这会创建一个独立的本地活动,之后的修改不会同步回原实时活动。', 'live.copySave': '创建可编辑副本', 'live.recoverEyebrow': '继续本地使用', 'live.recoverTitle': '你是活动中的哪位成员?', 'live.recoverExplanation': '实时共享已经结束。保存后,这个活动会成为可编辑的本地副本。', 'live.recoverSave': '保存可编辑活动', 'live.newChanges': '已自动加载新的共享修改。', 'live.latestLoaded': '已加载最新修改。', 'live.conflict': '有人保存了更新的版本。请先刷新活动,再重新提交修改。', 'live.notFound': '这个实时活动链接无效或已不可用。', 'live.rateLimit': '当前网络的实时活动请求过多,请等待几分钟后重试。', 'live.network': '无法连接实时活动服务,请检查网络后重试。', 'live.invalidInput': '活动里有字段过长,或金额超过支持范围。请修改后重试。', 'live.genericError': '无法更新实时活动,请重试。', 'live.conflictLoaded': '有人保存了更新的版本。最新修改已加载,请确认后重新保存。', 'live.notConfigured': '当前版本未配置实时共享。', 'live.ready': '实时活动 {code} 已创建。当前页面的修改会同步到共享活动。', 'live.addedExpense': '已将“{title}”添加到实时活动。', 'live.addedExpenses': '已将 {count} 笔支出添加到实时活动。', 'live.updatedExpense': '已更新“{title}”,分摊和余额已重新计算。', 'live.deletedExpense': '已从实时活动删除“{title}”。', 'live.creating': '正在创建私密实时活动链接…', + 'shareMenu.title': '分享活动', 'shareMenu.description': '邀请朋友一起编辑{name},或只发送余额总结。', 'shareMenu.liveBadge': '可编辑 · 自动同步', 'shareMenu.liveTitle': '一起实时编辑', 'shareMenu.currentLiveTitle': '邀请朋友实时编辑', 'shareMenu.liveHelp': '对方可以添加或修改支出,所有人都会看到最新版本。', 'shareMenu.startLive': '创建实时活动', 'shareMenu.copyLive': '复制实时邀请链接', 'shareMenu.liveQr': '显示实时二维码', 'shareMenu.otherTitle': '只想发送结算结果?', 'shareMenu.summary': '仅分享余额总结', 'shareMenu.summaryHelp': '发送总额和谁欠谁,不开放活动访问权限。', 'shareMenu.endLive': '结束实时共享', 'shareMenu.endLiveHelp': '当前邀请链接会立即失效,已打开活动的设备仍会保留恢复副本。', 'shareMenu.endLiveAction': '结束共享', + 'live.label': '实时活动', 'live.title': '实时活动 · {code}', 'live.opening': '正在打开实时活动', 'live.saving': '正在保存修改…', 'live.loadingLatest': '正在加载最新版本…', 'live.everyoneCanEdit': '任何拥有这个私密链接的人都可以编辑。', 'live.back': '返回我的活动', 'live.syncedTitle': '实时同步中 · {code}', 'live.syncedText': '所有人都在编辑同一个活动,本设备也会保存一份恢复副本。', 'live.reconnectingTitle': '实时连接已暂停', 'live.offlineTitle': '当前处于离线状态', 'live.cachedText': '这是最后一次同步的副本。重新连接前无法编辑,你也可以创建一个独立副本继续修改。', 'live.endedTitle': '实时共享已结束', 'live.endedText': '最后同步的副本已保存在本设备。你可以继续在本地编辑,需要时再创建新的实时活动。', 'live.unavailableTitle': '无法打开实时活动', 'live.unavailableText': 'Tally 无法打开这个实时活动,并且本设备上没有已保存的副本。', 'live.refresh': '刷新最新内容', 'live.retry': '重试连接', 'live.duplicate': '复制并编辑', 'live.continueLocally': '继续在本地编辑', 'live.copyName': '{name}(副本)', 'live.copyEyebrow': '独立副本', 'live.copyTitle': '你是副本中的哪位成员?', 'live.copyExplanation': '这会创建一个独立的本地活动,之后的修改不会同步回原实时活动。', 'live.copySave': '创建可编辑副本', 'live.recoverEyebrow': '继续本地使用', 'live.recoverTitle': '你是活动中的哪位成员?', 'live.recoverExplanation': '实时共享已经结束。保存后,这个活动会成为可编辑的本地副本。', 'live.recoverSave': '保存可编辑活动', 'live.newChanges': '已自动加载新的共享修改。', 'live.latestLoaded': '已加载最新修改。', 'live.conflict': '有人保存了更新的版本。请先刷新活动,再重新提交修改。', 'live.notFound': '这个实时活动链接无效或已不可用。', 'live.rateLimit': '当前网络的实时活动请求过多,请等待几分钟后重试。', 'live.network': '无法连接实时活动服务,请检查网络后重试。', 'live.invalidInput': '活动里有字段过长,或金额超过支持范围。请修改后重试。', 'live.genericError': '无法更新实时活动,请重试。', 'live.conflictLoaded': '有人保存了更新的版本。最新修改已加载,请确认后重新保存。', 'live.notConfigured': '当前版本未配置实时共享。', 'live.ready': '实时活动 {code} 已创建。当前页面的修改会同步到共享活动。', 'live.addedExpense': '已将“{title}”添加到实时活动。', 'live.addedExpenses': '已将 {count} 笔支出添加到实时活动。', 'live.updatedExpense': '已更新“{title}”,分摊和余额已重新计算。', 'live.deletedExpense': '已从实时活动删除“{title}”。', 'live.creating': '正在创建私密实时活动链接…', 'live.endedByUser': '实时共享已结束,最后同步的副本仍安全保存在本设备。', 'feedback.updatedExpense': '已更新“{title}”,分摊和余额已重新计算。', 'feedback.addedExpenses': '已添加 {count} 笔支出。', 'feedback.currencyChanged': '活动币种已更改为{currency}。', 'feedback.settlement': '{from} 已向 {to} 支付 {amount},剩余余额已重新计算。', 'feedback.liveShared': '实时活动链接已分享,任何拥有链接的人都可以编辑。', 'feedback.liveCopied': '实时活动链接已复制,任何拥有链接的人都可以编辑。', 'feedback.cancelled': '已取消分享。', 'feedback.liveShareFailed': '无法分享实时活动链接,请重试。', 'feedback.liveCopyFailed': '无法复制实时活动链接,请改用“分享链接”。', 'feedback.summaryShared': 'PNG 总结已分享。', 'feedback.summaryCopied': '总结已复制,可以粘贴到聊天中。', 'feedback.summaryDownloaded': 'PNG 总结已下载。', 'feedback.summaryFailed': '无法导出总结,请重试。', - 'confirm.deleteSettlementLabel': '这笔还款', 'confirm.deleteExpenseLabel': '“{title}”', 'confirm.eyebrow': '请确认', 'confirm.deleteExpenseTitle': '删除这条记录?', 'confirm.deleteActivityTitle': '删除这个活动?', 'confirm.resetTitle': '清空本地数据?', 'confirm.deleteAction': '删除', 'confirm.resetAction': '清空数据', 'confirm.deleteExpense': '删除{label}?删除后会重新计算所有人的余额。', 'confirm.deleteActivity': '删除“{name}”?这个浏览器中的活动和全部支出都会被删除,且无法恢复。', 'confirm.reset': '清空所有本地活动、朋友和支出?此操作无法撤销。', + 'confirm.deleteSettlementLabel': '这笔还款', 'confirm.deleteExpenseLabel': '“{title}”', 'confirm.eyebrow': '请确认', 'confirm.deleteExpenseTitle': '删除这条记录?', 'confirm.deleteActivityTitle': '删除这个活动?', 'confirm.resetTitle': '清空本地数据?', 'confirm.deleteAction': '删除', 'confirm.resetAction': '清空数据', 'confirm.deleteExpense': '删除{label}?删除后会重新计算所有人的余额。', 'confirm.deleteActivity': '删除“{name}”?这个浏览器中的活动和全部支出都会被删除,且无法恢复。', 'confirm.reset': '清空所有本地活动、朋友和支出?此操作无法撤销。', 'confirm.endLiveTitle': '结束实时共享?', 'confirm.endLive': '所有人会立即无法通过当前邀请链接访问活动。已经打开过活动的设备仍会保留恢复副本。', 'confirm.endLiveAction': '结束实时共享', 'friends.addedOne': '已将 {people} 添加到活动。', 'friends.addedMany': '已将 {people} 添加到活动。', 'friends.addedFutureOne': '已添加 {people},之后的支出会包含他们。之前的 {count} 笔{expenseUnit}保持不变。', 'friends.addedFutureMany': '已添加 {people},之后的支出会包含他们。之前的 {count} 笔{expenseUnit}保持不变。', 'friends.expenseOne': '支出', 'friends.expenseMany': '支出', 'join.eyebrow': 'PWA 跳转', 'join.title': '加入共享活动', 'join.continueTitle': '在已安装的 Tally 中继续', 'join.continueText': '如果 Safari 在单独的浏览器页面打开了链接,请在那里复制链接,然后返回 Tally 粘贴到下方。', 'join.link': '共享活动链接', 'join.paste': '粘贴链接', 'join.open': '打开活动', 'join.manualPaste': '请手动把共享链接粘贴到输入框。', 'join.clipboardFailed': 'Tally 无法读取剪贴板,请手动粘贴链接。', 'join.invalid': '请粘贴有效的 Tally Live 活动链接。', 'handoff.label': '在已安装的 Tally 中继续', 'handoff.title': '已经安装 Tally?', 'handoff.default': 'Safari 无法自动把这个链接切换到已安装的 Web App。', 'handoff.copied': '链接已复制。打开 Tally,选择“加入活动”,然后粘贴链接。', 'handoff.manual': '复制当前页面 URL,然后打开 Tally 并选择“加入活动”。', 'handoff.copy': '复制到 App', diff --git a/src/security/contentSecurityPolicy.test.ts b/src/security/contentSecurityPolicy.test.ts index e4e7fb2..4a43320 100644 --- a/src/security/contentSecurityPolicy.test.ts +++ b/src/security/contentSecurityPolicy.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_SUPABASE_CONNECT_ORIGIN, + DEVELOPMENT_CONNECT_SOURCES, + resolveConnectSources, resolveSupabaseConnectOrigin, } from './contentSecurityPolicy' @@ -22,4 +24,14 @@ describe('content security policy', () => { ])('falls back to the production origin for an unsafe value: %s', value => { expect(resolveSupabaseConnectOrigin(value)).toBe(DEFAULT_SUPABASE_CONNECT_ORIGIN) }) + + it('keeps local and test endpoints out of production builds', () => { + expect(resolveConnectSources('https://preview.supabase.co')).toBe('https://preview.supabase.co') + expect(resolveConnectSources('https://preview.supabase.co')).not.toMatch(/localhost|127\.0\.0\.1|live-sharing\.test/) + }) + + it('adds explicit browser-test and local endpoints only in development', () => { + expect(resolveConnectSources('https://preview.supabase.co', true)) + .toBe(['https://preview.supabase.co', ...DEVELOPMENT_CONNECT_SOURCES].join(' ')) + }) }) diff --git a/src/security/contentSecurityPolicy.ts b/src/security/contentSecurityPolicy.ts index 05a73eb..cf289eb 100644 --- a/src/security/contentSecurityPolicy.ts +++ b/src/security/contentSecurityPolicy.ts @@ -1,4 +1,11 @@ export const DEFAULT_SUPABASE_CONNECT_ORIGIN = 'https://khneqfwvlspwfzdzpsyg.supabase.co' +export const DEVELOPMENT_CONNECT_SOURCES = [ + 'https://live-sharing.test', + 'http://127.0.0.1:*', + 'http://localhost:*', + 'ws://127.0.0.1:*', + 'ws://localhost:*', +] as const export function resolveSupabaseConnectOrigin(value?: string) { const candidate = value?.trim() @@ -14,3 +21,10 @@ export function resolveSupabaseConnectOrigin(value?: string) { return DEFAULT_SUPABASE_CONNECT_ORIGIN } } + +export function resolveConnectSources(value?: string, development = false) { + return [ + resolveSupabaseConnectOrigin(value), + ...(development ? DEVELOPMENT_CONNECT_SOURCES : []), + ].join(' ') +} diff --git a/src/styles.css b/src/styles.css index f6f0587..71d813f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -403,6 +403,13 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .share-menu-copy b { font-size: 13px; } .share-menu-copy small { color: var(--muted); font-size: 11px; line-height: 1.35; } .share-summary-action > svg { color: #a29a91; flex: 0 0 auto; } +.share-menu-danger { margin-top: 17px; padding-top: 15px; border-top: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 14px; } +.share-menu-danger-copy { min-width: 0; display: flex; align-items: center; gap: 10px; color: #9b3931; } +.share-menu-danger-copy > svg { flex: 0 0 auto; } +.share-menu-danger-copy > span { min-width: 0; display: grid; gap: 3px; } +.share-menu-danger-copy b { color: var(--ink); font-size: 12px; } +.share-menu-danger-copy small { color: var(--muted); font-size: 10px; line-height: 1.4; } +.share-menu-danger > button { flex: 0 0 auto; } .share-menu-cancel { width: 100%; margin-top: 16px; } .modal label small { color: #9a938b; font-size: 9px; font-weight: 400; } @@ -552,6 +559,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .share-menu-copy small { font-size: 12px; } .share-menu-choices { grid-template-columns: 1fr; } .share-choice-copy { min-height: 0; } + .share-menu-danger { align-items: stretch; flex-direction: column; } + .share-menu-danger > button { width: 100%; } } @media (max-width: 430px) { diff --git a/supabase/functions/parse-expense/index.ts b/supabase/functions/parse-expense/index.ts index e6f4a36..ec6a73f 100644 --- a/supabase/functions/parse-expense/index.ts +++ b/supabase/functions/parse-expense/index.ts @@ -13,12 +13,15 @@ export default { handleParseExpenseRequest(request, { getEnvironment: name => Deno.env.get(name), consumeQuota: async (identifier, inputMode) => { - const { data, error } = await context.supabaseAdmin.rpc('consume_ai_expense_quota', { + const { data, error } = await context.supabaseAdmin.rpc('consume_ai_expense_quota_v2', { p_identifier: identifier, p_input_mode: inputMode, }) if (error) throw error - return data === true + if (data === 'allowed') return 'allowed' + if (data === 'client_limit') return 'client-limit' + if (data === 'global_limit') return 'global-limit' + throw new Error('AI quota service returned an invalid result.') }, reportProviderFailure: failure => console.warn('AI provider request failed', failure), }) diff --git a/supabase/migrations/20260809010545_harden_snapshot_revocation_ai_budgets.sql b/supabase/migrations/20260809010545_harden_snapshot_revocation_ai_budgets.sql new file mode 100644 index 0000000..cf3c9eb --- /dev/null +++ b/supabase/migrations/20260809010545_harden_snapshot_revocation_ai_budgets.sql @@ -0,0 +1,563 @@ +-- Keep the browser and database representations of a Live activity in sync. +-- The previous database predicate only checked shallow JSON shape, which meant +-- a holder of a valid edit capability could store a snapshot that every Tally +-- client would reject when loading it. + +create or replace function private.is_valid_activity_timestamp(p_value text) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + date_year integer; + date_month integer; + date_day integer; + time_hour integer; + time_minute integer; + time_second numeric; + offset_value text; + offset_hour integer; + offset_minute integer; +begin + if length(p_value) not between 1 and 120 + or p_value !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$' then + return false; + end if; + + date_year := substring(p_value from 1 for 4)::integer; + date_month := substring(p_value from 6 for 2)::integer; + date_day := substring(p_value from 9 for 2)::integer; + time_hour := substring(p_value from 12 for 2)::integer; + time_minute := substring(p_value from 15 for 2)::integer; + time_second := substring(p_value from 18 for 2)::numeric; + perform pg_catalog.make_date(date_year, date_month, date_day); + + if time_hour > 23 or time_minute > 59 or time_second >= 60 then + return false; + end if; + + if right(p_value, 1) <> 'Z' then + offset_value := right(p_value, 6); + offset_hour := substring(offset_value from 2 for 2)::integer; + offset_minute := substring(offset_value from 5 for 2)::integer; + if offset_hour > 14 or offset_minute > 59 or (offset_hour = 14 and offset_minute <> 0) then + return false; + end if; + end if; + + return true; +exception + when others then + return false; +end; +$$; + +create or replace function private.is_valid_activity_snapshot(p_snapshot jsonb) +returns boolean +language plpgsql +immutable +set search_path = '' +as $$ +declare + sender_data jsonb; + group_data jsonb; + friend_data jsonb; + expense_data jsonb; + member_value jsonb; + share_entry record; + member_id text; + expense_id text; + settlement_recipient_id text; + member_ids text[] := array[]::text[]; + group_member_ids text[] := array[]::text[]; + expense_ids text[] := array[]::text[]; + expense_amount numeric; + share_amount numeric; + share_total numeric; + share_count integer; +begin + if p_snapshot is null + or jsonb_typeof(p_snapshot) <> 'object' + or octet_length(p_snapshot::text) > 131072 + or jsonb_typeof(p_snapshot -> 'version') <> 'number' + or p_snapshot -> 'version' <> '2'::jsonb then + return false; + end if; + + sender_data := p_snapshot -> 'sender'; + if jsonb_typeof(sender_data) <> 'object' + or jsonb_typeof(sender_data -> 'id') <> 'string' + or sender_data ->> 'id' <> 'me' + or jsonb_typeof(sender_data -> 'name') <> 'string' + or length(sender_data ->> 'name') not between 1 and 120 + or jsonb_typeof(sender_data -> 'initials') <> 'string' + or length(sender_data ->> 'initials') not between 1 and 12 + or jsonb_typeof(sender_data -> 'color') <> 'string' + or length(sender_data ->> 'color') not between 1 and 32 then + return false; + end if; + member_ids := array['me']; + + if jsonb_typeof(p_snapshot -> 'friends') <> 'array' + or jsonb_array_length(p_snapshot -> 'friends') > 100 then + return false; + end if; + + for friend_data in + select value from jsonb_array_elements(p_snapshot -> 'friends') + loop + if jsonb_typeof(friend_data) <> 'object' + or jsonb_typeof(friend_data -> 'id') <> 'string' + or length(friend_data ->> 'id') not between 1 and 120 + or jsonb_typeof(friend_data -> 'name') <> 'string' + or length(friend_data ->> 'name') not between 1 and 120 + or jsonb_typeof(friend_data -> 'initials') <> 'string' + or length(friend_data ->> 'initials') not between 1 and 12 + or jsonb_typeof(friend_data -> 'color') <> 'string' + or length(friend_data ->> 'color') not between 1 and 32 then + return false; + end if; + + member_id := friend_data ->> 'id'; + if member_id = any(member_ids) then + return false; + end if; + member_ids := array_append(member_ids, member_id); + end loop; + + group_data := p_snapshot -> 'group'; + if jsonb_typeof(group_data) <> 'object' + or jsonb_typeof(group_data -> 'id') <> 'string' + or length(group_data ->> 'id') not between 1 and 120 + or jsonb_typeof(group_data -> 'name') <> 'string' + or length(group_data ->> 'name') not between 1 and 120 + or jsonb_typeof(group_data -> 'emoji') <> 'string' + or length(group_data ->> 'emoji') not between 1 and 16 + or jsonb_typeof(group_data -> 'memberIds') <> 'array' + or jsonb_array_length(group_data -> 'memberIds') not between 1 and 101 + or ( + group_data ? 'currency' + and ( + jsonb_typeof(group_data -> 'currency') <> 'string' + or group_data ->> 'currency' not in ( + 'USD', 'EUR', 'GBP', 'CNY', 'JPY', + 'CAD', 'AUD', 'HKD', 'SGD', 'KRW', + 'INR', 'CHF', 'NZD', 'TWD', 'THB' + ) + ) + ) then + return false; + end if; + + for member_value in + select value from jsonb_array_elements(group_data -> 'memberIds') + loop + if jsonb_typeof(member_value) <> 'string' + or length(member_value #>> '{}') not between 1 and 120 then + return false; + end if; + + member_id := member_value #>> '{}'; + if member_id = any(group_member_ids) + or not (member_id = any(member_ids)) then + return false; + end if; + group_member_ids := array_append(group_member_ids, member_id); + end loop; + + -- A snapshot contains only members participating in this activity. Requiring + -- both sets to match prevents hidden or ambiguous participant references. + if cardinality(group_member_ids) <> cardinality(member_ids) + or not ('me' = any(group_member_ids)) then + return false; + end if; + + if jsonb_typeof(p_snapshot -> 'expenses') <> 'array' + or jsonb_array_length(p_snapshot -> 'expenses') > 1000 then + return false; + end if; + + for expense_data in + select value from jsonb_array_elements(p_snapshot -> 'expenses') + loop + if jsonb_typeof(expense_data) <> 'object' + or jsonb_typeof(expense_data -> 'id') <> 'string' + or length(expense_data ->> 'id') not between 1 and 120 + or jsonb_typeof(expense_data -> 'groupId') <> 'string' + or expense_data ->> 'groupId' <> group_data ->> 'id' + or jsonb_typeof(expense_data -> 'title') <> 'string' + or length(expense_data ->> 'title') not between 1 and 200 + or jsonb_typeof(expense_data -> 'amount') <> 'number' + or jsonb_typeof(expense_data -> 'payerId') <> 'string' + or not ((expense_data ->> 'payerId') = any(group_member_ids)) + or jsonb_typeof(expense_data -> 'splitMethod') <> 'string' + or expense_data ->> 'splitMethod' not in ('equal', 'exact') + or jsonb_typeof(expense_data -> 'shares') <> 'object' + or jsonb_typeof(expense_data -> 'createdAt') <> 'string' + or length(expense_data ->> 'createdAt') not between 1 and 120 + or not private.is_valid_activity_timestamp(expense_data ->> 'createdAt') + or ( + expense_data ? 'updatedAt' + and ( + jsonb_typeof(expense_data -> 'updatedAt') <> 'string' + or not private.is_valid_activity_timestamp(expense_data ->> 'updatedAt') + ) + ) + or ( + expense_data ? 'kind' + and ( + jsonb_typeof(expense_data -> 'kind') <> 'string' + or expense_data ->> 'kind' not in ('expense', 'settlement') + ) + ) then + return false; + end if; + + expense_id := expense_data ->> 'id'; + if expense_id = any(expense_ids) then + return false; + end if; + expense_ids := array_append(expense_ids, expense_id); + + expense_amount := (expense_data ->> 'amount')::numeric; + if expense_amount < 0 or expense_amount > 1000000000 then + return false; + end if; + + share_total := 0; + share_count := 0; + settlement_recipient_id := null; + for share_entry in + select key, value from jsonb_each(expense_data -> 'shares') + loop + if length(share_entry.key) not between 1 and 120 + or not (share_entry.key = any(group_member_ids)) + or jsonb_typeof(share_entry.value) <> 'number' then + return false; + end if; + + share_amount := (share_entry.value #>> '{}')::numeric; + if share_amount < 0 or share_amount > 1000000000 then + return false; + end if; + share_total := share_total + share_amount; + share_count := share_count + 1; + settlement_recipient_id := share_entry.key; + end loop; + + if share_count < 1 + or share_count > cardinality(group_member_ids) + or abs(share_total - expense_amount) >= 0.005 then + return false; + end if; + + if expense_data ->> 'kind' = 'settlement' + and ( + expense_amount <= 0 + or expense_data ->> 'splitMethod' <> 'exact' + or share_count <> 1 + or settlement_recipient_id = expense_data ->> 'payerId' + ) then + return false; + end if; + end loop; + + return true; +exception + when numeric_value_out_of_range or invalid_text_representation then + return false; +end; +$$; + +alter table private.shared_activities + drop constraint shared_activities_valid_snapshot; +alter table private.shared_activities + add constraint shared_activities_valid_snapshot + check (private.is_valid_activity_snapshot(snapshot)) not valid; +alter table private.shared_activities + validate constraint shared_activities_valid_snapshot; + +revoke all on function private.is_valid_activity_timestamp(text) + from public, anon, authenticated, service_role; +revoke all on function private.is_valid_activity_snapshot(jsonb) + from public, anon, authenticated, service_role; + +-- Let any holder of the current capability deliberately revoke it. The local +-- recovery mirror remains in each browser, but the old URL stops loading or +-- accepting edits immediately. + +create function private.end_shared_activity(p_code text, p_edit_token text) +returns table (code text) +language plpgsql +security definer +set search_path = '' +set statement_timeout = '5s' +as $$ +begin + perform private.enforce_shared_activity_rate_limit('update', 120, interval '5 minutes'); + + if p_code !~ '^[A-F0-9]{10}$' or p_edit_token !~ '^[a-f0-9]{64}$' then + perform private.set_anonymous_rpc_status(404); + return; + end if; + + return query + delete from private.shared_activities activity + where activity.code = p_code + and activity.edit_token_hash = extensions.digest(p_edit_token, 'sha256') + and activity.expires_at > clock_timestamp() + returning activity.code; + + if not found then + perform private.set_anonymous_rpc_status(404); + end if; +end; +$$; + +create function public.end_shared_activity(p_code text, p_edit_token text) +returns table (code text) +language sql +security definer +set search_path = '' +as $$ + select * from private.end_shared_activity(p_code, p_edit_token); +$$; + +revoke all on function private.end_shared_activity(text, text) + from public, anon, authenticated, service_role; +revoke all on function public.end_shared_activity(text, text) + from public, anon, authenticated, service_role; +grant execute on function public.end_shared_activity(text, text) + to anon, authenticated; + +comment on function public.end_shared_activity(text, text) is + 'Revokes a Live activity capability while browser recovery copies remain local.'; + +-- Add a project-wide safety ceiling on top of the existing per-client limits. +-- This prevents distributed clients from creating unbounded OpenRouter spend. + +create table private.ai_expense_budget_limits ( + input_mode text primary key check (input_mode in ('text', 'voice')), + daily_limit integer not null check (daily_limit between 1 and 100000), + enabled boolean not null default true, + updated_at timestamptz not null default now() +); + +alter table private.ai_expense_budget_limits enable row level security; +revoke all on table private.ai_expense_budget_limits + from public, anon, authenticated, service_role; + +insert into private.ai_expense_budget_limits (input_mode, daily_limit) +values ('text', 500), ('voice', 100); + +alter table private.shared_activity_rate_limits + drop constraint shared_activity_rate_limits_operation_check; +alter table private.shared_activity_rate_limits + add constraint shared_activity_rate_limits_operation_check + check (operation in ( + 'create', + 'load', + 'update', + 'analytics', + 'ai-expense', + 'ai-expense-daily', + 'ai-expense-voice', + 'ai-expense-voice-daily', + 'ai-expense-global-daily', + 'ai-expense-voice-global-daily' + )); + +create function private.consume_ai_expense_quota_v2( + p_identifier text, + p_input_mode text default 'text' +) +returns text +language plpgsql +security definer +set search_path = '' +set statement_timeout = '2s' +as $$ +declare + identifier_pepper bytea; + hashed_identifier bytea; + global_identifier bytea; + request_time timestamptz := clock_timestamp(); + burst_operation text; + daily_operation text; + global_operation text; + burst_limit integer; + client_daily_limit integer; + project_daily_limit integer; + project_enabled boolean; + burst_count integer; + daily_count integer; + global_count integer; +begin + if p_identifier is null + or length(btrim(p_identifier)) < 1 + or length(p_identifier) > 200 + or p_input_mode not in ('text', 'voice') then + return 'invalid_request'; + end if; + + if p_input_mode = 'voice' then + burst_operation := 'ai-expense-voice'; + daily_operation := 'ai-expense-voice-daily'; + global_operation := 'ai-expense-voice-global-daily'; + burst_limit := 10; + client_daily_limit := 25; + else + burst_operation := 'ai-expense'; + daily_operation := 'ai-expense-daily'; + global_operation := 'ai-expense-global-daily'; + burst_limit := 30; + client_daily_limit := 100; + end if; + + select secret + into identifier_pepper + from private.security_secrets + where name = 'request_identifier_pepper'; + + if identifier_pepper is null then + raise exception using errcode = '55000', message = 'request_identifier_pepper_missing'; + end if; + + select daily_limit, enabled + into project_daily_limit, project_enabled + from private.ai_expense_budget_limits + where input_mode = p_input_mode; + + if not found or not project_enabled then + return 'global_limit'; + end if; + + hashed_identifier := extensions.hmac( + convert_to(btrim(p_identifier), 'UTF8'), + identifier_pepper, + 'sha256' + ); + global_identifier := extensions.hmac( + convert_to('tally-global-ai-budget:' || p_input_mode, 'UTF8'), + identifier_pepper, + 'sha256' + ); + + insert into private.shared_activity_rate_limits ( + identifier_hash, + operation, + window_started_at, + request_count + ) values ( + hashed_identifier, + burst_operation, + request_time, + 1 + ) + on conflict (identifier_hash, operation) do update + set window_started_at = case + when private.shared_activity_rate_limits.window_started_at <= request_time - interval '10 minutes' then request_time + else private.shared_activity_rate_limits.window_started_at + end, + request_count = case + when private.shared_activity_rate_limits.window_started_at <= request_time - interval '10 minutes' then 1 + else least(private.shared_activity_rate_limits.request_count + 1, 1000000) + end + returning request_count into burst_count; + + insert into private.shared_activity_rate_limits ( + identifier_hash, + operation, + window_started_at, + request_count + ) values ( + hashed_identifier, + daily_operation, + request_time, + 1 + ) + on conflict (identifier_hash, operation) do update + set window_started_at = case + when private.shared_activity_rate_limits.window_started_at <= request_time - interval '1 day' then request_time + else private.shared_activity_rate_limits.window_started_at + end, + request_count = case + when private.shared_activity_rate_limits.window_started_at <= request_time - interval '1 day' then 1 + else least(private.shared_activity_rate_limits.request_count + 1, 1000000) + end + returning request_count into daily_count; + + if burst_count > burst_limit or daily_count > client_daily_limit then + return 'client_limit'; + end if; + + insert into private.shared_activity_rate_limits ( + identifier_hash, + operation, + window_started_at, + request_count + ) values ( + global_identifier, + global_operation, + request_time, + 1 + ) + on conflict (identifier_hash, operation) do update + set window_started_at = case + when private.shared_activity_rate_limits.window_started_at <= request_time - interval '1 day' then request_time + else private.shared_activity_rate_limits.window_started_at + end, + request_count = case + when private.shared_activity_rate_limits.window_started_at <= request_time - interval '1 day' then 1 + else least(private.shared_activity_rate_limits.request_count + 1, 1000000) + end + returning request_count into global_count; + + if global_count > project_daily_limit then + return 'global_limit'; + end if; + return 'allowed'; +end; +$$; + +create function public.consume_ai_expense_quota_v2( + p_identifier text, + p_input_mode text default 'text' +) +returns text +language sql +security definer +set search_path = '' +as $$ + select private.consume_ai_expense_quota_v2(p_identifier, p_input_mode); +$$; + +create or replace function public.consume_ai_expense_quota( + p_identifier text, + p_input_mode text default 'text' +) +returns boolean +language sql +security definer +set search_path = '' +as $$ + select private.consume_ai_expense_quota_v2(p_identifier, p_input_mode) = 'allowed'; +$$; + +revoke all on function private.consume_ai_expense_quota_v2(text, text) + from public, anon, authenticated, service_role; +revoke all on function public.consume_ai_expense_quota_v2(text, text) + from public, anon, authenticated, service_role; +revoke all on function public.consume_ai_expense_quota(text, text) + from public, anon, authenticated, service_role; +grant execute on function public.consume_ai_expense_quota_v2(text, text) + to service_role; +grant execute on function public.consume_ai_expense_quota(text, text) + to service_role; + +comment on function public.consume_ai_expense_quota_v2(text, text) is + 'Consumes per-client and project-wide AI budgets and returns the limiting scope.'; +comment on table private.ai_expense_budget_limits is + 'Server-only project budget controls. Defaults: 500 text and 100 voice provider calls per rolling day.'; diff --git a/supabase/tests/ai_expense.sql b/supabase/tests/ai_expense.sql index f1729ac..5ee33b5 100644 --- a/supabase/tests/ai_expense.sql +++ b/supabase/tests/ai_expense.sql @@ -1,6 +1,59 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(26); +select plan(41); + +select has_table('private', 'ai_expense_budget_limits', 'project-wide AI budget controls exist'); +select is( + (select relrowsecurity from pg_class where oid = 'private.ai_expense_budget_limits'::regclass), + true, + 'AI budget controls have row security enabled' +); +select is( + has_table_privilege('anon', 'private.ai_expense_budget_limits', 'SELECT'), + false, + 'anonymous clients cannot read project AI budgets' +); +select is( + has_table_privilege('service_role', 'private.ai_expense_budget_limits', 'SELECT'), + false, + 'the Edge Function client cannot bypass the quota function to read budgets' +); +select has_function( + 'public', + 'consume_ai_expense_quota_v2', + array['text', 'text'], + 'the scoped AI quota function exists' +); +select is( + has_function_privilege('anon', 'public.consume_ai_expense_quota_v2(text,text)', 'EXECUTE'), + false, + 'anonymous clients cannot consume scoped AI quota directly' +); +select is( + has_function_privilege('authenticated', 'public.consume_ai_expense_quota_v2(text,text)', 'EXECUTE'), + false, + 'authenticated clients cannot consume scoped AI quota directly' +); +select is( + has_function_privilege('service_role', 'public.consume_ai_expense_quota_v2(text,text)', 'EXECUTE'), + true, + 'the Edge Function client can consume scoped AI quota' +); +select is( + has_function_privilege('service_role', 'private.consume_ai_expense_quota_v2(text,text)', 'EXECUTE'), + false, + 'the Edge Function client cannot bypass the public quota wrapper' +); +select is( + public.consume_ai_expense_quota_v2('', 'text'), + 'invalid_request', + 'the scoped quota reports invalid identifiers explicitly' +); +select results_eq( + $$select input_mode, daily_limit from private.ai_expense_budget_limits order by input_mode$$, + $$values ('text'::text, 500::integer), ('voice'::text, 100::integer)$$, + 'project budgets default to 500 text and 100 voice provider calls per rolling day' +); select has_function( 'public', @@ -217,5 +270,41 @@ select is( 'rejected voice daily requests remain counted' ); +select is( + ( + select request_count + from private.shared_activity_rate_limits + where operation = 'ai-expense-global-daily' + ), + 33, + 'client-rejected text requests do not consume the project-wide budget' +); + +update private.shared_activity_rate_limits +set request_count = 499, + window_started_at = clock_timestamp() +where operation = 'ai-expense-global-daily'; + +select is( + public.consume_ai_expense_quota_v2('global-budget-client-a', 'text'), + 'allowed', + 'the final request inside the project-wide text budget is allowed' +); +select is( + public.consume_ai_expense_quota_v2('global-budget-client-b', 'text'), + 'global_limit', + 'distributed clients are stopped at the project-wide text budget' +); + +update private.ai_expense_budget_limits +set enabled = false +where input_mode = 'voice'; + +select is( + public.consume_ai_expense_quota_v2('disabled-voice-client', 'voice'), + 'global_limit', + 'a project budget can disable one AI input mode without a deployment' +); + select * from finish(); rollback; diff --git a/supabase/tests/shared_activities.sql b/supabase/tests/shared_activities.sql index 359aae9..28793a9 100644 --- a/supabase/tests/shared_activities.sql +++ b/supabase/tests/shared_activities.sql @@ -1,6 +1,6 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(61); +select plan(84); select has_schema('private', 'private schema exists'); select has_table('private', 'shared_activities', 'shared activity storage exists'); @@ -59,6 +59,16 @@ select is( true, 'anonymous clients can execute the revision-only polling wrapper' ); +select is( + has_function_privilege('anon', 'private.end_shared_activity(text,text)', 'EXECUTE'), + false, + 'anonymous clients cannot execute the private revocation function' +); +select is( + has_function_privilege('anon', 'public.end_shared_activity(text,text)', 'EXECUTE'), + true, + 'anonymous capability holders can execute only the public revocation wrapper' +); select ok( exists ( select 1 @@ -75,6 +85,7 @@ select has_function('public', 'poll_shared_activity', array['text', 'text'], 'po select has_function('public', 'update_shared_activity', array['text', 'text', 'bigint', 'jsonb'], 'update RPC exists'); select has_function('public', 'update_shared_activity_v2', array['text', 'text', 'bigint', 'jsonb'], 'conflict-aware update RPC exists'); select has_function('public', 'update_shared_activity_v3', array['text', 'text', 'bigint', 'jsonb'], 'rejection-aware update RPC exists'); +select has_function('public', 'end_shared_activity', array['text', 'text'], 'revocation RPC exists'); create temporary table created_activity as select * from public.create_shared_activity(jsonb_build_object( @@ -255,6 +266,153 @@ select is( 'null snapshots are rejected explicitly' ); +create temporary table strict_snapshot as +select jsonb_build_object( + 'version', 2, + 'sender', jsonb_build_object('id', 'me', 'name', 'Alex', 'initials', 'A', 'color', '#16724c'), + 'group', jsonb_build_object( + 'id', 'trip', + 'name', 'Weekend', + 'emoji', '✦', + 'memberIds', jsonb_build_array('me', 'maya'), + 'currency', 'USD' + ), + 'friends', jsonb_build_array(jsonb_build_object('id', 'maya', 'name', 'Maya', 'initials', 'M', 'color', '#abc')), + 'expenses', jsonb_build_array(jsonb_build_object( + 'id', 'dinner', + 'groupId', 'trip', + 'title', 'Dinner', + 'amount', 10, + 'payerId', 'me', + 'splitMethod', 'equal', + 'shares', jsonb_build_object('me', 5, 'maya', 5), + 'createdAt', '2026-08-08T20:00:00.000Z', + 'updatedAt', '2026-08-08T20:01:00.000Z' + )) +) as snapshot; + +select is( + private.is_valid_activity_snapshot(snapshot), + true, + 'the strict validator accepts a complete activity graph' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{sender,id}', '"someone-else"')), + false, + 'the strict validator rejects ambiguous sender identities' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{friends}', (snapshot -> 'friends') || (snapshot -> 'friends'))), + false, + 'the strict validator rejects duplicate friend identifiers' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{group,memberIds}', '["me"]')), + false, + 'the strict validator requires the exact activity participant set' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{group,memberIds}', '["me","maya","maya"]')), + false, + 'the strict validator rejects duplicate group members' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses}', (snapshot -> 'expenses') || (snapshot -> 'expenses'))), + false, + 'the strict validator rejects duplicate expense identifiers' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,groupId}', '"other"')), + false, + 'the strict validator rejects expenses for another activity' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,payerId}', '"missing"')), + false, + 'the strict validator rejects unknown payers' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,shares}', '{"missing":10}')), + false, + 'the strict validator rejects unknown share recipients' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,shares}', '{"me":4,"maya":5}')), + false, + 'the strict validator rejects share totals that do not match the amount' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,amount}', '-1')), + false, + 'the strict validator rejects negative amounts' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,shares}', '{}')), + false, + 'the strict validator rejects empty split maps' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{group,currency}', '"BTC"')), + false, + 'the strict validator rejects unsupported currencies' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,createdAt}', '"yesterday"')), + false, + 'the strict validator rejects invalid creation timestamps' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,updatedAt}', '"yesterday"')), + false, + 'the strict validator rejects invalid update timestamps' +) from strict_snapshot; +select is( + private.is_valid_activity_snapshot(jsonb_set(snapshot, '{expenses,0,kind}', '"settlement"')), + false, + 'the strict validator rejects malformed settlement records' +) from strict_snapshot; + +create temporary table revoked_activity as +select * from public.create_shared_activity((select snapshot from strict_snapshot)); + +select set_config('response.status', '200', true); +select is( + ( + select count(*) + from revoked_activity activity + cross join lateral public.end_shared_activity(activity.code, repeat('0', 64)) + ), + 0::bigint, + 'an invalid capability cannot revoke a Live activity' +); +select is( + ( + select count(*) + from revoked_activity activity + cross join lateral public.load_shared_activity(activity.code, activity.edit_token) + ), + 1::bigint, + 'a failed revocation leaves the Live activity available' +); +select is( + ( + select ended.code + from revoked_activity activity + cross join lateral public.end_shared_activity(activity.code, activity.edit_token) ended + ), + (select code from revoked_activity), + 'a valid capability revokes its Live activity' +); +select is( + ( + select count(*) + from revoked_activity activity + cross join lateral public.load_shared_activity(activity.code, activity.edit_token) + ), + 0::bigint, + 'a revoked Live activity cannot be loaded again' +); + select is( ( select count(*) diff --git a/vite.config.ts b/vite.config.ts index 9d935b8..c4e5709 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,15 @@ import react from '@vitejs/plugin-react' import { defineConfig, loadEnv, type Plugin } from 'vite' import { VitePWA } from 'vite-plugin-pwa' -import { resolveSupabaseConnectOrigin } from './src/security/contentSecurityPolicy' +import { resolveConnectSources } from './src/security/contentSecurityPolicy' const appDescription = 'Split group expenses fairly for free—no account required, with optional live collaboration.' -function contentSecurityPolicyPlugin(supabaseUrl?: string): Plugin { - const supabaseOrigin = resolveSupabaseConnectOrigin(supabaseUrl) +function contentSecurityPolicyPlugin(supabaseUrl: string | undefined, development: boolean): Plugin { + const connectSources = resolveConnectSources(supabaseUrl, development) return { name: 'tally-content-security-policy', - transformIndexHtml: html => html.replaceAll('__TALLY_SUPABASE_ORIGIN__', supabaseOrigin), + transformIndexHtml: html => html.replaceAll('__TALLY_CONNECT_SOURCES__', connectSources), } } @@ -19,7 +19,7 @@ export default defineConfig(({ mode }) => { return { plugins: [ - contentSecurityPolicyPlugin(supabaseUrl), + contentSecurityPolicyPlugin(supabaseUrl, mode !== 'production' || process.env.TALLY_INCLUDE_DEV_CSP === 'true'), react(), VitePWA({ strategies: 'injectManifest',