diff --git a/README.md b/README.md index 918afff..258d5d7 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ The complete contract is documented in [TESTING.md](TESTING.md). Every pull request is type-checked, linted, tested, and built by GitHub Actions. A successful `main` release applies pending Supabase migrations, deploys the AI Edge Function, and then publishes the configured frontend artifact to GitHub Pages. Follow [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for the one-time environment setup and release procedure. -The conversational expense flow was validated through an isolated preview before production. Its model strategy, privacy boundary, rate limits, synchronization behavior, test checklist, and preview deployment runbook live in [docs/AI_EXPENSE_PREVIEW.md](docs/AI_EXPENSE_PREVIEW.md). +The conversational expense flow keeps manual entry as the default and uses separate client/server kill switches. Its model strategy, privacy boundary, rate limits, synchronization behavior, test checklist, and isolated preview runbook for future AI changes live in [docs/AI_EXPENSE_PREVIEW.md](docs/AI_EXPENSE_PREVIEW.md). ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 52430c6..3662d68 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,6 +16,6 @@ Local activities and identities are stored in browser `localStorage`. Live activ Anyone with a complete live URL can read and edit that activity. There are no accounts, participant-level permissions, token revocation, or audit trail in this release. Treat leaked URLs as compromised and avoid regulated or highly sensitive data. -Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes rate-limit budget without being stored. Shared snapshot URLs are decoded with strict compressed-input and expanded-output limits. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page. +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. 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 a527759..b647c71 100644 --- a/docs/AI_EXPENSE_PREVIEW.md +++ b/docs/AI_EXPENSE_PREVIEW.md @@ -1,17 +1,17 @@ -# Conversational expense entry preview +# Conversational expense entry -This experiment keeps manual expense entry as the default and adds two optional shortcuts: a typed description or a voice recording. Either shortcut can describe one expense or several expenses at once. Tally preserves their order, presents every draft for review, and saves nothing until the user confirms. +Conversational expense entry is available in production as an optional shortcut alongside the default manual form. A typed description or voice recording can describe one expense or several expenses at once. Tally preserves their order, presents every draft for review, and saves nothing until the user confirms. -## Deployment boundary +## Release boundary -Before production release, the experiment on `codex/ai-expense-entry` uses: +The feature was validated on an isolated frontend and Supabase preview before its production release. Future model, prompt, quota, or audio changes should use the same boundary: - a separate frontend URL and origin, so preview local storage and PWA caches cannot affect production; - a separate Supabase preview project, so migrations, rate limits, logs, and Edge Function secrets cannot affect production; - a preview-only OpenRouter API key with a low account limit; -- both feature switches described below. +- both feature switches described below, so either deployment can disable AI entry without affecting manual entry. -Production enables the feature only after the preview gates pass, the production Edge Function secrets are configured, and `VITE_AI_EXPENSE_ENABLED=true` is added to the GitHub `production` environment. Without that exact client value—or with `AI_EXPENSE_ENABLED=false` on the server—the stable manual expense flow remains available while AI entry is disabled. +Production enables the feature only when the Edge Function secrets are configured, `VITE_AI_EXPENSE_ENABLED=true` is present in the GitHub `production` environment, and `AI_EXPENSE_ENABLED=true` is set on the server. Without both exact flag values, the stable manual expense flow remains available while AI entry is disabled. ## Why this design is safe to trial @@ -53,7 +53,7 @@ Use the local Supabase URL and publishable key printed by `npm run backend:start VITE_AI_EXPENSE_ENABLED=true ``` -Copy `supabase/functions/.env.example` to the ignored `supabase/functions/.env.local`, set a preview OpenRouter key, then serve the function: +Copy `supabase/functions/.env.example` to the ignored `supabase/functions/.env.local`, set a development OpenRouter key, then serve the function: ```bash npx supabase functions serve parse-expense --env-file supabase/functions/.env.local @@ -61,7 +61,7 @@ npx supabase functions serve parse-expense --env-file supabase/functions/.env.lo The Edge Function also requires `AI_EXPENSE_ENABLED=true`; either switch disables the feature independently. -Run the complete gates before sharing the preview: +Run the complete gates before sharing a local or hosted preview: ```bash npm run typecheck @@ -71,11 +71,11 @@ npm run test:backend npm run test:e2e ``` -## Separate preview deployment +## Isolated preview for future AI changes ### 1. Backend -Create a dedicated Supabase preview project. Apply this branch’s migrations and deploy only `parse-expense` to that project. Configure these Edge Function secrets in the preview project: +Create a dedicated Supabase preview project. Apply the candidate branch's migrations and deploy only `parse-expense` to that project. Configure these Edge Function secrets in the preview project: ```text AI_EXPENSE_ENABLED=true @@ -91,7 +91,7 @@ If voice recording stops normally but the app reports that its AI budget was rea ### 2. Frontend -Create a separate Cloudflare Pages project connected to the same GitHub repository, with `codex/ai-expense-entry` as that project’s production branch. +Create or reuse a separate Cloudflare Pages project connected to the same GitHub repository, with the candidate AI branch as that project's production branch. Use: @@ -111,13 +111,13 @@ This yields a stable `*.pages.dev` preview origin while the existing GitHub Page - Frontend stop: set `VITE_AI_EXPENSE_ENABLED=false` and rebuild the preview. - Full preview rollback: redeploy the previous preview commit. In production, use the independent server and client kill switches documented in [DEPLOYMENT.md](DEPLOYMENT.md). -## Keeping the experiment current +## Keeping a future AI experiment current -Regular customer fixes continue to land on `main`. Before each preview deployment, bring them into the experiment and rerun every gate: +Regular customer fixes continue to land on `main`. Before each preview deployment, bring them into the candidate branch and rerun every gate: ```bash git fetch origin main -git switch codex/ai-expense-entry +git switch git merge --no-edit origin/main npm run typecheck npm run lint @@ -126,7 +126,7 @@ npm run test:backend npm run test:e2e ``` -Resolve any conflict in favor of the current `main` behavior first, then reapply the smallest AI integration. Keep the preview as a pull request so GitHub continuously shows whether it is mergeable and whether CI remains green. Merge only after model quality, cost, privacy copy, user feedback, production secrets, and every automated gate are acceptable. +Resolve any conflict in favor of the current `main` behavior first, then reapply the smallest AI integration. Keep the candidate as a pull request so GitHub continuously shows whether it is mergeable and whether CI remains green. Merge only after model quality, cost, privacy copy, user feedback, production secrets, and every automated gate are acceptable. ## Trial checklist diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index f204d86..f605612 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -41,6 +41,8 @@ Opening the app records its initial surface. Successful product actions are meas `friend_added` records one event after a successful friend-add action, including activity creation when at least one initial friend is supplied. Adding several friends in one submission still records one event. Failed Live saves do not count, and the request never includes friend names, IDs, or a friend count. +`expense_added` records one event after a successful add action. Saving an AI-generated batch still records one event, matching the single confirmation and atomic state update rather than sending one analytics request per expense. The request never includes the batch size or any expense data. + `live_share_clicked` is also an intentional interaction event. It records when someone chooses **Start live activity**, before the backend request begins. Compare it with `live_activity_created` to distinguish sharing intent from successful Live activity creation. It contains no activity or link data. `summary_export_clicked` records when someone chooses **Share balances only**, before PNG generation or any share, download, or clipboard fallback begins. It measures export intent rather than successful delivery and contains no activity name, participants, expenses, balances, or generated image data. diff --git a/package-lock.json b/package-lock.json index c7a2369..a29d4e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4746,9 +4746,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -5736,9 +5736,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -6861,16 +6861,6 @@ } } }, - "node_modules/jsdom/node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7665,7 +7655,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -7683,7 +7675,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8999,6 +8991,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 2fb10a1..50fc01f 100644 --- a/package.json +++ b/package.json @@ -65,11 +65,13 @@ "vitest": "^4.1.10" }, "overrides": { - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "ejs": "6.0.1", - "fast-uri": "3.1.4", + "fast-uri": "3.1.5", "nanoid": "3.3.15", - "sharp": "0.35.3" + "postcss": "8.5.25", + "sharp": "0.35.3", + "undici": "7.29.0" }, "type": "module" } diff --git a/src/App.test.tsx b/src/App.test.tsx index 6411a33..7c17f6e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4,7 +4,8 @@ import { StrictMode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import App from './App' import type { AnalyticsClient } from './analytics' -import { Avatar, FreshStart, ModalShell, Sidebar, Topbar } from './components/AppShell' +import { Avatar, FreshStart, Sidebar, Topbar } from './components/AppShell' +import { ModalShell } from './components/Dialog' import { ACTIVITY_IDENTITY_KEY } from './data/activityIdentity' import { IDENTITY_KEY } from './data/identity' import { EMPTY_STATE, loadState, parseState, saveState, STORAGE_KEY } from './data/storage' @@ -879,7 +880,6 @@ describe('complete app workflows', () => { expect(analyticsClient.track.mock.calls.filter(([event]) => event === 'expense_added')) .toEqual([ ['expense_added', 'local', 'en'], - ['expense_added', 'local', 'en'], ]) expect(analyticsClient.track.mock.calls.filter(([event]) => event.startsWith('ai_'))) .toEqual([ @@ -1185,7 +1185,6 @@ describe('complete app workflows', () => { expect(screen.getByRole('status')).toHaveTextContent('2 expenses were added to the live activity.') expect(analyticsClient.track.mock.calls.filter(([event]) => event === 'expense_added')).toEqual([ ['expense_added', 'live', 'en'], - ['expense_added', 'live', 'en'], ]) expect(analyticsClient.track.mock.calls.filter(([event]) => event.startsWith('ai_'))).toEqual([ ['ai_text_requested', 'live', 'en'], diff --git a/src/App.tsx b/src/App.tsx index 0cf69b4..72745b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -295,13 +295,13 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, liveActi JSON.stringify(['add-expenses', expenses.map(item => [item.title, item.amount, item.payerId, item.splitMethod, item.shares])]), ) if (saved) { - expenses.forEach(() => analyticsClient?.track('expense_added', 'live', locale)) + analyticsClient?.track('expense_added', 'live', locale) closeExpenseModal() } return } setState(current => addLocalExpenses(current, expenses)) - expenses.forEach(() => analyticsClient?.track('expense_added', 'local', locale)) + analyticsClient?.track('expense_added', 'local', locale) setActivityFeedback({ groupId: expenses[0].groupId, message: t('feedback.addedExpenses', { count: expenses.length }) }) closeExpenseModal() } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index af52c3e..5d03c71 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -16,8 +16,6 @@ import type { ActivityGroup, Member } from '../domain/models' import { useLocalization } from '../i18n/LocalizationContext' import { Button, IconButton } from './Button' -export { ModalShell } from './Dialog' - const EMPTY_LIVE_ACTIVITY_CODES: Record = {} export function Avatar({ member, size = 'md' }: { member: Member; size?: 'sm' | 'md' | 'lg' }) { diff --git a/src/features/activity/ActivityModals.tsx b/src/features/activity/ActivityModals.tsx index dc168d8..65315a8 100644 --- a/src/features/activity/ActivityModals.tsx +++ b/src/features/activity/ActivityModals.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react' import { ArrowRight, CircleDollarSign, Mic, Pencil, Sparkles, Users } from 'lucide-react' -import { Avatar, ModalShell } from '../../components/AppShell' +import { Avatar } from '../../components/AppShell' +import { ModalShell } from '../../components/Dialog' import { Button } from '../../components/Button' import { SelectMenu, type SelectMenuOption } from '../../components/SelectMenu' import { activityCurrency, currencyLabel, currencySymbol, defaultCurrencyForLocale, SUPPORTED_CURRENCIES, type CurrencyCode } from '../../domain/currency' diff --git a/src/features/changelog/ChangelogModal.tsx b/src/features/changelog/ChangelogModal.tsx index 9bca140..d4cac02 100644 --- a/src/features/changelog/ChangelogModal.tsx +++ b/src/features/changelog/ChangelogModal.tsx @@ -8,7 +8,7 @@ import { SlidersHorizontal, type LucideIcon, } from 'lucide-react' -import { ModalShell } from '../../components/AppShell' +import { ModalShell } from '../../components/Dialog' import { Button } from '../../components/Button' import { useLocalization } from '../../i18n/LocalizationContext' import { diff --git a/src/features/identity/IdentityModal.tsx b/src/features/identity/IdentityModal.tsx index 51dd931..7822bfb 100644 --- a/src/features/identity/IdentityModal.tsx +++ b/src/features/identity/IdentityModal.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { Globe2, UserRound } from 'lucide-react' -import { ModalShell } from '../../components/AppShell' +import { ModalShell } from '../../components/Dialog' import { Button } from '../../components/Button' import { useLocalization } from '../../i18n/LocalizationContext' import { LanguageControl } from './LanguageControl' diff --git a/src/features/sharing/JoinActivityModal.tsx b/src/features/sharing/JoinActivityModal.tsx index 5ee5d54..66dcb1b 100644 --- a/src/features/sharing/JoinActivityModal.tsx +++ b/src/features/sharing/JoinActivityModal.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { ClipboardPaste, Link2, Smartphone } from 'lucide-react' -import { ModalShell } from '../../components/AppShell' +import { ModalShell } from '../../components/Dialog' import { Button } from '../../components/Button' import { useLocalization } from '../../i18n/LocalizationContext' import { copyLink } from './shareLink' diff --git a/src/features/sharing/LiveActivityIdentityModal.tsx b/src/features/sharing/LiveActivityIdentityModal.tsx index 18a289b..1cf2acb 100644 --- a/src/features/sharing/LiveActivityIdentityModal.tsx +++ b/src/features/sharing/LiveActivityIdentityModal.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { UserRoundCheck } from 'lucide-react' -import { ModalShell } from '../../components/AppShell' +import { ModalShell } from '../../components/Dialog' import { Button } from '../../components/Button' import { SelectMenu } from '../../components/SelectMenu' import type { Member } from '../../domain/models' diff --git a/src/features/sharing/LiveActivityQrModal.tsx b/src/features/sharing/LiveActivityQrModal.tsx index 2216501..6d4dc2c 100644 --- a/src/features/sharing/LiveActivityQrModal.tsx +++ b/src/features/sharing/LiveActivityQrModal.tsx @@ -1,6 +1,6 @@ import { Copy, ScanQrCode, Share2, ShieldCheck } from 'lucide-react' import { QRCodeSVG } from 'qrcode.react' -import { ModalShell } from '../../components/AppShell' +import { ModalShell } from '../../components/Dialog' import { Button } from '../../components/Button' import { useLocalization } from '../../i18n/LocalizationContext' diff --git a/supabase/functions/parse-expense/deno.json b/supabase/functions/parse-expense/deno.json index 24e5e66..1b48198 100644 --- a/supabase/functions/parse-expense/deno.json +++ b/supabase/functions/parse-expense/deno.json @@ -1,7 +1,7 @@ { "imports": { - "@supabase/functions-js": "jsr:@supabase/functions-js@^2", - "@supabase/server": "npm:@supabase/server@^1", + "@supabase/functions-js": "jsr:@supabase/functions-js@2.110.8", + "@supabase/server": "npm:@supabase/server@1.4.1", "zod": "npm:zod@4.4.3" } }