diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 089de5f..b962ea0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,7 +46,7 @@ Tally does not encode complete activities into share URLs. A local activity can Supabase stores the canonical live activity. A short activity code identifies the row, while a secret edit token in the URL fragment grants read/write access. The database stores only a SHA-256 hash of that token. Every update supplies an expected revision and increments it atomically, preventing silent last-write-wins data loss. -`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, secret-peppered request throttling, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly. +`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, secret-peppered request throttling, a byte-weighted project circuit breaker for validated Live creation, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly, and new database functions do not inherit browser execution by default. The frontend treats Supabase as canonical whenever a live capability is active. Local shortcut rows contain only navigation metadata and credentials; they are never a second writable activity copy. Live capabilities are trusted-group bearer credentials rather than user authorization. See `docs/LIVE_SHARING_EXPERIMENT.md` and `docs/DEPLOYMENT.md` before changing this boundary. @@ -54,7 +54,7 @@ The frontend treats Supabase as canonical whenever a live capability is active. The configured production build sends a fixed event enum through `public.record_analytics_event`. Local activities remain entirely in browser storage; recording a local event never uploads the activity itself. The browser sends only the event name, coarse surface, resolved `en`/`zh-CN` UI locale, and a random session-scoped token. Locale represents the app language rather than physical location. The database stores a SHA-256 hash of that token in `private.analytics_events`, and browser roles have no table or aggregate-view access. -The RPC validates every value, applies the existing secret-peppered request throttle, and incrementally deletes events older than 90 days. Private daily, hourly, and locale aggregates support event counts and anonymous session funnels. A three-argument RPC overload classifies older clients as `unknown`, preserving compatibility without guessing their locale. There is no third-party analytics fallback. See `docs/ANALYTICS.md` before adding events or properties. +The RPC validates every value, applies the existing secret-peppered request throttle, consumes a project-wide event budget only after validation, and incrementally deletes events older than 90 days. Private daily, hourly, and locale aggregates support event counts and anonymous session funnels. A three-argument RPC overload classifies older clients as `unknown`, preserving compatibility without guessing their locale. There is no third-party analytics fallback. See `docs/ANALYTICS.md` before adding events or properties. ## Feedback boundary diff --git a/PRIVACY.md b/PRIVACY.md index 5ec08f5..f82cd1a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -16,6 +16,14 @@ When someone chooses **Share live**, the activity name, participant names, expen Anyone with the complete live URL can read and edit the activity. Share it only with intended participants. Removing a shortcut from one browser does not delete the backend activity for everyone. +## Optional AI expense drafts + +Manual expense entry remains the default. When someone deliberately chooses text or voice AI, Tally sends the expense description or recording through OpenRouter to a selected model provider. The request also includes the activity currency, app language, participant IDs and names, the activity member selected as the current user, and any answers to follow-up questions. This context is required to resolve phrases such as “I paid” and participant names correctly. + +Every AI request opts out of provider data collection and is restricted to OpenRouter endpoints marked for Zero Data Retention. Tally does not store descriptions, recordings, model responses, or unfinished drafts in its backend, and no expense is added to an activity until the person reviews and confirms it. Audio is held only long enough to process that request. + +AI providers and models can change as availability and cost change, but the zero-retention routing requirement remains. Avoid including regulated or highly sensitive financial, medical, identity, or payment-card information in an AI description or recording. + ## Abuse protection and analytics The backend rate-limits requests using a secret-peppered one-way identifier derived from the client IP address; neither the raw address nor an unpeppered IP hash is stored in the application rate-limit table. diff --git a/README.md b/README.md index dc6d1b9..7fd7b7b 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Every push and pull request must pass: - ESLint with TypeScript and React Hooks rules and zero warnings; - component and helper tests; - Playwright integration tests against the production GitHub Pages build; -- pgTAP contracts for live-activity, analytics, and feedback access control, graph validation, revocation, privacy, retention, and per-client/project-wide rate limits; +- pgTAP contracts for live-activity, analytics, and feedback access control, fail-closed function defaults, graph validation, revocation, privacy, retention, and per-client/project-wide budgets; - 100% statement, branch, function, and line coverage; - a production static build. diff --git a/SECURITY.md b/SECURITY.md index 3283718..eb3ae97 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, edit, and end that activity. **End live sharing** deletes the canonical backend record immediately; browsers that previously opened it retain only their local recovery copies. There are no accounts, participant-level permissions, token rotation, or audit trail in this release. Treat leaked URLs as compromised and avoid regulated or highly sensitive data. -Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes rate-limit budget without being stored. AI calls have separate per-client text/voice quotas plus server-only project-wide daily ceilings. Live activity payloads are validated against the same bounded graph, reference, amount, settlement, timestamp, and snapshot-size rules in the browser and database. Production builds allow connections only to the configured HTTPS Supabase origin. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page. +Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes client rate-limit budget without being stored. Validated Live creation is additionally bounded by a server-only, byte-weighted project budget, and validated analytics has an independent project event ceiling. AI calls have separate per-client text/voice quotas plus server-only project-wide daily ceilings, and each OpenRouter request requires a Zero Data Retention endpoint. Future database functions are closed to browser execution by default and must opt in through an explicit grant. Live activity payloads are validated against the same bounded graph, reference, amount, settlement, timestamp, and snapshot-size rules in the browser and database. Production builds allow connections only to the configured HTTPS Supabase origin. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page. Reports involving capability leakage, RPC privilege escalation, rate-limit bypasses, browser storage exposure, exported summaries, dependency vulnerabilities, or deployment configuration are in scope. Never include a real live activity URL, database password, service-role key, or Supabase access token in a report. diff --git a/docs/AI_EXPENSE_PREVIEW.md b/docs/AI_EXPENSE_PREVIEW.md index a31ce02..19c87bc 100644 --- a/docs/AI_EXPENSE_PREVIEW.md +++ b/docs/AI_EXPENSE_PREVIEW.md @@ -19,7 +19,7 @@ Production enables the feature only when the Edge Function secrets are configure 2. The OpenRouter key exists only in the Supabase Edge Function. 3. Tiny, clearly incomplete category-only descriptions receive a deterministic, localized clarification before any provider request or quota consumption. 4. Substantive descriptions in any language, dialect, shorthand, or mixed language go to the model; language-specific regexes never block them. -5. The Edge Function accepts a publishable client request, checks a server-only rate limit, prefers the cheapest model that meets a three-second p90 latency target, and requests no provider data collection. +5. The Edge Function accepts a publishable client request, checks a server-only rate limit, prefers the cheapest model that meets a three-second p90 latency target, opts out of provider data collection, and requires an OpenRouter Zero Data Retention endpoint. 6. A strict JSON Schema constrains every model-generated expense without imposing a fixed expense-count limit. 7. Titles and clarification questions follow the description's language, with the interface locale used only as a fallback. 8. Zod and deterministic business rules reject unknown members, invalid cents, duplicate participants, and exact splits that do not equal the total. @@ -31,7 +31,7 @@ This does not use RAG: there is no external knowledge to retrieve. Reliability c ## Model and cost control -Typed descriptions use the candidates `google/gemma-4-26b-a4b-it:free` and `google/gemini-2.5-flash-lite`. OpenRouter prefers the cheapest eligible route whose recent p90 latency is at most three seconds, while keeping slower routes available as fallbacks. Voice goes directly to the audio-capable `google/gemini-2.5-flash-lite`, avoiding a separate transcription request. Override it with `OPENROUTER_VOICE_MODEL` only after running the same multilingual voice and browser checks. +Typed descriptions use the candidates `google/gemma-4-26b-a4b-it:free` and `google/gemini-2.5-flash-lite`. OpenRouter prefers the cheapest eligible zero-retention route whose recent p90 latency is at most three seconds, while keeping slower eligible routes available as fallbacks. Voice goes directly to the audio-capable `google/gemini-2.5-flash-lite`, avoiding a separate transcription request. Override either model only after confirming it appears in OpenRouter's current ZDR catalog and running the same multilingual voice and browser checks. A successful provider response that does not satisfy the expense contract is treated as an incomplete conversation: the user receives a localized prompt to restate the amount, payer, and participants. A genuine upstream failure is logged without the expense text and shown as a model-specific retry/manual-entry message. The request has a bounded timeout so an unavailable route cannot leave the user waiting indefinitely. diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index 7253490..2310b23 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -35,7 +35,7 @@ Do not add arbitrary metadata to this contract. Analytics must never receive URL `src/analytics.ts` sends events as non-blocking `fetch` requests with `keepalive`, omitted credentials, and no referrer. Failed analytics requests are ignored and never affect local or live workflows. -`public.record_analytics_event` is the only browser-callable database entry point. It validates the event, surface, locale, and session-token shape; applies hashed-IP throttling; hashes the session token; and inserts into `private.analytics_events`. Browser roles cannot read or write that table directly and cannot read `private.analytics_daily`, `private.analytics_hourly`, or `private.analytics_locale_daily`. +`public.record_analytics_event` is the only browser-callable database entry point. It validates the event, surface, locale, and session-token shape; applies hashed-IP throttling; consumes one unit from a server-only 5,000-event rolling daily project budget; hashes the session token; and inserts into `private.analytics_events`. Invalid events still consume their client throttle but do not consume project capacity. Browser roles cannot read or write the event or budget tables directly and cannot read `private.analytics_daily`, `private.analytics_hourly`, or `private.analytics_locale_daily`. Opening the app records its initial surface. Successful product actions are measured only after their local state update or live revision save succeeds. A failed expense or settlement save does not produce a success event. Currency selection is intentionally an interaction event: it records a deliberate change in either currency selector, even if the person later cancels activity creation or a live update cannot be saved. diff --git a/docs/LIVE_SHARING_EXPERIMENT.md b/docs/LIVE_SHARING_EXPERIMENT.md index fb4c998..c04c52b 100644 --- a/docs/LIVE_SHARING_EXPERIMENT.md +++ b/docs/LIVE_SHARING_EXPERIMENT.md @@ -28,7 +28,7 @@ The link is intentionally a bearer capability: anyone who has the full link can - **PostgREST RPCs** provide create, lightweight revision polling, full snapshot loading, revision-checked updates, and explicit capability revocation. - The storage table and privileged functions live in the non-exposed `private` schema. - Narrow security-definer `public` wrappers are callable with the project's publishable key. Browser roles cannot query private tables or execute private functions directly. -- RLS, validated JSON constraints, hashed-IP request throttling, statement timeouts, and 90-day sliding expiration provide defense in depth. +- RLS, validated JSON constraints, hashed-IP request throttling, a server-only five-MiB rolling daily snapshot budget, statement timeouts, and 90-day sliding expiration provide defense in depth. Only validated snapshots consume the shared byte budget. - TanStack Query owns the in-memory live record, visibility-aware polling, reconnect/focus refreshes, and revision-checked mutations. - Zod schemas validate versioned snapshots, references, settlements, and size limits before data enters or leaves the client. @@ -58,6 +58,7 @@ An update sends `expectedRevision`. A conditional database update compares the c - Unknown code or invalid token: SQLSTATE `P0002`, surfaced as `not-found` without revealing which part was wrong. - Invalid snapshot or revision: SQLSTATE `22023`, surfaced as `invalid-input`. - Too many requests from one network: HTTP `429`, surfaced as `rate-limit`. +- Valid Live creation beyond the project-wide snapshot budget: HTTP `429`, surfaced through the same retryable `rate-limit` experience. - Valid capability + **End live sharing**: atomically delete the canonical record; later loads and saves receive the same `not-found` response as an unknown code or token. The UI immediately loads the latest record, keeps the editor open, and asks the person to review and save again. Visible live-activity tabs poll a lightweight revision-only RPC every 15 seconds and fetch the full snapshot only when that revision changes. They also check immediately when they regain focus or reconnect. **Refresh latest** remains available as a manual fallback. Automatic field-level merging should wait until we have evidence that whole-activity optimistic concurrency is too disruptive. @@ -98,4 +99,4 @@ The recovery-copy and revocation features are additive. Existing `#live=` capabi - Vitest enforces 100% statement, branch, function, and line coverage, including happy paths and failure states. - Playwright covers isolated creator, editor, observer, and pre-upgrade bookmark-only browser sessions, including additive mirror backfilling, latest-state synchronization, offline read-only behavior, explicit duplication, revocation propagation, and preserved recovery copies. Component and integration tests cover a failed first upgrade connection, stale-save recovery, and continuing locally after confirmed expiration. -- pgTAP verifies the SQL capability, privacy, strict graph-validation, revocation, and optimistic-concurrency contract. +- pgTAP verifies the SQL capability, privacy, fail-closed function defaults, strict graph validation, project-wide storage budgeting, revocation, and optimistic-concurrency contract. diff --git a/src/features/aiExpense/aiExpensePrompt.test.ts b/src/features/aiExpense/aiExpensePrompt.test.ts index aaada6e..8df52d1 100644 --- a/src/features/aiExpense/aiExpensePrompt.test.ts +++ b/src/features/aiExpense/aiExpensePrompt.test.ts @@ -44,6 +44,7 @@ describe('OpenRouter expense prompt', () => { preferred_max_latency: { p90: 3 }, require_parameters: true, sort: { by: 'price', partition: 'none' }, + zdr: true, }) expect(built).not.toHaveProperty('reasoning') expect(built.max_tokens).toBe(700) diff --git a/src/features/aiExpense/aiExpensePrompt.ts b/src/features/aiExpense/aiExpensePrompt.ts index feccbec..257c836 100644 --- a/src/features/aiExpense/aiExpensePrompt.ts +++ b/src/features/aiExpense/aiExpensePrompt.ts @@ -174,6 +174,7 @@ export function buildOpenRouterRequest( preferred_max_latency: { p90: 3 }, require_parameters: true, sort: { by: 'price', partition: 'none' }, + zdr: true, }, temperature: 0, max_tokens: batchMode ? 8_000 : 700, diff --git a/src/features/aiExpense/parseExpenseHandler.test.ts b/src/features/aiExpense/parseExpenseHandler.test.ts index 36d3adb..bd32d3b 100644 --- a/src/features/aiExpense/parseExpenseHandler.test.ts +++ b/src/features/aiExpense/parseExpenseHandler.test.ts @@ -283,7 +283,11 @@ describe('parse expense Edge Function handler', () => { expect(body.models).toEqual([DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FALLBACK_MODEL]) expect(JSON.parse(body.messages[1].content)).toMatchObject({ currentMemberId: 'me' }) expect(body).not.toHaveProperty('model') - expect(body.provider).toMatchObject({ allow_fallbacks: true, data_collection: 'deny' }) + expect(body.provider).toMatchObject({ + allow_fallbacks: true, + data_collection: 'deny', + zdr: true, + }) expect(init.headers).toMatchObject({ authorization: 'Bearer secret-key' }) expect(init.signal).toBeInstanceOf(AbortSignal) }) diff --git a/src/i18n/localization.ts b/src/i18n/localization.ts index ba1db46..1e6ebe8 100644 --- a/src/i18n/localization.ts +++ b/src/i18n/localization.ts @@ -164,7 +164,7 @@ const en = { 'expense.aiExample': 'For each expense, include who paid, the amount, and who should be included.', 'expense.aiGenerate': 'Create draft', 'expense.aiWorking': 'Creating draft…', - 'expense.aiPrivacy': 'Your description is sent to the configured AI provider only to create drafts. Nothing is saved until you confirm it.', + 'expense.aiPrivacy': 'Your description is sent through OpenRouter only to a zero-retention model provider. Nothing is saved until you confirm it.', 'expense.aiClarification': 'One quick question', 'expense.aiAnswer': 'Your answer', 'expense.aiContinue': 'Update draft', @@ -207,7 +207,7 @@ const en = { 'expense.voiceModelUnavailable': 'The voice model could not respond. Try again shortly, use text AI, or enter the expense manually.', 'expense.voiceCredits': "Voice AI has reached its current budget. Text AI and manual entry are still available.", 'expense.voiceInvalid': 'Tally could not turn that recording into a reliable draft. Say the total amount, who paid, and who should share it—or enter it manually.', - 'expense.voicePrivacy': 'The recording is sent to the configured AI provider only to create drafts. Tally does not store the audio.', + 'expense.voicePrivacy': 'Your recording is sent through OpenRouter only to a zero-retention model provider. Tally does not store the audio.', 'expense.voiceClarificationHelp': 'You can type this follow-up; Tally will keep all expense details from your recording.', 'expense.addTitle': 'Add a shared expense', 'expense.editTitle': 'Edit expense', @@ -465,7 +465,7 @@ const zhCN: Record = { '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.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': '这段描述只会通过 OpenRouter 发送给不保留数据的模型服务;确认之前不会保存任何支出。', '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': '录音只会通过 OpenRouter 发送给不保留数据的模型服务,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': '包含全部支出、还款、总额和谁欠谁。', 'shareMenu.summaryHelpLive': '包含全部支出、还款和余额,并附上实时活动二维码。', 'shareMenu.endLive': '结束实时共享', 'shareMenu.endLiveHelp': '当前邀请链接会立即失效,已打开活动的设备仍会保留恢复副本。', 'shareMenu.endLiveAction': '结束共享', diff --git a/supabase/functions/.env.example b/supabase/functions/.env.example index fe94342..ea1c7e2 100644 --- a/supabase/functions/.env.example +++ b/supabase/functions/.env.example @@ -3,7 +3,8 @@ AI_EXPENSE_ENABLED=true OPENROUTER_API_KEY=replace-with-a-preview-only-key # The free candidate wins on price when it meets the latency target; the paid -# candidate protects responsiveness and availability when it does not. +# candidate protects responsiveness and availability when it does not. Every +# request also enforces OpenRouter Zero Data Retention routing in application code. OPENROUTER_MODEL=google/gemma-4-26b-a4b-it:free OPENROUTER_FALLBACK_MODEL=google/gemini-2.5-flash-lite diff --git a/supabase/migrations/20260810170808_harden_public_api_budgets_and_defaults.sql b/supabase/migrations/20260810170808_harden_public_api_budgets_and_defaults.sql new file mode 100644 index 0000000..233b4b4 --- /dev/null +++ b/supabase/migrations/20260810170808_harden_public_api_budgets_and_defaults.sql @@ -0,0 +1,289 @@ +-- Keep future browser-facing database functions fail closed, and bound the +-- project-wide storage impact of anonymous live creation and analytics. + +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema private + revoke execute on functions from public, anon, authenticated, service_role; + +create table private.public_api_budget_limits ( + operation text primary key check (operation in ('create_bytes', 'analytics_events')), + daily_limit bigint not null check (daily_limit between 1 and 1000000000), + enabled boolean not null default true, + updated_at timestamptz not null default clock_timestamp() +); + +create table private.public_api_budget_usage ( + operation text primary key references private.public_api_budget_limits(operation) on delete cascade, + window_started_at timestamptz not null, + used_units bigint not null check (used_units > 0) +); + +alter table private.public_api_budget_limits enable row level security; +alter table private.public_api_budget_usage enable row level security; +revoke all on table private.public_api_budget_limits, private.public_api_budget_usage + from public, anon, authenticated, service_role; + +-- Five MiB of validated live snapshots and 5,000 validated product events per +-- rolling day are generous for current traffic while providing a firm circuit +-- breaker. These values can be raised without a deployment if usage grows. +insert into private.public_api_budget_limits (operation, daily_limit) +values + ('create_bytes', 5 * 1024 * 1024), + ('analytics_events', 5000); + +create function private.enforce_public_api_project_budget( + p_operation text, + p_units bigint +) +returns void +language plpgsql +security definer +set search_path = '' +set statement_timeout = '2s' +as $$ +declare + request_time timestamptz := clock_timestamp(); + configured_limit bigint; + budget_enabled boolean; + current_units bigint; +begin + if p_operation not in ('create_bytes', 'analytics_events') + or p_units is null + or p_units < 1 + or p_units > 1000000000 then + raise exception using errcode = '22023', message = 'invalid_project_budget_configuration'; + end if; + + select daily_limit, enabled + into configured_limit, budget_enabled + from private.public_api_budget_limits + where operation = p_operation; + + if not found or not budget_enabled or p_units > configured_limit then + raise sqlstate 'PGRST' using + message = json_build_object( + 'code', 'project_rate_limit_exceeded', + 'message', 'This project is temporarily at capacity. Try again later.', + 'details', null, + 'hint', null + )::text, + detail = json_build_object( + 'status', 429, + 'status_text', 'Too Many Requests', + 'headers', json_build_object('Retry-After', '86400') + )::text; + end if; + + insert into private.public_api_budget_usage ( + operation, + window_started_at, + used_units + ) values ( + p_operation, + request_time, + p_units + ) + on conflict (operation) do update + set window_started_at = case + when private.public_api_budget_usage.window_started_at <= request_time - interval '1 day' + then request_time + else private.public_api_budget_usage.window_started_at + end, + used_units = case + when private.public_api_budget_usage.window_started_at <= request_time - interval '1 day' + then p_units + else least(private.public_api_budget_usage.used_units + p_units, 2000000000::bigint) + end + returning used_units into current_units; + + if current_units > configured_limit then + raise sqlstate 'PGRST' using + message = json_build_object( + 'code', 'project_rate_limit_exceeded', + 'message', 'This project is temporarily at capacity. Try again later.', + 'details', null, + 'hint', null + )::text, + detail = json_build_object( + 'status', 429, + 'status_text', 'Too Many Requests', + 'headers', json_build_object('Retry-After', '86400') + )::text; + end if; +end; +$$; + +revoke all on function private.enforce_public_api_project_budget(text, bigint) + from public, anon, authenticated, service_role; + +create or replace function private.create_shared_activity(p_snapshot jsonb) +returns table ( + code text, + edit_token text, + revision bigint, + snapshot jsonb, + updated_at timestamptz +) +language plpgsql +security definer +set search_path = '' +set statement_timeout = '5s' +as $$ +declare + generated_code text; + generated_token text; +begin + perform private.enforce_shared_activity_rate_limit('create', 10, interval '1 hour'); + + if not coalesce(private.is_valid_activity_snapshot(p_snapshot), false) then + perform private.set_anonymous_rpc_status(400); + return; + end if; + + -- Charge only validated input so malformed anonymous traffic cannot exhaust + -- the shared project circuit breaker. + perform private.enforce_public_api_project_budget( + 'create_bytes', + pg_column_size(p_snapshot)::bigint + ); + + delete from private.shared_activities + where id in ( + select id + from private.shared_activities + where expires_at <= clock_timestamp() + order by expires_at + limit 100 + ); + delete from private.shared_activity_rate_limits + where (identifier_hash, operation) in ( + select identifier_hash, operation + from private.shared_activity_rate_limits + where window_started_at < clock_timestamp() - interval '1 day' + limit 500 + ); + + for attempt in 1..5 loop + generated_code := upper(encode(extensions.gen_random_bytes(5), 'hex')); + generated_token := encode(extensions.gen_random_bytes(32), 'hex'); + begin + return query + insert into private.shared_activities (code, edit_token_hash, snapshot) + values (generated_code, extensions.digest(generated_token, 'sha256'), p_snapshot) + returning private.shared_activities.code, + generated_token, + private.shared_activities.revision, + private.shared_activities.snapshot, + private.shared_activities.updated_at; + return; + exception when unique_violation then + -- A 40-bit code collision is unlikely; generate another capability code. + end; + end loop; + + raise exception using errcode = '54000', message = 'activity_code_generation_failed'; +end; +$$; + +create or replace function private.record_analytics_event( + p_event_name text, + p_surface text, + p_session_token text, + p_locale text, + p_currency text +) +returns void +language plpgsql +security definer +set search_path = '' +set statement_timeout = '2s' +as $$ +begin + perform private.enforce_shared_activity_rate_limit('analytics', 300, interval '5 minutes'); + + if p_event_name is null or p_event_name not in ( + 'app_opened', + 'activity_created', + 'friend_added', + 'expense_added', + 'summary_export_clicked', + 'live_share_clicked', + 'live_activity_created', + 'live_activity_opened', + 'settlement_recorded', + 'currency_selected', + 'ai_text_requested', + 'ai_text_ready', + 'ai_text_clarification', + 'ai_text_failed', + 'ai_voice_requested', + 'ai_voice_ready', + 'ai_voice_clarification', + 'ai_voice_failed' + ) + or p_surface is null + or p_surface not in ('local', 'live', 'snapshot') + or p_session_token is null + or p_session_token !~ '^[a-f0-9]{32}$' + or p_locale is null + or p_locale not in ('en', 'zh-CN', 'unknown') + or ( + p_event_name = 'currency_selected' + and ( + p_currency is null + or p_currency not in ( + 'USD', 'EUR', 'GBP', 'CNY', 'JPY', + 'CAD', 'AUD', 'HKD', 'SGD', 'KRW', + 'INR', 'CHF', 'NZD', 'TWD', 'THB' + ) + ) + ) + or (p_event_name <> 'currency_selected' and p_currency is not null) then + perform private.set_anonymous_rpc_status(400); + return; + end if; + + -- Invalid telemetry remains constrained by the client rate limit above, but + -- cannot consume the project-wide budget or poison analytics storage. + perform private.enforce_public_api_project_budget('analytics_events', 1); + + if p_event_name = 'app_opened' then + delete from private.analytics_events + where id in ( + select id + from private.analytics_events + where occurred_at < clock_timestamp() - interval '90 days' + order by occurred_at + limit 500 + ); + end if; + + insert into private.analytics_events ( + event_name, + surface, + session_hash, + locale, + currency + ) + values ( + p_event_name, + p_surface, + extensions.digest(p_session_token, 'sha256'), + p_locale, + p_currency + ); +end; +$$; + +revoke all on function private.create_shared_activity(jsonb) + from public, anon, authenticated, service_role; +revoke all on function private.record_analytics_event(text, text, text, text, text) + from public, anon, authenticated, service_role; + +comment on table private.public_api_budget_limits is + 'Server-only rolling daily circuit breakers for anonymous live storage and analytics.'; +comment on table private.public_api_budget_usage is + 'Project-wide rolling usage counters; contains no client identifiers or activity data.'; +comment on function private.enforce_public_api_project_budget(text, bigint) is + 'Atomically consumes validated project capacity and returns HTTP 429 when exhausted.'; diff --git a/supabase/tests/feedback.sql b/supabase/tests/feedback.sql index 8616e1f..d9239b0 100644 --- a/supabase/tests/feedback.sql +++ b/supabase/tests/feedback.sql @@ -2,6 +2,12 @@ begin; create extension if not exists pgtap with schema extensions; select plan(47); +-- Keep the suite repeatable against a developer database that may already +-- contain manually submitted feedback. The enclosing transaction restores it. +delete from private.feedback_submissions; +delete from private.shared_activity_rate_limits +where operation in ('feedback', 'feedback-daily'); + select has_table('private', 'feedback_submissions', 'private feedback storage exists'); select columns_are( 'private', diff --git a/supabase/tests/public_api_security.sql b/supabase/tests/public_api_security.sql new file mode 100644 index 0000000..4947358 --- /dev/null +++ b/supabase/tests/public_api_security.sql @@ -0,0 +1,190 @@ +begin; +create extension if not exists pgtap with schema extensions; +select plan(35); + +-- Isolate project-wide counters and records while preserving developer data +-- through the final rollback. +delete from private.public_api_budget_usage; +delete from private.analytics_events; + +select has_table('private', 'public_api_budget_limits', 'project API budget configuration exists'); +select has_table('private', 'public_api_budget_usage', 'project API budget usage exists'); +select is( + (select relrowsecurity from pg_class where oid = 'private.public_api_budget_limits'::regclass), + true, + 'project API budget configuration has row security enabled' +); +select is( + (select relrowsecurity from pg_class where oid = 'private.public_api_budget_usage'::regclass), + true, + 'project API budget usage has row security enabled' +); +select is(has_table_privilege('anon', 'private.public_api_budget_limits', 'SELECT'), false, 'anonymous clients cannot read project budgets'); +select is(has_table_privilege('authenticated', 'private.public_api_budget_limits', 'SELECT'), false, 'authenticated clients cannot read project budgets'); +select is(has_table_privilege('service_role', 'private.public_api_budget_limits', 'SELECT'), false, 'service clients cannot read project budgets directly'); +select is(has_table_privilege('anon', 'private.public_api_budget_usage', 'SELECT'), false, 'anonymous clients cannot read project usage'); +select is(has_table_privilege('authenticated', 'private.public_api_budget_usage', 'SELECT'), false, 'authenticated clients cannot read project usage'); +select is(has_table_privilege('service_role', 'private.public_api_budget_usage', 'SELECT'), false, 'service clients cannot read project usage directly'); + +select has_function( + 'private', + 'enforce_public_api_project_budget', + array['text', 'bigint'], + 'the private project budget function exists' +); +select is(has_function_privilege('anon', 'private.enforce_public_api_project_budget(text,bigint)', 'EXECUTE'), false, 'anonymous clients cannot execute the budget function'); +select is(has_function_privilege('authenticated', 'private.enforce_public_api_project_budget(text,bigint)', 'EXECUTE'), false, 'authenticated clients cannot execute the budget function'); +select is(has_function_privilege('service_role', 'private.enforce_public_api_project_budget(text,bigint)', 'EXECUTE'), false, 'service clients cannot bypass public RPCs through the budget function'); +select results_eq( + $$select operation, daily_limit, enabled from private.public_api_budget_limits order by operation$$, + $$values ('analytics_events'::text, 5000::bigint, true), ('create_bytes'::text, 5242880::bigint, true)$$, + 'project budgets start with conservative configurable defaults' +); + +select is( + ( + select count(*) + from pg_default_acl defaults + cross join lateral aclexplode(defaults.defaclacl) privilege + where defaults.defaclrole = 'postgres'::regrole + and defaults.defaclnamespace = 'public'::regnamespace + and defaults.defaclobjtype = 'f' + and privilege.privilege_type = 'EXECUTE' + and ( + privilege.grantee = 0 + or privilege.grantee in ( + 'anon'::regrole, + 'authenticated'::regrole, + 'service_role'::regrole + ) + ) + ), + 0::bigint, + 'future public functions created by the app owner do not inherit browser execution' +); + +create function public.security_default_privilege_probe() +returns integer +language sql +as $$ select 1; $$; +select is(has_function_privilege('anon', 'public.security_default_privilege_probe()', 'EXECUTE'), false, 'new functions are closed to anonymous clients by default'); +select is(has_function_privilege('authenticated', 'public.security_default_privilege_probe()', 'EXECUTE'), false, 'new functions are closed to authenticated clients by default'); +select is(has_function_privilege('service_role', 'public.security_default_privilege_probe()', 'EXECUTE'), false, 'new functions are closed to service clients by default'); + +select throws_like( + $$select private.enforce_public_api_project_budget('unknown', 1)$$, + '%invalid_project_budget_configuration%', + 'unknown project budget operations fail closed' +); + +create temporary table project_budget_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', 'security-budget', + 'name', 'Security budget', + 'emoji', '✦', + 'memberIds', jsonb_build_array('me'), + 'currency', 'USD' + ), + 'friends', '[]'::jsonb, + 'expenses', '[]'::jsonb, + 'payments', '[]'::jsonb +) as snapshot; + +select set_config('request.headers', '{"x-forwarded-for":"203.0.113.201"}', true); +select lives_ok( + $$select * from public.create_shared_activity('{}'::jsonb)$$, + 'invalid live creation returns a normal rejection' +); +select is( + (select count(*) from private.public_api_budget_usage where operation = 'create_bytes'), + 0::bigint, + 'invalid live creation does not consume shared storage capacity' +); +select lives_ok( + $$select * from public.create_shared_activity((select snapshot from project_budget_snapshot))$$, + 'valid live creation succeeds inside the project budget' +); +select ok( + (select used_units > 0 from private.public_api_budget_usage where operation = 'create_bytes'), + 'valid live creation consumes its encoded snapshot size' +); +update private.public_api_budget_limits +set daily_limit = ( + select used_units from private.public_api_budget_usage where operation = 'create_bytes' +) +where operation = 'create_bytes'; +select throws_like( + $$select * from public.create_shared_activity((select snapshot from project_budget_snapshot))$$, + '%project_rate_limit_exceeded%', + 'live creation stops before exceeding the project storage budget' +); +select is( + ( + select used_units + from private.public_api_budget_usage + where operation = 'create_bytes' + ), + ( + select daily_limit + from private.public_api_budget_limits + where operation = 'create_bytes' + ), + 'a rejected live creation does not inflate committed project usage' +); + +select set_config('request.headers', '{"x-forwarded-for":"203.0.113.202"}', true); +select lives_ok( + $$select public.record_analytics_event('not_allowed', 'local', '0123456789abcdef0123456789abcdef', 'en', null)$$, + 'invalid analytics returns a normal rejection' +); +select is( + (select count(*) from private.public_api_budget_usage where operation = 'analytics_events'), + 0::bigint, + 'invalid analytics does not consume shared reporting capacity' +); +select lives_ok( + $$select public.record_analytics_event('expense_added', 'local', '0123456789abcdef0123456789abcdef', 'en', null)$$, + 'valid analytics succeeds inside the project budget' +); +select is( + (select used_units from private.public_api_budget_usage where operation = 'analytics_events'), + 1::bigint, + 'a valid analytics event consumes one project unit' +); +update private.public_api_budget_limits set daily_limit = 1 where operation = 'analytics_events'; +select throws_like( + $$select public.record_analytics_event('expense_added', 'local', 'abcdef0123456789abcdef0123456789', 'en', null)$$, + '%project_rate_limit_exceeded%', + 'analytics stops before exceeding the project event budget' +); +select is( + (select count(*) from private.analytics_events), + 1::bigint, + 'an event rejected by the project budget is not stored' +); + +update private.public_api_budget_limits set enabled = false where operation = 'analytics_events'; +select throws_like( + $$select private.enforce_public_api_project_budget('analytics_events', 1)$$, + '%project_rate_limit_exceeded%', + 'disabling a project budget fails closed' +); +update private.public_api_budget_limits set enabled = true, daily_limit = 5000 where operation = 'analytics_events'; +update private.public_api_budget_usage +set window_started_at = clock_timestamp() - interval '2 days', used_units = 4999 +where operation = 'analytics_events'; +select lives_ok( + $$select private.enforce_public_api_project_budget('analytics_events', 1)$$, + 'a completed rolling window accepts new usage' +); +select is( + (select used_units from private.public_api_budget_usage where operation = 'analytics_events'), + 1::bigint, + 'a completed rolling window resets its usage atomically' +); + +select * from finish(); +rollback;