diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f25f6c..c3eafd2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository
- uses: actions/checkout@v5
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Set up Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
cache: npm
@@ -53,7 +53,7 @@ jobs:
- name: Upload Playwright failure report
if: failure()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: playwright-report
path: playwright-report/
@@ -69,18 +69,18 @@ jobs:
id-token: write
environment:
name: production
- env:
- SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
- SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
- VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
- VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }}
steps:
- name: Check out repository
- uses: actions/checkout@v5
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Validate production configuration
shell: bash
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
+ SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
+ VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
+ VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }}
run: |
if [[ -z "$SUPABASE_ACCESS_TOKEN" || -z "$SUPABASE_DB_PASSWORD" || -z "$SUPABASE_PROJECT_ID" || -z "$VITE_SUPABASE_URL" || -z "$VITE_SUPABASE_PUBLISHABLE_KEY" ]]; then
echo "The production environment is missing required Supabase secrets or variables."
@@ -88,7 +88,7 @@ jobs:
fi
- name: Set up Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
cache: npm
@@ -97,19 +97,29 @@ jobs:
run: npm ci
- name: Build the production GitHub Pages artifact
+ env:
+ VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
+ VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }}
run: npm run build:pages
- name: Link the production database
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
+ SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
run: npx supabase link --project-ref "$SUPABASE_PROJECT_ID"
- name: Apply pending production migrations
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
run: npx supabase db push
- name: Configure GitHub Pages
- uses: actions/configure-pages@v6
+ uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
- name: Upload GitHub Pages artifact
- uses: actions/upload-pages-artifact@v5
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: dist
@@ -127,4 +137,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v5
+ uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index dc79bd1..1b81405 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -14,7 +14,8 @@ Tally is local-first rather than frontend-only. Browser persistence powers priva
- `src/features/liveSharing/useLiveActivitySession.ts` owns capability-URL synchronization, backend loading and saving, optimistic-conflict recovery, and local live shortcuts.
- `src/features/sharing/` owns text summaries, PNG generation, and browser sharing fallbacks.
- `src/pwa/` contains pure service-worker cache-manifest helpers, while `src/sw.ts` owns install, activation, and fetch lifecycle events.
-- `src/analytics.ts` owns the typed, non-blocking first-party event client and frontend-only Cloudflare fallback.
+- `src/analytics.ts` owns the typed, non-blocking first-party event client.
+- `src/security/` contains browser-side defense-in-depth guards for hosting limitations, including clickjacking protection.
Dependencies point inward: UI features may use domain and data utilities, while domain modules never import React or feature components. Imports are direct instead of routed through a barrel file.
@@ -38,7 +39,7 @@ Safari does not reliably route ordinary HTTPS links into an installed Home Scree
## URL-state sharing experiment
-`shareActivityUrl.ts` defines a versioned activity snapshot independent of the local-storage schema. New snapshots use an LZ-compressed `#share=z.…` payload, while the decoder retains compatibility with earlier base64url links. It validates every member, expense, relationship, and split before rendering a shared URL. Shared fragments open read-only, do not write to local storage, and suppress third-party analytics; first-party measurement receives only the `snapshot` surface. Saving requires the recipient to choose their participant, remaps that participant to `me`, and creates new IDs for every other imported entity so copies cannot overwrite existing records.
+`shareActivityUrl.ts` defines a versioned activity snapshot independent of the local-storage schema. New snapshots use an LZ-compressed `#share=z.…` payload, while the decoder retains compatibility with earlier base64url links. It bounds the incoming token and decoded payload before validating every member, expense, relationship, and split. Shared fragments open read-only and do not write to local storage; first-party measurement receives only the `snapshot` surface. Saving requires the recipient to choose their participant, remaps that participant to `me`, and creates new IDs for every other imported entity so copies cannot overwrite existing records.
URL state is a transport rather than synchronization: every edit produces a new snapshot, and there is no canonical latest version or automatic conflict resolution.
@@ -46,7 +47,7 @@ URL state is a transport rather than synchronization: every edit produces a new
Supabase stores the canonical live activity. A short activity code identifies the row, while a secret edit token in the URL fragment grants read/write access. The database stores only a SHA-256 hash of that token. Every update supplies an expected revision and increments it atomically, preventing silent last-write-wins data loss.
-`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, hashed-IP request throttling, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly.
+`src/features/liveSharing/` owns the typed API, URL, configuration, and versioned browser-shortcut contracts. `supabase/` contains RLS-protected private storage, secret-peppered request throttling, expiring activity rows, narrow security-definer RPC wrappers, and pgTAP security tests. Browser roles cannot query the private schema or execute private functions directly.
The frontend treats Supabase as canonical whenever a live capability is active. Local shortcut rows contain only navigation metadata and credentials; they are never a second writable activity copy. Live capabilities are trusted-group bearer credentials rather than user authorization. See `docs/LIVE_SHARING_EXPERIMENT.md` and `docs/DEPLOYMENT.md` before changing this boundary.
@@ -54,7 +55,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 hashed-IP throttle, and incrementally deletes events older than 90 days. Private daily, hourly, and locale aggregates support event counts and anonymous session funnels. A three-argument RPC overload classifies older clients as `unknown`, preserving compatibility without guessing their locale. Third-party Cloudflare analytics is a fallback only for frontend-only production builds and never executes on `#share=` or `#live=` URLs. See `docs/ANALYTICS.md` before adding events or properties.
+The RPC validates every value, applies the existing secret-peppered request throttle, and incrementally deletes events older than 90 days. Private daily, hourly, and locale aggregates support event counts and anonymous session funnels. A three-argument RPC overload classifies older clients as `unknown`, preserving compatibility without guessing their locale. There is no third-party analytics fallback. See `docs/ANALYTICS.md` before adding events or properties.
## Change contract
diff --git a/PRIVACY.md b/PRIVACY.md
index 1c2126e..3be829f 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -18,11 +18,11 @@ Anyone with the complete live URL can read and edit the activity. Share it only
## Abuse protection and analytics
-The backend rate-limits requests using a one-way hash of the client IP address; the raw address is not stored in the application rate-limit table.
+The backend rate-limits requests using a secret-peppered one-way identifier derived from the client IP address; neither the raw address nor an unpeppered IP hash is stored in the application rate-limit table.
Production records a small allowlist of first-party product events for both local and live workflows. Each event contains only an event name, a coarse `local`, `live`, or `snapshot` surface, a one-way hash of the session token, and the event time. Analytics never receives a page URL or fragment, activity code, edit token, participant identity, activity name, expense description, amount, balance, or activity snapshot. Event rows expire after 90 days. Browser roles can write through a validated, rate-limited RPC but cannot read analytics events or reports.
-Frontend-only production builds may use Cloudflare Web Analytics on ordinary app pages. The third-party beacon is always suppressed on `#share=` and `#live=` URLs so it cannot observe shared state or capability tokens.
+Tally does not load a third-party analytics beacon. A deployment without Supabase analytics configuration simply records no product analytics.
## Scope
diff --git a/README.md b/README.md
index c3f9095..b008537 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ On first use, Tally asks for a display name and stores that identity only in the
Tally supports English and Simplified Chinese. It starts in Chinese when the browser language is Chinese or the device time zone is in mainland China; otherwise it starts in English. A manual choice in **Settings** is saved in the browser and always takes priority. Expense timestamps use the browser's IANA time zone, which is shown in Settings.
-Production uses privacy-preserving first-party analytics through Supabase for both browser-local and live activity workflows. Only allowlisted event names, a coarse `local`/`live`/`snapshot` surface, the resolved `en`/`zh-CN` UI locale, an allowlisted currency code for currency-selection events, and a one-way session hash are stored—never precise location, URLs, capability tokens, activity IDs, names, descriptions, amounts, or balances. Frontend-only deployments can retain Cloudflare Web Analytics, but third-party analytics never loads on shared activity URLs. See [the analytics design](docs/ANALYTICS.md).
+Production uses privacy-preserving first-party analytics through Supabase for both browser-local and live activity workflows. Only allowlisted event names, a coarse `local`/`live`/`snapshot` surface, the resolved `en`/`zh-CN` UI locale, an allowlisted currency code for currency-selection events, and a one-way session hash are stored—never precise location, URLs, capability tokens, activity IDs, names, descriptions, amounts, or balances. Tally does not load a third-party analytics beacon. See [the analytics design](docs/ANALYTICS.md).
## Sharing and live collaboration
@@ -20,7 +20,7 @@ Tally supports two deliberately different sharing modes:
- **Share live** creates a short capability URL for one canonical activity in Supabase. Trusted recipients with the complete link can load and edit the same revision-checked data from different browsers.
- **Share link** opens the device share sheet, with a separate copy-link fallback. If Safari opens the link outside the installed PWA, **Join activity** safely transfers the copied link into the existing Tally app session.
-Opening a snapshot never overwrites browser data, and shared-preview URLs never load third-party analytics because the fragment contains names and expense details. First-party measurement records only the coarse `snapshot` surface. Live links keep their secret edit token in the fragment; Supabase stores only its SHA-256 hash. Every browser that successfully opens a live link keeps the latest full snapshot as a recovery copy, while Supabase remains the source of truth for as long as that Live session is available. See [the live sharing architecture](docs/LIVE_SHARING_EXPERIMENT.md) and [production deployment guide](docs/DEPLOYMENT.md).
+Opening a snapshot never overwrites browser data, and Tally never loads third-party analytics. First-party measurement records only the coarse `snapshot` surface, never the fragment containing names and expense details. Live links keep their secret edit token in the fragment; Supabase stores only its SHA-256 hash. Every browser that successfully opens a live link keeps the latest full snapshot as a recovery copy, while Supabase remains the source of truth for as long as that Live session is available. See [the live sharing architecture](docs/LIVE_SHARING_EXPERIMENT.md) and [production deployment guide](docs/DEPLOYMENT.md).
[Try the live demo](https://pengfanz.github.io/splitbill/)
diff --git a/SECURITY.md b/SECURITY.md
index 6d31bbb..52430c6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,4 +16,6 @@ Local activities and identities are stored in browser `localStorage`. Live activ
Anyone with a complete live URL can read and edit that activity. There are no accounts, participant-level permissions, token revocation, or audit trail in this release. Treat leaked URLs as compromised and avoid regulated or highly sensitive data.
+Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes rate-limit budget without being stored. Shared snapshot URLs are decoded with strict compressed-input and expanded-output limits. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page.
+
Reports involving capability leakage, RPC privilege escalation, rate-limit bypasses, browser storage exposure, exported summaries, dependency vulnerabilities, or deployment configuration are in scope. Never include a real live activity URL, database password, service-role key, or Supabase access token in a report.
diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md
index 809ce2d..2f524eb 100644
--- a/docs/ANALYTICS.md
+++ b/docs/ANALYTICS.md
@@ -139,8 +139,8 @@ order by hour_of_day;
These are anonymous sessions, not authenticated users. One person can create multiple sessions, a selected UI language is not proof of physical location, and offline or self-hosted development use is not measured.
-## Retention and fallback
+## Retention and availability
Events older than 90 days are removed in bounded batches when an app-open event is recorded. The timestamp index keeps cleanup bounded as the table grows.
-When a production build has no Supabase configuration, the existing Cloudflare Web Analytics page-view beacon remains an optional fallback. It is always disabled on `#share=` and `#live=` URLs. Development and test builds do not initialize either analytics path by default.
+When a production build has no Supabase configuration, product analytics is disabled. Tally does not load a third-party page-view beacon. Development and test builds do not initialize first-party analytics by default.
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 4da59f5..a864f31 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -22,6 +22,8 @@ Create a project in [Supabase](https://supabase.com/dashboard), then record:
- the project URL;
- the publishable client key.
+In **Integrations → Data API → Settings**, disable automatic exposure for new tables and functions when that option is available. Tally grants access only to its reviewed public RPC wrappers; application tables stay in the unexposed `private` schema. The migrations also remove anonymous default privileges for objects owned by the migration role.
+
Generate a personal access token from [Supabase account tokens](https://supabase.com/dashboard/account/tokens). The token and database password are deployment secrets; the project URL, project reference, and publishable key are intentionally safe client configuration.
Do not make production schema changes in the Dashboard. All schema changes must be committed under `supabase/migrations/` and released by CI, following [Supabase's migration workflow](https://supabase.com/docs/guides/deployment/database-migrations).
@@ -83,12 +85,12 @@ The workflow can also be started manually from `main` with **Run workflow**.
## Operational requirements
- Backend activities expire 90 days after their last successful update and expired rows are removed incrementally during new activity creation. Each browser that opened the activity keeps its latest full snapshot locally until the person removes it or clears site data; after confirmed backend expiration, that saved copy can continue as a local activity and start a new Live session.
-- Create, load, and update RPCs are rate-limited per hashed client IP. Review API/database logs and tune limits from observed traffic.
+- Create, load, update, and analytics RPCs are rate-limited per secret-peppered identifier derived from the client IP. Rejected requests consume the same budget as successful requests. Review API/database logs and tune limits from observed traffic.
- First-party analytics events expire after 90 days and contain no URL, capability, identity, activity, or financial payload. Review aggregate usage with the queries in [ANALYTICS.md](ANALYTICS.md).
- Free-tier projects should export regular off-site logical backups with `supabase db dump`. Paid projects provide daily backups; consider point-in-time recovery when the recovery objective warrants it. See [Supabase backups](https://supabase.com/docs/guides/platform/backups).
- Review Security Advisor and Performance Advisor after every schema change.
- If a capability URL leaks, treat the activity as compromised. Token rotation/revocation is a required follow-up before serving groups that need stronger access control.
-- If a custom Supabase API domain is introduced, add its origin to the `connect-src` policy in `index.html`.
+- If the Supabase project URL changes or a custom API domain is introduced, replace the exact allowed Supabase origin in the `connect-src` policy in `index.html`.
## Rollback
diff --git a/e2e/activity-lifecycle.spec.ts b/e2e/activity-lifecycle.spec.ts
index 0de0385..a1c7892 100644
--- a/e2e/activity-lifecycle.spec.ts
+++ b/e2e/activity-lifecycle.spec.ts
@@ -14,11 +14,6 @@ async function chooseActivityCurrency(page: Page, currentCurrency: string, nextC
}
test.beforeEach(async ({ context }) => {
- await context.route('https://static.cloudflareinsights.com/**', route => route.fulfill({
- status: 200,
- contentType: 'application/javascript',
- body: '',
- }))
await context.route('https://live-sharing.test/rest/v1/rpc/record_analytics_event', route => route.fulfill({
status: 204,
body: '',
@@ -493,11 +488,6 @@ test('shares a QR destination that opens the same read-only activity on another
expect(sharedUrl).toContain('/splitbill/#share=z.')
const recipientContext = await browser.newContext()
- await recipientContext.route('https://static.cloudflareinsights.com/**', route => route.fulfill({
- status: 200,
- contentType: 'application/javascript',
- body: '',
- }))
await recipientContext.route('https://live-sharing.test/rest/v1/rpc/record_analytics_event', route => route.fulfill({
status: 204,
body: '',
@@ -569,11 +559,6 @@ test('shares one editable backend activity across isolated browser sessions', as
})
}
const prepareSharedSession = async (targetContext: BrowserContext) => {
- await targetContext.route('https://static.cloudflareinsights.com/**', route => route.fulfill({
- status: 200,
- contentType: 'application/javascript',
- body: '',
- }))
await targetContext.route('https://live-sharing.test/rest/v1/rpc/**', handleLiveBackend)
}
await context.route('https://live-sharing.test/rest/v1/rpc/**', handleLiveBackend)
diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts
new file mode 100644
index 0000000..8eb34da
--- /dev/null
+++ b/e2e/security.spec.ts
@@ -0,0 +1,22 @@
+import { expect, test } from '@playwright/test'
+
+test('blocks clickjacking while allowing the user to open Tally directly', async ({ page }) => {
+ await page.goto('/')
+ const appUrl = page.url()
+
+ await page.setContent(`
+
+ Embedding site
+
+
+ `)
+
+ const embeddedApp = page.frameLocator('iframe[title="Embedded Tally"]')
+ await expect(embeddedApp.getByRole('heading', { name: 'Open Tally directly' })).toBeVisible()
+ await expect(embeddedApp.getByText('Tally cannot run inside another website')).toBeVisible()
+ await expect(embeddedApp.getByRole('button')).toHaveCount(0)
+
+ await embeddedApp.getByRole('link', { name: 'Open Tally' }).click()
+ await expect(page).toHaveURL(appUrl)
+ await expect(page.locator('#root')).not.toContainText('Tally cannot run inside another website')
+})
diff --git a/index.html b/index.html
index 1a50cc6..e17506a 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 b240124..bba7d9c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2159,10 +2159,20 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
- "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -2173,19 +2183,19 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
- "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -2196,19 +2206,39 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.0.4"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
- "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -2223,9 +2253,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
- "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -2240,9 +2270,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
- "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -2260,9 +2290,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
- "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -2279,10 +2309,50 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
- "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -2300,9 +2370,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
- "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -2320,9 +2390,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
- "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -2340,9 +2410,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
- "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -2360,9 +2430,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
- "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -2376,19 +2446,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.0.5"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
- "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -2402,19 +2472,71 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.0.4"
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
- "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -2428,19 +2550,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.0.4"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
- "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -2454,19 +2576,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.0.4"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
- "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -2480,19 +2602,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
- "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -2506,39 +2628,76 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
- "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"dev": true,
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "license": "Apache-2.0",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.2.0"
+ "@img/sharp-wasm32": "0.35.3"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
- "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -2549,16 +2708,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
- "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -2569,7 +2728,7 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@@ -4467,13 +4626,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/async": {
- "version": "3.2.6",
- "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
- "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -4595,16 +4747,16 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
+ "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/browserslist": {
@@ -4737,51 +4889,6 @@
"node": ">=18"
}
},
- "node_modules/color": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
- "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1",
- "color-string": "^1.9.0"
- },
- "engines": {
- "node": ">=12.5.0"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/color-string": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
- "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "^1.0.0",
- "simple-swizzle": "^0.2.2"
- }
- },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -5130,19 +5237,16 @@
}
},
"node_modules/ejs": {
- "version": "3.1.10",
- "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
- "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
+ "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"dev": true,
"license": "Apache-2.0",
- "dependencies": {
- "jake": "^10.8.5"
- },
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
- "node": ">=0.10.0"
+ "node": ">=0.12.18"
}
},
"node_modules/electron-to-chromium": {
@@ -5633,9 +5737,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+ "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"dev": true,
"funding": [
{
@@ -5679,46 +5783,6 @@
"node": ">=16.0.0"
}
},
- "node_modules/filelist": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
- "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "minimatch": "^5.0.1"
- }
- },
- "node_modules/filelist/node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/filelist/node_modules/brace-expansion": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
- "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/filelist/node_modules/minimatch": {
- "version": "5.1.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
- "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -6230,13 +6294,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-arrayish": {
- "version": "0.3.4",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
- "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/is-async-function": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
@@ -6744,24 +6801,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/jake": {
- "version": "10.9.4",
- "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
- "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "async": "^3.2.6",
- "filelist": "^1.0.4",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "jake": "bin/cli.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -8144,43 +8183,53 @@
}
},
"node_modules/sharp": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
- "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"dev": true,
- "hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "color": "^4.2.3",
- "detect-libc": "^2.0.3",
- "semver": "^7.6.3"
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.33.5",
- "@img/sharp-darwin-x64": "0.33.5",
- "@img/sharp-libvips-darwin-arm64": "1.0.4",
- "@img/sharp-libvips-darwin-x64": "1.0.4",
- "@img/sharp-libvips-linux-arm": "1.0.5",
- "@img/sharp-libvips-linux-arm64": "1.0.4",
- "@img/sharp-libvips-linux-s390x": "1.0.4",
- "@img/sharp-libvips-linux-x64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
- "@img/sharp-linux-arm": "0.33.5",
- "@img/sharp-linux-arm64": "0.33.5",
- "@img/sharp-linux-s390x": "0.33.5",
- "@img/sharp-linux-x64": "0.33.5",
- "@img/sharp-linuxmusl-arm64": "0.33.5",
- "@img/sharp-linuxmusl-x64": "0.33.5",
- "@img/sharp-wasm32": "0.33.5",
- "@img/sharp-win32-ia32": "0.33.5",
- "@img/sharp-win32-x64": "0.33.5"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/sharp-ico": {
@@ -8314,16 +8363,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/simple-swizzle": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
- "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-arrayish": "^0.3.1"
- }
- },
"node_modules/smob": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz",
diff --git a/package.json b/package.json
index b568a1a..3b844a8 100644
--- a/package.json
+++ b/package.json
@@ -65,7 +65,11 @@
"vitest": "^4.1.10"
},
"overrides": {
- "nanoid": "3.3.15"
+ "brace-expansion": "5.0.8",
+ "ejs": "6.0.1",
+ "fast-uri": "3.1.4",
+ "nanoid": "3.3.15",
+ "sharp": "0.35.3"
},
"type": "module"
}
diff --git a/src/analytics.test.ts b/src/analytics.test.ts
index 4e915e6..e83bb6d 100644
--- a/src/analytics.test.ts
+++ b/src/analytics.test.ts
@@ -3,12 +3,8 @@ import {
ANALYTICS_SESSION_KEY,
createConfiguredAnalyticsClient,
getOrCreateAnalyticsSessionToken,
- initializeAnalytics,
} from './analytics'
-const beaconSelector =
- 'script[src="https://static.cloudflareinsights.com/beacon.min.js"]'
-
function deterministicCrypto(byte = 0xab) {
return {
getRandomValues: vi.fn((array: Uint8Array) => {
@@ -196,52 +192,3 @@ describe('first-party analytics', () => {
})).toBeNull()
})
})
-
-describe('Cloudflare fallback analytics', () => {
- it('loads the deferred Cloudflare beacon for frontend-only production builds', () => {
- initializeAnalytics(true, false)
-
- const script = document.querySelector(beaconSelector)
- expect(script).not.toBeNull()
- expect(script?.defer).toBe(true)
- expect(script?.dataset.cfBeacon).toBe(
- JSON.stringify({ token: 'e7952cd24d1b46ef8f41cb98923762e8' }),
- )
- })
-
- it('does not load analytics outside production', () => {
- initializeAnalytics(false)
-
- expect(document.querySelector(beaconSelector)).toBeNull()
- })
-
- it('does not load third-party analytics when first-party analytics is configured', () => {
- initializeAnalytics(true, true)
-
- expect(document.querySelector(beaconSelector)).toBeNull()
- })
-
- it('detects configured first-party analytics from the default environment', () => {
- vi.stubEnv('VITE_SUPABASE_URL', 'https://project.supabase.co')
- vi.stubEnv('VITE_SUPABASE_PUBLISHABLE_KEY', 'key')
-
- initializeAnalytics(true)
-
- expect(document.querySelector(beaconSelector)).toBeNull()
- })
-
- it.each(['#share=private-state', '#live=PRIVATE-CAPABILITY'])('does not load third-party analytics for activity URL %s', hash => {
- window.history.replaceState(null, '', `/${hash}`)
-
- initializeAnalytics(true, false)
-
- expect(document.querySelector(beaconSelector)).toBeNull()
- })
-
- it('does not add the beacon more than once', () => {
- initializeAnalytics(true, false)
- initializeAnalytics(true, false)
-
- expect(document.querySelectorAll(beaconSelector)).toHaveLength(1)
- })
-})
diff --git a/src/analytics.ts b/src/analytics.ts
index 09b8338..fb7ccd3 100644
--- a/src/analytics.ts
+++ b/src/analytics.ts
@@ -1,10 +1,6 @@
import type { AppLocale } from './i18n/localization'
import type { CurrencyCode } from './domain/currency'
-const CLOUDFLARE_BEACON_URL =
- 'https://static.cloudflareinsights.com/beacon.min.js'
-const CLOUDFLARE_ANALYTICS_TOKEN = 'e7952cd24d1b46ef8f41cb98923762e8'
-
export const ANALYTICS_SESSION_KEY = 'tally:analytics-session:v1'
export const ANALYTICS_EVENTS = [
@@ -128,29 +124,3 @@ export function createConfiguredAnalyticsClient(
return null
}
}
-
-export function initializeAnalytics(
- enabled = import.meta.env.PROD,
- firstPartyAnalyticsEnabled = Boolean(
- import.meta.env.VITE_SUPABASE_URL?.trim()
- && import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim(),
- ),
-) {
- if (
- !enabled
- || firstPartyAnalyticsEnabled
- || window.location.hash.startsWith('#share=')
- || window.location.hash.startsWith('#live=')
- || document.querySelector(`script[src="${CLOUDFLARE_BEACON_URL}"]`)
- ) {
- return
- }
-
- const script = document.createElement('script')
- script.defer = true
- script.src = CLOUDFLARE_BEACON_URL
- script.dataset.cfBeacon = JSON.stringify({
- token: CLOUDFLARE_ANALYTICS_TOKEN,
- })
- document.body.append(script)
-}
diff --git a/src/features/liveSharing/liveActivityApi.test.ts b/src/features/liveSharing/liveActivityApi.test.ts
index fdf13e1..db0d057 100644
--- a/src/features/liveSharing/liveActivityApi.test.ts
+++ b/src/features/liveSharing/liveActivityApi.test.ts
@@ -99,6 +99,8 @@ describe('live activity API client', () => {
['PT409', 409, 'conflict'],
['P0002', 404, 'not-found'],
['22023', 400, 'invalid-input'],
+ ['', 404, 'not-found'],
+ ['', 400, 'invalid-input'],
['rate_limit_exceeded', 429, 'rate-limit'],
['XX000', 500, 'backend'],
] as const)('maps backend code %s with status %s to %s', async (code, status, kind) => {
@@ -107,6 +109,16 @@ describe('live activity API client', () => {
await expectApiError(client.load(credentials), kind)
})
+ it.each([
+ [404, 'not-found'],
+ [400, 'invalid-input'],
+ ] as const)('maps an empty committed rejection with status %s to %s', async (status, kind) => {
+ fetcher.mockResolvedValue(response([], status))
+ const client = createLiveActivityClient({ supabaseUrl: 'https://project.supabase.co', publishableKey: 'key' }, fetcher)
+
+ await expectApiError(client.load(credentials), kind)
+ })
+
it('turns a normal conflict result into an error carrying the latest record', async () => {
fetcher.mockResolvedValue(response([row({
edit_token: undefined,
diff --git a/src/features/liveSharing/liveActivityApi.ts b/src/features/liveSharing/liveActivityApi.ts
index 40c33e2..68a16f9 100644
--- a/src/features/liveSharing/liveActivityApi.ts
+++ b/src/features/liveSharing/liveActivityApi.ts
@@ -51,6 +51,8 @@ function responseErrorKind(body: unknown, status: number): LiveActivityApiErrorK
if (code === '40001') return 'conflict'
if (code === 'P0002') return 'not-found'
if (code === '22023') return 'invalid-input'
+ if (status === 404) return 'not-found'
+ if (status === 400) return 'invalid-input'
return 'backend'
}
diff --git a/src/features/sharing/boundedLzString.test.ts b/src/features/sharing/boundedLzString.test.ts
new file mode 100644
index 0000000..77da182
--- /dev/null
+++ b/src/features/sharing/boundedLzString.test.ts
@@ -0,0 +1,70 @@
+import { compressToEncodedURIComponent } from 'lz-string'
+import { describe, expect, it } from 'vitest'
+import { decompressFromEncodedURIComponentBounded } from './boundedLzString'
+
+describe('bounded LZ-string decoding', () => {
+ it('preserves the LZ-string empty-value representation', () => {
+ expect(
+ decompressFromEncodedURIComponentBounded(
+ compressToEncodedURIComponent(''),
+ 100,
+ ),
+ ).toBe('')
+ })
+
+ it.each([
+ 'plain ASCII',
+ 'Unicode 夏天 🏖️',
+ 'a'.repeat(10_000),
+ `${String.fromCharCode(300)}${String.fromCharCode(301)}`.repeat(100),
+ ])('round-trips supported LZ-string data without exceeding the bound', value => {
+ expect(
+ decompressFromEncodedURIComponentBounded(
+ compressToEncodedURIComponent(value),
+ value.length,
+ ),
+ ).toBe(value)
+ })
+
+ it('stops expansion as soon as the decoded output exceeds the limit', () => {
+ const compressed = compressToEncodedURIComponent('a'.repeat(200_000))
+
+ expect(decompressFromEncodedURIComponentBounded(compressed, 128 * 1024)).toBeNull()
+ })
+
+ it.each([
+ ['', 100],
+ ['%%%', 100],
+ ['A%', 100],
+ [compressToEncodedURIComponent('value').slice(0, 1), 100],
+ [compressToEncodedURIComponent('value'), 0],
+ [compressToEncodedURIComponent('value'), 1.5],
+ ])('rejects malformed data or invalid limits', (compressed, limit) => {
+ expect(decompressFromEncodedURIComponentBounded(compressed, limit)).toBeNull()
+ })
+
+ it('fails closed for truncated and corrupted URI-safe streams without exceeding the bound', () => {
+ const validTokens = [
+ compressToEncodedURIComponent('several different words and symbols 夏天'),
+ compressToEncodedURIComponent('abcabcabcabcabcabcabcabcabcabc'),
+ ]
+ const candidates = validTokens.flatMap(token => (
+ Array.from({ length: token.length - 1 }, (_, index) => token.slice(0, index + 1))
+ ))
+ let seed = 1
+ for (let sample = 0; sample < 500; sample += 1) {
+ let token = ''
+ const length = 2 + (sample % 14)
+ for (let index = 0; index < length; index += 1) {
+ seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0
+ token += 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$'.charAt(seed % 65)
+ }
+ candidates.push(token)
+ }
+
+ candidates.forEach(token => {
+ const result = decompressFromEncodedURIComponentBounded(token, 64)
+ expect(result === null || result.length <= 64).toBe(true)
+ })
+ })
+})
diff --git a/src/features/sharing/boundedLzString.ts b/src/features/sharing/boundedLzString.ts
new file mode 100644
index 0000000..cdd1d5c
--- /dev/null
+++ b/src/features/sharing/boundedLzString.ts
@@ -0,0 +1,108 @@
+const URI_SAFE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$'
+const URI_SAFE_VALUES = new Map(
+ Array.from(URI_SAFE_ALPHABET, (character, index) => [character, index]),
+)
+
+type BitReader = {
+ input: string
+ value: number
+ position: number
+ index: number
+}
+
+function readBits(reader: BitReader, bitCount: number) {
+ let bits = 0
+ for (let power = 1, index = 0; index < bitCount; power <<= 1, index += 1) {
+ const bit = reader.value & reader.position
+ reader.position >>= 1
+ if (reader.position === 0) {
+ if (reader.index >= reader.input.length) return null
+ reader.position = 32
+ reader.value = URI_SAFE_VALUES.get(reader.input.charAt(reader.index)) ?? -1
+ reader.index += 1
+ if (reader.value < 0) return null
+ }
+ if (bit > 0) bits |= power
+ }
+ return bits
+}
+
+function readCharacter(reader: BitReader, bitCount: 8 | 16) {
+ const value = readBits(reader, bitCount)
+ return value == null ? null : String.fromCharCode(value)
+}
+
+export function decompressFromEncodedURIComponentBounded(
+ compressed: string,
+ maxOutputLength: number,
+) {
+ if (!compressed || !Number.isSafeInteger(maxOutputLength) || maxOutputLength < 1) return null
+ const input = compressed.replaceAll(' ', '+')
+ const firstValue = URI_SAFE_VALUES.get(input.charAt(0))
+ if (firstValue == null) return null
+
+ const reader: BitReader = {
+ input,
+ value: firstValue,
+ position: 32,
+ index: 1,
+ }
+ const dictionary: Array = [0, 1, 2]
+ let enlargeIn = 4
+ let dictionarySize = 4
+ let bitCount = 3
+
+ // The first URI-safe character always contains the two-bit stream prefix.
+ const firstCode = readBits(reader, 2)!
+ if (firstCode === 2) return ''
+ const firstCharacter = readCharacter(reader, firstCode === 0 ? 8 : 16)
+ if (firstCharacter == null) return null
+
+ dictionary[3] = firstCharacter
+ let previous = firstCharacter
+ const result = [firstCharacter]
+ let outputLength = firstCharacter.length
+
+ while (true) {
+ const nextCode = readBits(reader, bitCount)
+ if (nextCode == null) return null
+ if (nextCode === 2) return result.join('')
+
+ let dictionaryCode = nextCode
+ if (nextCode === 0 || nextCode === 1) {
+ const character = readCharacter(reader, nextCode === 0 ? 8 : 16)
+ if (character == null) return null
+ dictionary[dictionarySize] = character
+ dictionaryCode = dictionarySize
+ dictionarySize += 1
+ enlargeIn -= 1
+ }
+
+ if (enlargeIn === 0) {
+ enlargeIn = 2 ** bitCount
+ bitCount += 1
+ }
+
+ const knownEntry = dictionary[dictionaryCode]
+ const entry = typeof knownEntry === 'string'
+ ? knownEntry
+ : dictionaryCode === dictionarySize
+ ? previous + previous.charAt(0)
+ : null
+ if (entry == null) return null
+
+ outputLength += entry.length
+ if (outputLength > maxOutputLength) return null
+ result.push(entry)
+
+ dictionary[dictionarySize] = previous + entry.charAt(0)
+ dictionarySize += 1
+ enlargeIn -= 1
+ previous = entry
+
+ if (enlargeIn === 0) {
+ enlargeIn = 2 ** bitCount
+ bitCount += 1
+ }
+ }
+}
diff --git a/src/features/sharing/shareActivityUrl.test.ts b/src/features/sharing/shareActivityUrl.test.ts
index 7505765..f5461dc 100644
--- a/src/features/sharing/shareActivityUrl.test.ts
+++ b/src/features/sharing/shareActivityUrl.test.ts
@@ -1,3 +1,4 @@
+import { compressToEncodedURIComponent } from 'lz-string'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_STATE } from '../../data/storage'
import { CURRENT_USER } from '../../domain/members'
@@ -17,6 +18,7 @@ import {
MAX_QR_URL_LENGTH,
MAX_ACTIVITY_AMOUNT,
MAX_ACTIVITY_FRIENDS,
+ MAX_ACTIVITY_SNAPSHOT_BYTES,
saveSharedActivityCopy,
SHARE_HASH_PREFIX,
SHARE_URL_MESSAGES,
@@ -167,6 +169,22 @@ describe('URL activity serialization', () => {
invalid.forEach(value => expect(decodeSharedActivityHash(encoded(value))).toBeNull())
})
+ it('rejects oversized compressed and legacy tokens before they can consume unbounded memory', () => {
+ const expansionBomb = `${COMPRESSED_SHARE_PREFIX}${compressToEncodedURIComponent(
+ 'x'.repeat(MAX_ACTIVITY_SNAPSHOT_BYTES + 1),
+ )}`
+
+ expect(decodeSharedActivityHash(`${SHARE_HASH_PREFIX}${expansionBomb}`)).toBeNull()
+ expect(decodeSharedActivityHash(
+ `${SHARE_HASH_PREFIX}${'a'.repeat(MAX_SHARE_URL_LENGTH + 1)}`,
+ )).toBeNull()
+
+ const oversizedUtf8 = `${COMPRESSED_SHARE_PREFIX}${compressToEncodedURIComponent(
+ '夏'.repeat(Math.floor(MAX_ACTIVITY_SNAPSHOT_BYTES / 2)),
+ )}`
+ expect(decodeSharedActivityHash(`${SHARE_HASH_PREFIX}${oversizedUtf8}`)).toBeNull()
+ })
+
it('enforces a conservative URL size limit', () => {
const oversized = { ...shared, group: { ...group, name: incompressibleText(MAX_SHARE_URL_LENGTH) } }
expect(() => buildSharedActivityUrl(oversized)).toThrow(RangeError)
diff --git a/src/features/sharing/shareActivityUrl.ts b/src/features/sharing/shareActivityUrl.ts
index 5cd67a6..134e677 100644
--- a/src/features/sharing/shareActivityUrl.ts
+++ b/src/features/sharing/shareActivityUrl.ts
@@ -1,9 +1,14 @@
-import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
+import { compressToEncodedURIComponent } from 'lz-string'
import { CURRENT_USER, makeId } from '../../domain/members'
import type { ActivityGroup, Expense, Member, PersistedState } from '../../domain/models'
+import { decompressFromEncodedURIComponentBounded } from './boundedLzString'
import { shareLink } from './shareLink'
import {
legacySharedActivitySchema,
+ MAX_ACTIVITY_AMOUNT,
+ MAX_ACTIVITY_EXPENSES,
+ MAX_ACTIVITY_FRIENDS,
+ MAX_ACTIVITY_SNAPSHOT_BYTES,
sharedActivitySchema,
type SharedActivity,
} from './sharedActivitySchema'
@@ -14,7 +19,7 @@ export {
MAX_ACTIVITY_FRIENDS,
MAX_ACTIVITY_SNAPSHOT_BYTES,
type SharedActivity,
-} from './sharedActivitySchema'
+}
export const SHARE_HASH_PREFIX = '#share='
export const COMPRESSED_SHARE_PREFIX = 'z.'
@@ -79,10 +84,15 @@ export function decodeSharedActivityHash(hash: string): SharedActivity | null {
if (!hash.startsWith(SHARE_HASH_PREFIX)) return null
try {
const token = hash.slice(SHARE_HASH_PREFIX.length)
+ if (token.length > MAX_SHARE_URL_LENGTH) return null
const serialized = token.startsWith(COMPRESSED_SHARE_PREFIX)
- ? decompressFromEncodedURIComponent(token.slice(COMPRESSED_SHARE_PREFIX.length))
+ ? decompressFromEncodedURIComponentBounded(
+ token.slice(COMPRESSED_SHARE_PREFIX.length),
+ MAX_ACTIVITY_SNAPSHOT_BYTES,
+ )
: fromBase64Url(token)
if (!serialized) return null
+ if (new TextEncoder().encode(serialized).byteLength > MAX_ACTIVITY_SNAPSHOT_BYTES) return null
const parsed: unknown = JSON.parse(serialized)
if (!isRecord(parsed)) return null
if (legacySharedActivitySchema.safeParse(parsed).success) {
diff --git a/src/main.test.tsx b/src/main.test.tsx
index 5a2d0f7..4a2558f 100644
--- a/src/main.test.tsx
+++ b/src/main.test.tsx
@@ -3,15 +3,21 @@ import { beforeEach, expect, it, vi } from 'vitest'
const render = vi.fn()
const createRoot = vi.fn(() => ({ render }))
const reloadOnServiceWorkerUpdate = vi.fn()
+const isEmbeddedWindow = vi.fn(() => false)
+const renderFrameProtection = vi.fn()
vi.mock('react-dom/client', () => ({ createRoot }))
vi.mock('./pwa/serviceWorkerUpdates', () => ({ reloadOnServiceWorkerUpdate }))
+vi.mock('./security/frameProtection', () => ({ isEmbeddedWindow, renderFrameProtection }))
beforeEach(() => {
vi.resetModules()
render.mockClear()
createRoot.mockClear()
reloadOnServiceWorkerUpdate.mockClear()
+ isEmbeddedWindow.mockReset()
+ isEmbeddedWindow.mockReturnValue(false)
+ renderFrameProtection.mockClear()
document.body.innerHTML = ''
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined })
})
@@ -20,6 +26,7 @@ it('mounts the application at the root element', async () => {
await import('./main')
expect(createRoot).toHaveBeenCalledWith(document.getElementById('root'))
expect(render).toHaveBeenCalledOnce()
+ expect(renderFrameProtection).not.toHaveBeenCalled()
expect(reloadOnServiceWorkerUpdate).not.toHaveBeenCalled()
})
@@ -32,6 +39,16 @@ it('reloads the mounted app when an active service worker is replaced', async ()
expect(reloadOnServiceWorkerUpdate).toHaveBeenCalledWith(serviceWorker, expect.any(Function))
})
+it('blocks rendering and background services when another site embeds the app', async () => {
+ isEmbeddedWindow.mockReturnValue(true)
+
+ await import('./main')
+
+ expect(renderFrameProtection).toHaveBeenCalledWith(document.getElementById('root'))
+ expect(createRoot).not.toHaveBeenCalled()
+ expect(reloadOnServiceWorkerUpdate).not.toHaveBeenCalled()
+})
+
it('fails clearly when the root element is missing', async () => {
document.body.innerHTML = ''
diff --git a/src/main.tsx b/src/main.tsx
index 5290438..51007b0 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,23 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
-import { createConfiguredAnalyticsClient, initializeAnalytics } from './analytics'
+import { createConfiguredAnalyticsClient } from './analytics'
import { reloadOnServiceWorkerUpdate } from './pwa/serviceWorkerUpdates'
+import { isEmbeddedWindow, renderFrameProtection } from './security/frameProtection'
import './styles.css'
-const analyticsClient = createConfiguredAnalyticsClient()
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Tally requires a root element')
-createRoot(rootElement).render(
-
-
- ,
-)
+if (isEmbeddedWindow()) {
+ renderFrameProtection(rootElement)
+} else {
+ const analyticsClient = createConfiguredAnalyticsClient()
+ createRoot(rootElement).render(
+
+
+ ,
+ )
-initializeAnalytics(undefined, Boolean(analyticsClient))
-
-if (navigator.serviceWorker) {
- reloadOnServiceWorkerUpdate(navigator.serviceWorker, window.location.reload.bind(window.location))
+ if (navigator.serviceWorker) {
+ reloadOnServiceWorkerUpdate(navigator.serviceWorker, window.location.reload.bind(window.location))
+ }
}
diff --git a/src/security/frameProtection.test.ts b/src/security/frameProtection.test.ts
new file mode 100644
index 0000000..2a2bd90
--- /dev/null
+++ b/src/security/frameProtection.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { isEmbeddedWindow, renderFrameProtection } from './frameProtection'
+
+describe('frame protection', () => {
+ it('distinguishes the top-level app from an embedded copy', () => {
+ const topLevel = {} as Window
+
+ expect(isEmbeddedWindow({ self: topLevel, top: topLevel })).toBe(false)
+ expect(isEmbeddedWindow({ self: {} as Window, top: topLevel })).toBe(true)
+ expect(isEmbeddedWindow()).toBe(false)
+ })
+
+ it('replaces embedded app controls with a safe top-level link', () => {
+ const root = document.createElement('div')
+ root.textContent = 'Sensitive app controls'
+
+ renderFrameProtection(root, document, 'https://example.com/splitbill/#live=capability')
+
+ expect(root).toHaveTextContent('Tally cannot run inside another website')
+ expect(root).not.toHaveTextContent('Sensitive app controls')
+ expect(root.querySelector('main')).toHaveClass('frame-protection')
+ expect(root.querySelector('a')).toMatchObject({
+ href: 'https://example.com/splitbill/#live=capability',
+ target: '_top',
+ rel: 'noopener noreferrer',
+ textContent: 'Open Tally',
+ })
+ })
+})
diff --git a/src/security/frameProtection.ts b/src/security/frameProtection.ts
new file mode 100644
index 0000000..61941d5
--- /dev/null
+++ b/src/security/frameProtection.ts
@@ -0,0 +1,33 @@
+type FrameWindow = {
+ self: unknown
+ top: unknown
+}
+
+export function isEmbeddedWindow(frameWindow: FrameWindow = window) {
+ return frameWindow.self !== frameWindow.top
+}
+
+export function renderFrameProtection(
+ rootElement: HTMLElement,
+ documentRef: Document = document,
+ destination = window.location.href,
+) {
+ const notice = documentRef.createElement('main')
+ notice.className = 'frame-protection'
+
+ const title = documentRef.createElement('h1')
+ title.textContent = 'Open Tally directly'
+
+ const message = documentRef.createElement('p')
+ message.textContent = 'For your security, Tally cannot run inside another website.'
+
+ const link = documentRef.createElement('a')
+ link.className = 'frame-protection-link'
+ link.href = destination
+ link.target = '_top'
+ link.rel = 'noopener noreferrer'
+ link.textContent = 'Open Tally'
+
+ notice.append(title, message, link)
+ rootElement.replaceChildren(notice)
+}
diff --git a/src/styles.css b/src/styles.css
index 0af4ede..430ac57 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -551,3 +551,30 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px s
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
+
+.frame-protection {
+ min-height: 100dvh;
+ display: grid;
+ place-content: center;
+ justify-items: center;
+ gap: 14px;
+ padding: 32px;
+ text-align: center;
+ background: var(--paper);
+ color: var(--ink);
+}
+
+.frame-protection h1,
+.frame-protection p { margin: 0; }
+.frame-protection p { max-width: 420px; color: var(--muted); }
+.frame-protection-link {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 20px;
+ border-radius: 10px;
+ background: var(--coral);
+ color: white;
+ font-weight: 700;
+ text-decoration: none;
+}
diff --git a/supabase/migrations/20260728020918_harden_anonymous_rpc_security.sql b/supabase/migrations/20260728020918_harden_anonymous_rpc_security.sql
new file mode 100644
index 0000000..2e3d23d
--- /dev/null
+++ b/supabase/migrations/20260728020918_harden_anonymous_rpc_security.sql
@@ -0,0 +1,560 @@
+-- Keep anonymous RPC throttles effective even when the request is rejected.
+-- Expected client failures return an HTTP status through PostgREST instead of
+-- raising, because a raised database error rolls the throttle increment back.
+
+create table private.security_secrets (
+ name text primary key,
+ secret bytea not null check (octet_length(secret) = 32)
+);
+
+alter table private.security_secrets enable row level security;
+revoke all on table private.security_secrets from public, anon, authenticated, service_role;
+
+insert into private.security_secrets (name, secret)
+values ('request_identifier_pepper', extensions.gen_random_bytes(32));
+
+create or replace function private.shared_activity_request_identifier()
+returns bytea
+language plpgsql
+stable
+security definer
+set search_path = ''
+set statement_timeout = '2s'
+as $$
+declare
+ request_address text;
+ identifier_pepper bytea;
+begin
+ request_address := btrim(coalesce(
+ nullif(
+ split_part(
+ coalesce(nullif(current_setting('request.headers', true), ''), '{}')::jsonb ->> 'x-forwarded-for',
+ ',',
+ 1
+ ),
+ ''
+ ),
+ 'local-development'
+ ));
+
+ 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;
+
+ return extensions.hmac(
+ convert_to(request_address, 'UTF8'),
+ identifier_pepper,
+ 'sha256'
+ );
+end;
+$$;
+
+revoke all on function private.shared_activity_request_identifier()
+ from public, anon, authenticated, service_role;
+
+-- Hashes created without the secret pepper are no longer reusable. Resetting
+-- this short-lived table avoids retaining obsolete pseudonymous identifiers.
+delete from private.shared_activity_rate_limits;
+
+create or replace function private.set_anonymous_rpc_status(p_status integer)
+returns void
+language plpgsql
+security invoker
+set search_path = ''
+as $$
+begin
+ if p_status not in (400, 404, 409) then
+ raise exception using errcode = '22023', message = 'invalid_anonymous_rpc_status';
+ end if;
+
+ perform set_config('response.status', p_status::text, true);
+ perform set_config(
+ 'response.headers',
+ '[{"Cache-Control":"no-store"}]',
+ true
+ );
+end;
+$$;
+
+revoke all on function private.set_anonymous_rpc_status(integer)
+ 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', 20, 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;
+
+ 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.load_shared_activity(p_code text, p_edit_token text)
+returns table (
+ code text,
+ revision bigint,
+ snapshot jsonb,
+ updated_at timestamptz
+)
+language plpgsql
+security definer
+set search_path = ''
+set statement_timeout = '5s'
+as $$
+begin
+ perform private.enforce_shared_activity_rate_limit('load', 300, 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
+ select activity.code, activity.revision, activity.snapshot, activity.updated_at
+ 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();
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ end if;
+end;
+$$;
+
+create or replace function private.poll_shared_activity(
+ p_code text,
+ p_edit_token text
+)
+returns table (
+ code text,
+ revision bigint,
+ updated_at timestamptz
+)
+language plpgsql
+security definer
+set search_path = ''
+set statement_timeout = '5s'
+as $$
+begin
+ perform private.enforce_shared_activity_rate_limit('load', 300, 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
+ select activity.code, activity.revision, activity.updated_at
+ 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();
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ end if;
+end;
+$$;
+
+create or replace function private.update_shared_activity(
+ p_code text,
+ p_edit_token text,
+ p_expected_revision bigint,
+ p_snapshot jsonb
+)
+returns table (
+ code text,
+ revision bigint,
+ snapshot jsonb,
+ updated_at timestamptz
+)
+language plpgsql
+security definer
+set search_path = ''
+set statement_timeout = '5s'
+as $$
+declare
+ activity_id bigint;
+ current_revision bigint;
+begin
+ perform private.enforce_shared_activity_rate_limit('update', 120, interval '5 minutes');
+
+ if not coalesce(private.is_valid_activity_snapshot(p_snapshot), false)
+ or p_expected_revision is null
+ or p_expected_revision < 1 then
+ perform private.set_anonymous_rpc_status(400);
+ return;
+ end if;
+ 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;
+
+ select activity.id, activity.revision
+ into activity_id, current_revision
+ 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()
+ for update;
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ return;
+ end if;
+ if current_revision <> p_expected_revision then
+ perform private.set_anonymous_rpc_status(409);
+ return;
+ end if;
+
+ return query
+ update private.shared_activities activity
+ set snapshot = p_snapshot,
+ revision = activity.revision + 1,
+ updated_at = clock_timestamp(),
+ expires_at = clock_timestamp() + interval '90 days'
+ where activity.id = activity_id
+ returning activity.code, activity.revision, activity.snapshot, activity.updated_at;
+end;
+$$;
+
+create or replace function private.update_shared_activity_v2(
+ p_code text,
+ p_edit_token text,
+ p_expected_revision bigint,
+ p_snapshot jsonb
+)
+returns table (
+ code text,
+ revision bigint,
+ snapshot jsonb,
+ updated_at timestamptz,
+ conflicted boolean
+)
+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_expected_revision is null or p_expected_revision < 1 then
+ perform private.set_anonymous_rpc_status(400);
+ return;
+ end if;
+ 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;
+
+ if not coalesce(private.is_valid_activity_snapshot(p_snapshot), false) then
+ return query
+ select activity.code,
+ activity.revision,
+ activity.snapshot,
+ activity.updated_at,
+ true
+ 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();
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ end if;
+ return;
+ end if;
+
+ return query
+ update private.shared_activities activity
+ set snapshot = p_snapshot,
+ revision = activity.revision + 1,
+ updated_at = clock_timestamp(),
+ expires_at = clock_timestamp() + interval '90 days'
+ where activity.code = p_code
+ and activity.edit_token_hash = extensions.digest(p_edit_token, 'sha256')
+ and activity.expires_at > clock_timestamp()
+ and activity.revision = p_expected_revision
+ returning activity.code,
+ activity.revision,
+ activity.snapshot,
+ activity.updated_at,
+ false;
+
+ if found then
+ return;
+ end if;
+
+ return query
+ select activity.code,
+ activity.revision,
+ activity.snapshot,
+ activity.updated_at,
+ true
+ 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();
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ end if;
+end;
+$$;
+
+create or replace function private.update_shared_activity_v3(
+ p_code text,
+ p_edit_token text,
+ p_expected_revision bigint,
+ p_snapshot jsonb
+)
+returns table (
+ code text,
+ revision bigint,
+ snapshot jsonb,
+ updated_at timestamptz,
+ conflicted boolean,
+ rejection_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_expected_revision is null or p_expected_revision < 1 then
+ perform private.set_anonymous_rpc_status(400);
+ return;
+ end if;
+ 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;
+
+ if not coalesce(private.is_valid_activity_snapshot(p_snapshot), false) then
+ return query
+ select activity.code,
+ activity.revision,
+ activity.snapshot,
+ activity.updated_at,
+ true,
+ 'invalid_activity_snapshot'::text
+ 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();
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ end if;
+ return;
+ end if;
+
+ return query
+ update private.shared_activities activity
+ set snapshot = p_snapshot,
+ revision = activity.revision + 1,
+ updated_at = clock_timestamp(),
+ expires_at = clock_timestamp() + interval '90 days'
+ where activity.code = p_code
+ and activity.edit_token_hash = extensions.digest(p_edit_token, 'sha256')
+ and activity.expires_at > clock_timestamp()
+ and activity.revision = p_expected_revision
+ returning activity.code,
+ activity.revision,
+ activity.snapshot,
+ activity.updated_at,
+ false,
+ null::text;
+
+ if found then
+ return;
+ end if;
+
+ return query
+ select activity.code,
+ activity.revision,
+ activity.snapshot,
+ activity.updated_at,
+ true,
+ null::text
+ 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();
+
+ if not found then
+ perform private.set_anonymous_rpc_status(404);
+ end if;
+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',
+ 'expense_added',
+ 'live_share_clicked',
+ 'live_activity_created',
+ 'live_activity_opened',
+ 'settlement_recorded',
+ 'currency_selected'
+ )
+ 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;
+
+ 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;
+$$;
+
+-- New public objects must opt in to Data API access explicitly. Apply the
+-- fail-closed defaults to the migration owner and, when permitted, the
+-- Supabase platform owner.
+alter default privileges for role postgres in schema public
+ revoke all on tables from public, anon, authenticated, service_role;
+alter default privileges for role postgres in schema public
+ revoke all on sequences from public, anon, authenticated, service_role;
+alter default privileges for role postgres
+ revoke execute on functions from public, anon, authenticated, service_role;
+alter default privileges for role postgres in schema private
+ revoke all on tables from public, anon, authenticated, service_role;
+alter default privileges for role postgres in schema private
+ revoke all on sequences from public, anon, authenticated, service_role;
+
+do $$
+begin
+ if pg_has_role(current_user, 'supabase_admin', 'MEMBER') then
+ execute 'alter default privileges for role supabase_admin in schema public
+ revoke all on tables from public, anon, authenticated, service_role';
+ execute 'alter default privileges for role supabase_admin in schema public
+ revoke all on sequences from public, anon, authenticated, service_role';
+ execute 'alter default privileges for role supabase_admin
+ revoke execute on functions from public, anon, authenticated, service_role';
+ execute 'alter default privileges for role supabase_admin in schema private
+ revoke all on tables from public, anon, authenticated, service_role';
+ execute 'alter default privileges for role supabase_admin in schema private
+ revoke all on sequences from public, anon, authenticated, service_role';
+ else
+ raise notice 'Skipping supabase_admin default privileges: migration role is not a member';
+ end if;
+end;
+$$;
diff --git a/supabase/tests/analytics.sql b/supabase/tests/analytics.sql
index f982247..01e42b4 100644
--- a/supabase/tests/analytics.sql
+++ b/supabase/tests/analytics.sql
@@ -1,6 +1,6 @@
begin;
create extension if not exists pgtap with schema extensions;
-select plan(53);
+select plan(57);
select has_table('private', 'analytics_events', 'private analytics storage exists');
select columns_are(
@@ -173,41 +173,59 @@ select is(
'Live sharing clicks retain their server-side event time without activity data'
);
-select throws_ok(
+create temporary table analytics_count_before_invalid as
+select count(*) as event_count
+from private.analytics_events;
+
+create temporary table analytics_rate_before_invalid as
+select request_count
+from private.shared_activity_rate_limits
+where identifier_hash = private.shared_activity_request_identifier()
+ and operation = 'analytics';
+
+select lives_ok(
$$select public.record_analytics_event('expense_with_amount_42', 'local', '0123456789abcdef0123456789abcdef')$$,
- '22023',
- 'invalid_analytics_event',
- 'unapproved event names are rejected'
+ 'unapproved event names are rejected without rolling back the throttle'
);
-select throws_ok(
+select lives_ok(
$$select public.record_analytics_event('expense_added', 'private_activity_ABC123', '0123456789abcdef0123456789abcdef')$$,
- '22023',
- 'invalid_analytics_surface',
- 'unapproved surfaces are rejected'
+ 'unapproved surfaces are rejected without rolling back the throttle'
);
-select throws_ok(
+select lives_ok(
$$select public.record_analytics_event('expense_added', 'live', 'secret-live-capability')$$,
- '22023',
- 'invalid_analytics_session',
- 'non-session identifiers are rejected'
+ 'non-session identifiers are rejected without rolling back the throttle'
);
-select throws_ok(
+select lives_ok(
$$select public.record_analytics_event('expense_added', 'live', '0123456789abcdef0123456789abcdef', 'en-US')$$,
- '22023',
- 'invalid_analytics_locale',
- 'unapproved locales are rejected'
+ 'unapproved locales are rejected without rolling back the throttle'
);
-select throws_ok(
+select lives_ok(
$$select public.record_analytics_event('currency_selected', 'local', '0123456789abcdef0123456789abcdef', 'en', 'BTC')$$,
- '22023',
- 'invalid_analytics_currency',
- 'unsupported currencies are rejected'
+ 'unsupported currencies are rejected without rolling back the throttle'
);
-select throws_ok(
+select lives_ok(
$$select public.record_analytics_event('expense_added', 'local', '0123456789abcdef0123456789abcdef', 'en', 'USD')$$,
- '22023',
- 'invalid_analytics_currency',
- 'currency metadata is rejected for unrelated events'
+ 'currency metadata is rejected for unrelated events without rolling back the throttle'
+);
+select is(
+ (select count(*) from private.analytics_events),
+ (select event_count from analytics_count_before_invalid),
+ 'invalid analytics input never writes an event'
+);
+select is(
+ (
+ select request_count
+ from private.shared_activity_rate_limits
+ where identifier_hash = private.shared_activity_request_identifier()
+ and operation = 'analytics'
+ ),
+ (select request_count + 6 from analytics_rate_before_invalid),
+ 'every invalid analytics request consumes rate-limit budget'
+);
+select is(
+ current_setting('response.status', true),
+ '400',
+ 'invalid analytics input preserves the bad-request HTTP status'
);
select is(
@@ -341,10 +359,15 @@ select is(
select octet_length(identifier_hash)
from private.shared_activity_rate_limits
where operation = 'analytics'
- and identifier_hash = extensions.digest('203.0.113.20', 'sha256')
+ and identifier_hash = private.shared_activity_request_identifier()
),
32,
- 'analytics throttling stores only a one-way client identifier hash'
+ 'analytics throttling stores only a fixed-length pseudonymous identifier'
+);
+select isnt(
+ private.shared_activity_request_identifier(),
+ extensions.digest('203.0.113.20', 'sha256'),
+ 'analytics request identifiers are protected with the database-secret pepper'
);
select * from finish();
diff --git a/supabase/tests/shared_activities.sql b/supabase/tests/shared_activities.sql
index 9dc01c9..359aae9 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(53);
+select plan(61);
select has_schema('private', 'private schema exists');
select has_table('private', 'shared_activities', 'shared activity storage exists');
@@ -12,6 +12,12 @@ select is(
'shared activity storage has row security enabled'
);
select has_table('private', 'shared_activity_rate_limits', 'private API rate limits exist');
+select has_table('private', 'security_secrets', 'private request-identifier secrets exist');
+select is(
+ has_table_privilege('anon', 'private.security_secrets', 'SELECT'),
+ false,
+ 'anonymous clients cannot read request-identifier secrets'
+);
select has_index(
'private',
'shared_activity_rate_limits',
@@ -106,16 +112,25 @@ select is(
'updates increment the revision atomically'
);
-select throws_ok(
- format(
- 'select public.update_shared_activity(%L, %L, 1, %L::jsonb)',
- (select code from created_activity),
- (select edit_token from created_activity),
- (select snapshot::text from created_activity)
+select set_config('response.status', '200', true);
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.update_shared_activity(
+ created.code,
+ created.edit_token,
+ 1,
+ created.snapshot
+ )
),
- 'PT409',
- 'shared_activity_conflict',
- 'legacy stale revisions return a semantic HTTP conflict'
+ 0::bigint,
+ 'legacy stale revisions return no writable record'
+);
+select is(
+ current_setting('response.status', true),
+ '409',
+ 'legacy stale revisions preserve the semantic HTTP conflict status'
);
create temporary table v2_updated_activity as
@@ -161,7 +176,7 @@ select is(revision, 3::bigint, 'legacy invalid snapshots do not increment the re
create temporary table update_rate_before_rejection as
select request_count
from private.shared_activity_rate_limits
-where identifier_hash = extensions.digest('local-development', 'sha256')
+where identifier_hash = private.shared_activity_request_identifier()
and operation = 'update';
create temporary table v3_rejected_activity as
@@ -181,99 +196,133 @@ select is(
(
select request_count
from private.shared_activity_rate_limits
- where identifier_hash = extensions.digest('local-development', 'sha256')
+ where identifier_hash = private.shared_activity_request_identifier()
and operation = 'update'
),
(select request_count + 1 from update_rate_before_rejection),
'rejected snapshots still consume a rate-limit request'
);
-select throws_ok(
- format('select public.load_shared_activity(%L, %L)', (select code from created_activity), repeat('0', 64)),
- 'P0002',
- 'shared_activity_not_found',
+select set_config('response.status', '200', true);
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.load_shared_activity(created.code, repeat('0', 64))
+ ),
+ 0::bigint,
'invalid edit tokens do not reveal activities'
);
-
-select throws_ok(
- format('select public.poll_shared_activity(%L, %L)', (select code from created_activity), repeat('0', 64)),
- 'P0002',
- 'shared_activity_not_found',
+select is(
+ current_setting('response.status', true),
+ '404',
+ 'invalid edit tokens preserve the not-found HTTP status'
+);
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.poll_shared_activity(created.code, repeat('0', 64))
+ ),
+ 0::bigint,
'revision polling does not reveal invalid edit tokens'
);
-select throws_ok(
- $$select public.create_shared_activity('{}'::jsonb)$$,
- '22023',
- 'invalid_activity_snapshot',
+create temporary table create_rate_before_rejection as
+select request_count
+from private.shared_activity_rate_limits
+where identifier_hash = private.shared_activity_request_identifier()
+ and operation = 'create';
+
+select is(
+ (select count(*) from public.create_shared_activity('{}'::jsonb)),
+ 0::bigint,
'invalid snapshots are rejected'
);
-
-select throws_ok(
- $$select public.create_shared_activity(null::jsonb)$$,
- '22023',
- 'invalid_activity_snapshot',
+select is(
+ (
+ select request_count
+ from private.shared_activity_rate_limits
+ where identifier_hash = private.shared_activity_request_identifier()
+ and operation = 'create'
+ ),
+ (select request_count + 1 from create_rate_before_rejection),
+ 'invalid creates still consume a rate-limit request'
+);
+select is(
+ (select count(*) from public.create_shared_activity(null::jsonb)),
+ 0::bigint,
'null snapshots are rejected explicitly'
);
-select throws_ok(
- format(
- 'select public.update_shared_activity_v2(%L, %L, 0, %L::jsonb)',
- (select code from created_activity),
- (select edit_token from created_activity),
- (select snapshot::text from created_activity)
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.update_shared_activity_v2(
+ created.code,
+ created.edit_token,
+ 0,
+ created.snapshot
+ )
),
- '22023',
- 'invalid_expected_revision',
+ 0::bigint,
'invalid revisions are rejected'
);
-select throws_ok(
- format(
- 'select public.update_shared_activity_v2(%L, %L, null, %L::jsonb)',
- (select code from created_activity),
- (select edit_token from created_activity),
- (select snapshot::text from created_activity)
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.update_shared_activity_v2(
+ created.code,
+ created.edit_token,
+ null,
+ created.snapshot
+ )
),
- '22023',
- 'invalid_expected_revision',
+ 0::bigint,
'null revisions are rejected explicitly'
);
-select throws_ok(
- format(
- 'select public.update_shared_activity_v2(%L, %L, 3, %L::jsonb)',
- (select code from created_activity),
- repeat('0', 64),
- (select snapshot::text from created_activity)
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.update_shared_activity_v2(
+ created.code,
+ repeat('0', 64),
+ 3,
+ created.snapshot
+ )
),
- 'P0002',
- 'shared_activity_not_found',
+ 0::bigint,
'conflict-aware updates do not reveal invalid edit tokens'
);
-select throws_ok(
- format(
- 'select public.create_shared_activity(%L::jsonb)',
- (
- select jsonb_set(
- snapshot,
- '{friends}',
- (
- select jsonb_agg(jsonb_build_object(
- 'id', 'friend-' || index,
- 'name', 'Friend ' || index,
- 'initials', 'F',
- 'color', '#abc'
- ))
- from generate_series(1, 101) index
+select is(
+ (
+ select count(*)
+ from public.create_shared_activity(
+ (
+ select jsonb_set(
+ snapshot,
+ '{friends}',
+ (
+ select jsonb_agg(jsonb_build_object(
+ 'id', 'friend-' || index,
+ 'name', 'Friend ' || index,
+ 'initials', 'F',
+ 'color', '#abc'
+ ))
+ from generate_series(1, 101) index
+ )
)
- )::text
- from created_activity
+ from created_activity
+ )
)
),
- '22023',
- 'invalid_activity_snapshot',
+ 0::bigint,
'oversized participant lists are rejected'
);
@@ -283,25 +332,23 @@ set created_at = clock_timestamp() - interval '100 days',
expires_at = clock_timestamp() - interval '1 day'
where code = (select code from created_activity);
-select throws_ok(
- format(
- 'select public.load_shared_activity(%L, %L)',
- (select code from created_activity),
- (select edit_token from created_activity)
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.load_shared_activity(created.code, created.edit_token)
),
- 'P0002',
- 'shared_activity_not_found',
+ 0::bigint,
'expired activities cannot be loaded'
);
-select throws_ok(
- format(
- 'select public.poll_shared_activity(%L, %L)',
- (select code from created_activity),
- (select edit_token from created_activity)
+select is(
+ (
+ select count(*)
+ from created_activity created
+ cross join lateral public.poll_shared_activity(created.code, created.edit_token)
),
- 'P0002',
- 'shared_activity_not_found',
+ 0::bigint,
'expired activities cannot be polled'
);
@@ -323,11 +370,32 @@ select is(
(
select octet_length(identifier_hash)
from private.shared_activity_rate_limits
- where identifier_hash = extensions.digest('203.0.113.10', 'sha256')
+ where identifier_hash = private.shared_activity_request_identifier()
and operation = 'load'
),
32,
- 'rate limits store only a one-way client identifier hash'
+ 'rate limits store only a fixed-length pseudonymous identifier'
+);
+select isnt(
+ private.shared_activity_request_identifier(),
+ extensions.digest('203.0.113.10', 'sha256'),
+ 'request identifiers are protected with a database-secret pepper'
+);
+
+create table public.default_privilege_probe (id bigint);
+create function public.default_privilege_probe()
+returns integer
+language sql
+as $$ select 1; $$;
+select is(
+ has_table_privilege('anon', 'public.default_privilege_probe', 'SELECT'),
+ false,
+ 'new public tables are not exposed to anonymous clients by default'
+);
+select is(
+ has_function_privilege('anon', 'public.default_privilege_probe()', 'EXECUTE'),
+ false,
+ 'new public functions are not executable anonymously by default'
);
select * from finish();