From f175338439a4efd262eec247e24741c09c57179e Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:43:34 +0200 Subject: [PATCH 1/8] Harden rate-limit table RLS, settings action auth, and secret comparisons Three low-risk security fixes from the full-project audit. None changes application behaviour; verified with tsc, 565/565 tests, lint and a production build. - rate_limit_buckets: enable RLS and revoke anon/authenticated grants. The table backing every application rate limit was created without RLS, so stock Supabase default privileges left it directly writable over PostgREST by any signed-in user, making all limiters bypassable. Its only accessor is the consume_rate_limit security definer function, which bypasses RLS, so no code path changes. - getWorkspaceSettings / getWorkspaceSettingsForDisplay: require a session and workspace:read on the caller-supplied workspaceId. Every export in a 'use server' file is a POST-reachable endpoint; these two trusted their argument. RLS made it non-exploitable today, but the check keeps the action safe if the reader is ever moved to the admin client. - Cron secret and Meta webhook verify token: compare with timingSafeStringEqual instead of ===. The webhook POST path already used timingSafeEqual; this brings the two remaining comparisons in line. Migration is committed but NOT yet applied to the Supabase project. --- app/actions/settings.ts | 10 ++++++++++ app/api/cron/scheduler/route.ts | 3 ++- app/api/webhooks/instagram/route.ts | 5 ++++- ...12120000_harden_rate_limit_buckets_rls.sql | 19 +++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260912120000_harden_rate_limit_buckets_rls.sql diff --git a/app/actions/settings.ts b/app/actions/settings.ts index 303e849b..4d158252 100644 --- a/app/actions/settings.ts +++ b/app/actions/settings.ts @@ -88,6 +88,16 @@ export async function togglePageSelection(platform: string, pageId: string, sele */ export async function getWorkspaceSettings(workspaceId: string, ): Promise { + // Every export in a 'use server' file is a POST-reachable endpoint, so the + // caller-supplied workspaceId has to be checked here rather than trusted. + // RLS on workspace_settings already limits the read, but this keeps the + // action safe if the reader is ever switched to the admin client. + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error("Unauthorized") + + await requireWorkspacePermission(supabase, user.id, workspaceId, "workspace:read") + const settings = await getWorkspaceSettingsWithSecrets(workspaceId) return settings ? sanitizeWorkspaceSettingsForClient(settings) : null } diff --git a/app/api/cron/scheduler/route.ts b/app/api/cron/scheduler/route.ts index 683d9742..72ed913c 100644 --- a/app/api/cron/scheduler/route.ts +++ b/app/api/cron/scheduler/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { createAdminClient } from '@/utils/supabase/admin' +import { timingSafeStringEqual } from '@/lib/developer-api/key-format' export const runtime = 'nodejs' export const maxDuration = 60 @@ -10,7 +11,7 @@ function isAuthorizedCronRequest(request: NextRequest) { const cronSecret = process.env.CRON_SECRET if (cronSecret) { - return request.headers.get('authorization') === `Bearer ${cronSecret}` + return timingSafeStringEqual(request.headers.get('authorization') || '', `Bearer ${cronSecret}`) } // Production must never run scheduler ticks without a configured shared secret. diff --git a/app/api/webhooks/instagram/route.ts b/app/api/webhooks/instagram/route.ts index 9cae4de9..685a800c 100644 --- a/app/api/webhooks/instagram/route.ts +++ b/app/api/webhooks/instagram/route.ts @@ -8,6 +8,7 @@ import { buildInstagramMessagingAutomationEvents } from '@/lib/webhooks/instagra import { createSupabaseWebhookInboxStore } from '@/lib/webhooks/supabase-inbox-store'; import { readRawBodyWithLimit, RequestBodyTooLargeError } from '@/lib/security/phase1-validation'; import { requireSupabaseServiceRoleKey } from '@/lib/supabase/service-key'; +import { timingSafeStringEqual } from '@/lib/developer-api/key-format'; // Meta webhook payloads are small (batched entries stay well under this cap). const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024; @@ -106,7 +107,9 @@ export async function GET(request: NextRequest) { console.log('[WEBHOOK] Verification request:', { mode, hasToken: !!token, hasChallenge: !!challenge }); - if (mode === 'subscribe' && token === process.env.META_WEBHOOK_VERIFY_TOKEN) { + const verifyToken = process.env.META_WEBHOOK_VERIFY_TOKEN || ''; + + if (mode === 'subscribe' && verifyToken && timingSafeStringEqual(token || '', verifyToken)) { console.log('[WEBHOOK] Verification successful'); // Must return the challenge as plain text, not JSON return new NextResponse(challenge, { status: 200 }); diff --git a/supabase/migrations/20260912120000_harden_rate_limit_buckets_rls.sql b/supabase/migrations/20260912120000_harden_rate_limit_buckets_rls.sql new file mode 100644 index 00000000..d01115f7 --- /dev/null +++ b/supabase/migrations/20260912120000_harden_rate_limit_buckets_rls.sql @@ -0,0 +1,19 @@ +-- Hardens public.rate_limit_buckets, which 20260406220000_add_rate_limit_buckets.sql +-- created without row level security and without narrowing its grants. Stock +-- Supabase default privileges grant ALL on new public tables to anon and +-- authenticated, so the table backing every application rate limit has been +-- directly reachable over PostgREST by any signed-in user. +-- +-- No application code queries this table directly: its only accessor is +-- public.consume_rate_limit(), a security definer function already restricted +-- to service_role (20260406220000_add_rate_limit_buckets.sql:89-90). Security +-- definer functions bypass RLS, so enabling RLS and revoking direct grants +-- removes the PostgREST surface without changing any application behaviour. +-- +-- Intentionally no policies: service_role bypasses RLS, and no other role has +-- a legitimate reason to read or write these rows. + +alter table public.rate_limit_buckets enable row level security; + +revoke all on table public.rate_limit_buckets from public, anon, authenticated; +grant all on table public.rate_limit_buckets to service_role; From acc1ba70cee59a65f9cb8d6d69de0c44b50fdb26 Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:54:56 +0200 Subject: [PATCH 2/8] Upgrade Next.js to 16.3.5 and sharp to 0.35.4 (unauthenticated RCE fixes) Next.js 16.2.12 was affected by two unauthenticated remote code execution advisories, and the app actively enables the feature one of them targets: - GHSA-2xp9-vwfh-vxw4: unauthenticated RCE in the Image Optimization API via AVIF. next.config.ts configures remotePatterns for cdninstagram.com and fbcdn.net, so this code path is live. - GHSA-p293-qw3h-jr36: unauthenticated RCE on Windows-hosted servers. sharp was additionally held at the vulnerable 0.35.3 by this project's own overrides block (GHSA-rgj7-g3m4-5g8c, libheif). Raised to 0.35.4. eslint-config-next moved to 16.3.5 in lockstep, matching the repo's convention of pinning both exactly. npm audit --omit=dev now reports 0 vulnerabilities, down from 1 critical and 1 high. The 4 remaining advisories are dev-only (eslint, vitest, js-yaml) and do not ship to production. Verified: tsc 0 errors, 565/565 tests, production build OK, lint 0 errors. Lint gained 2 warnings from a new rule in the upgraded config (no-location-assign-relative-destination) flagging pre-existing window.location.href use in two Instagram connect components; left unchanged as they are warnings on working navigation behaviour. --- package-lock.json | 425 ++++++++++++++++++++++------------------------ package.json | 6 +- 2 files changed, 204 insertions(+), 227 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23da6865..b2eea236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "dotenv": "^17.2.3", "framer-motion": "^12.25.0", "lucide-react": "^0.562.0", - "next": "16.2.12", + "next": "16.3.5", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", "pg": "^8.22.0", @@ -53,7 +53,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.12", + "eslint-config-next": "16.3.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", @@ -104,6 +104,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -313,18 +314,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -1064,9 +1053,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1082,13 +1071,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1104,20 +1093,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1127,9 +1116,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1143,9 +1132,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1159,9 +1148,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -1175,9 +1164,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -1191,9 +1180,9 @@ } }, "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -1207,9 +1196,9 @@ } }, "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -1223,9 +1212,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -1239,9 +1228,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -1255,9 +1244,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -1271,9 +1260,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -1287,9 +1276,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -1305,13 +1294,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -1327,13 +1316,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -1349,13 +1338,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -1371,13 +1360,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -1393,13 +1382,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -1415,13 +1404,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -1437,13 +1426,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -1459,17 +1448,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1479,16 +1468,16 @@ } }, "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1498,9 +1487,9 @@ } }, "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1517,9 +1506,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1536,9 +1525,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "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==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1605,25 +1594,26 @@ } }, "node_modules/@next/env": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", - "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", - "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.5.tgz", + "integrity": "sha512-PGfSeItHJ12DH8t+6sEbuMe59NE5rAhCfgk06QKTH2ne9VUL1JlaXdYXY3B8RaiF2SO50rDYvd39TulP6A6xZQ==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", - "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", "cpu": [ "arm64" ], @@ -1637,9 +1627,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", - "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", "cpu": [ "x64" ], @@ -1653,9 +1643,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", - "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", "cpu": [ "arm64" ], @@ -1669,9 +1659,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", - "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", "cpu": [ "arm64" ], @@ -1685,9 +1675,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", - "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", "cpu": [ "x64" ], @@ -1701,9 +1691,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", - "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", "cpu": [ "x64" ], @@ -1717,9 +1707,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", - "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", "cpu": [ "arm64" ], @@ -1733,9 +1723,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", - "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", "cpu": [ "x64" ], @@ -4201,17 +4191,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -4372,6 +4351,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.90.1", "@supabase/functions-js": "2.90.1", @@ -4384,9 +4364,9 @@ } }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -4903,6 +4883,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4913,6 +4894,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4977,6 +4959,7 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -5446,29 +5429,6 @@ "node": ">=14.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -5695,6 +5655,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6072,6 +6033,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -6398,6 +6360,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6977,6 +6940,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7032,13 +6996,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", - "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.5.tgz", + "integrity": "sha512-wPjq9MLQuWykHs8tsm2gH6OJThk6N27L8Te2JatlaGZ7jT1gtgGmJKukygL28xOlWsg5J1a1bGy/PvLyQC8OYA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.12", + "@next/eslint-plugin-next": "16.3.5", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -7553,9 +7517,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -8021,6 +7985,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -9119,16 +9084,17 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", - "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", "license": "MIT", + "peer": true, "dependencies": { - "@next/env": "16.2.12", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -9138,15 +9104,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.12", - "@next/swc-darwin-x64": "16.2.12", - "@next/swc-linux-arm64-gnu": "16.2.12", - "@next/swc-linux-arm64-musl": "16.2.12", - "@next/swc-linux-x64-gnu": "16.2.12", - "@next/swc-linux-x64-musl": "16.2.12", - "@next/swc-win32-arm64-msvc": "16.2.12", - "@next/swc-win32-x64-msvc": "16.2.12", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -9490,6 +9456,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -9727,6 +9694,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9736,6 +9704,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9747,13 +9716,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9875,7 +9846,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -10167,9 +10139,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -10184,31 +10156,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@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" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { @@ -10691,6 +10663,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10771,6 +10744,7 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -10891,6 +10865,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11107,6 +11082,7 @@ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -11744,6 +11720,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b1e6d7fc..74fc7737 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "dotenv": "^17.2.3", "framer-motion": "^12.25.0", "lucide-react": "^0.562.0", - "next": "16.2.12", + "next": "16.3.5", "next-themes": "^0.4.6", "nextjs-toploader": "^3.9.17", "pg": "^8.22.0", @@ -70,7 +70,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.12", + "eslint-config-next": "16.3.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", @@ -79,7 +79,7 @@ "overrides": { "nanoid": "3.3.18", "postcss": "8.5.25", - "sharp": "0.35.3", + "sharp": "0.35.4", "ws": "8.21.1" } } From 0763d72ffc29e9671b233df7afb1a6f77f710aa7 Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:55:17 +0200 Subject: [PATCH 3/8] Deploy automation-worker-send-email with the other automation workers process-automations/graph-executor.ts maps the action_send_email node type to the automation-worker-send-email edge function, but that function was missing from both scripts/deploy-managed-supabase.mjs and scripts/setup-check.mjs while every other automation worker was listed in both. A fresh managed deployment therefore never shipped it, so send-email automation nodes would fail at runtime against a newly provisioned project. The function already exists and already carries the assertInternalInvoke guard, so this only adds it to the deploy and preflight manifests. Verified all eight workers dispatched by graph-executor are now present in both manifests. 565/565 tests pass. --- scripts/deploy-managed-supabase.mjs | 1 + scripts/setup-check.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/deploy-managed-supabase.mjs b/scripts/deploy-managed-supabase.mjs index f36dea03..8d86ba78 100644 --- a/scripts/deploy-managed-supabase.mjs +++ b/scripts/deploy-managed-supabase.mjs @@ -38,6 +38,7 @@ const internalFunctions = [ "automation-worker-private-reply", "automation-worker-reply-comment", "automation-worker-send-dm", + "automation-worker-send-email", "automation-worker-telegram", "telegram-automation-webhook", "retention-cleanup", diff --git a/scripts/setup-check.mjs b/scripts/setup-check.mjs index 7c04f5cc..b56a8e31 100644 --- a/scripts/setup-check.mjs +++ b/scripts/setup-check.mjs @@ -37,6 +37,7 @@ export const REQUIRED_FUNCTIONS = [ "automation-worker-private-reply", "automation-worker-reply-comment", "automation-worker-send-dm", + "automation-worker-send-email", "automation-worker-telegram", "telegram-automation-webhook", "scheduler-tick", From 05dc2eb67160c1d409bd276b8a8dc9591b8ff17b Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:06:21 +0200 Subject: [PATCH 4/8] Finish advisor function hardening: revoke trigger-only RPC, pin search_path Live Supabase advisors still reported three findings that 20260702110000_advisor_security_and_fk_index_remediation.sql had started but not completed, because later migrations added functions that missed the same treatment: - anon/authenticated_security_definer_function_executable (5 each): four trigger-only SECURITY DEFINER functions were callable over PostgREST by anon and authenticated. Each has exactly one CREATE TRIGGER definition and zero application rpc() call sites, so nothing needs REST execute. Revoked, the same way 20260702110000 handled create_default_settings and create_default_workspace_settings. - is_member_of(uuid) is the fifth function in those advisor findings and is deliberately left executable: it backs 16 RLS policy expressions in the baseline schema and revoking it would break row-level security. That exception is already documented in 20260702110000. - function_search_path_mutable (2): claim_webhook_inbox_events and claim_automation_actions had no pinned search_path. Both are SECURITY INVOKER with fully schema-qualified bodies and execute granted only to service_role and the worker roles, so this is deterministic-resolution hygiene, not a privilege fix. Signatures were read from the live database rather than inferred. 565/565 tests pass. Not yet applied. --- ...0000_finish_advisor_function_hardening.sql | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 supabase/migrations/20260912130000_finish_advisor_function_hardening.sql diff --git a/supabase/migrations/20260912130000_finish_advisor_function_hardening.sql b/supabase/migrations/20260912130000_finish_advisor_function_hardening.sql new file mode 100644 index 00000000..18b776f3 --- /dev/null +++ b/supabase/migrations/20260912130000_finish_advisor_function_hardening.sql @@ -0,0 +1,40 @@ +-- Finishes the function hardening started in +-- 20260702110000_advisor_security_and_fk_index_remediation.sql, which pinned +-- search_path on six functions and revoked REST execute on two trigger-only +-- SECURITY DEFINER functions. Live Supabase advisors still report: +-- +-- anon_security_definer_function_executable (5 functions) +-- authenticated_security_definer_function_executable (5 functions) +-- function_search_path_mutable (2 functions) +-- +-- because four more trigger-only SECURITY DEFINER functions were added after +-- that migration and never had their REST execute revoked, and two claim +-- functions were added without a pinned search_path. + +-- 1. Revoke REST execute on trigger-only SECURITY DEFINER functions ----------- +-- +-- All four fire only from triggers owned by postgres (one CREATE TRIGGER each, +-- zero application rpc() call sites). Nothing needs to reach them over +-- PostgREST, and as SECURITY DEFINER they should not be callable by anon or +-- authenticated. Same treatment 20260702110000 gave create_default_settings +-- and create_default_workspace_settings. +-- +-- is_member_of(uuid) is deliberately NOT revoked here: it backs 16 RLS policy +-- expressions in the baseline schema, and revoking execute would break +-- row-level security. That exception is documented in 20260702110000. + +revoke all on function public.capture_automation_workflow_version() from anon, authenticated, public; +revoke all on function public.pin_automation_execution_workflow_version() from anon, authenticated, public; +revoke all on function public.reject_automation_execution_event_mutation() from anon, authenticated, public; +revoke all on function public.reject_workflow_version_mutation() from anon, authenticated, public; + +-- 2. Pin search_path on the two claim functions ------------------------------ +-- +-- Both are SECURITY INVOKER, so there is no privilege-escalation path and this +-- is hygiene rather than a fix: it silences function_search_path_mutable and +-- makes resolution deterministic. Their bodies already schema-qualify every +-- object reference, and execute is granted only to service_role and the +-- least-privilege worker roles, so behaviour is unchanged. + +alter function public.claim_webhook_inbox_events(text, integer, integer) set search_path = public, pg_temp; +alter function public.claim_automation_actions(text, integer, integer) set search_path = public, pg_temp; From 02cb64e8a4d8cfa637b3ab1d05bca3c8591674dd Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:10:14 +0200 Subject: [PATCH 5/8] Make RLS role-aware on workspace_settings and social_accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_member_of(uuid) resolves membership only, so the baseline "Member access" policies on these two tables — both FOR ALL with no FOR clause — granted every member, viewer included, full read and write. Combined with the table-level GRANT ALL TO authenticated that both tables carry (confirmed live: authenticated=arwdDxtm/postgres), a viewer could bypass the application entirely, call PostgREST with their own JWT, and overwrite provider credentials: telegram_bot_token, the AI provider keys, and the Instagram access_token/refresh_token. decryptSecretIfNeeded() returns any value lacking an enc: prefix verbatim, so an attacker-written plaintext token would then be used as-is by the runtime. Adds has_workspace_role(uuid, text[]) — is_member_of plus a role constraint, SECURITY DEFINER with pinned search_path — and splits each FOR ALL policy into a member SELECT plus owner/admin INSERT/UPDATE/DELETE. This does not change application behaviour. Every session-client write path to these tables already enforces the same restriction, verified route by route: workspace_settings via requireWorkspacePermission(..., 'settings:write') and social_accounts via 'integrations:write', both mapping to ["owner","admin"] in WORKSPACE_PERMISSION_ROLE_MAP. Reads are untouched and remain open to every member. Service-role callers bypass RLS. The two worker-role SELECT policies on social_accounts are left in place. Scoped deliberately to the two credential-bearing tables. The same role-blind pattern exists on other baseline tables and needs the same per-table review of write paths before it can be changed safely. 565/565 tests pass. Not yet applied. --- ...aware_rls_settings_and_social_accounts.sql | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 supabase/migrations/20260912140000_role_aware_rls_settings_and_social_accounts.sql diff --git a/supabase/migrations/20260912140000_role_aware_rls_settings_and_social_accounts.sql b/supabase/migrations/20260912140000_role_aware_rls_settings_and_social_accounts.sql new file mode 100644 index 00000000..c8ae915c --- /dev/null +++ b/supabase/migrations/20260912140000_role_aware_rls_settings_and_social_accounts.sql @@ -0,0 +1,124 @@ +-- Makes row level security enforce the workspace role model that until now +-- existed only in application code (lib/workspace-rbac.ts). +-- +-- THE GAP +-- public.is_member_of(uuid) resolves membership and nothing else, so the +-- baseline "Member access settings" and "Member access social_accounts" +-- policies (both FOR ALL, no FOR clause) granted every member — including +-- `viewer` — full read AND write. Combined with the table-level +-- GRANT ALL ... TO authenticated that both tables carry, a viewer could skip +-- the application entirely, call PostgREST with their own JWT, and overwrite +-- provider credentials: telegram_bot_token, the AI provider keys, and the +-- Instagram access_token / refresh_token on social_accounts. Because +-- decryptSecretIfNeeded() passes through any value lacking an enc: prefix, +-- an attacker-written plaintext token is then used verbatim by the runtime. +-- +-- WHY THIS DOES NOT CHANGE APPLICATION BEHAVIOUR +-- Every session-client write path to these two tables already enforces the +-- same restriction in application code, verified route by route: +-- workspace_settings -> requireWorkspacePermission(..., 'settings:write') +-- app/actions/settings.ts, app/actions/telegram-settings.ts, +-- app/api/workspace/settings/route.ts:185 +-- social_accounts -> requireWorkspacePermission(..., 'integrations:write') +-- app/actions/settings.ts, app/api/auth/instagram/{callback,refresh, +-- subscribe,verify}/route.ts, app/api/brand/social-accounts/route.ts:32 +-- Both permissions map to ["owner","admin"] in WORKSPACE_PERMISSION_ROLE_MAP, +-- so these policies mirror the checks the app already makes. Reads are +-- unchanged and remain open to every member ('workspace:read' includes +-- viewer). Service-role callers (edge functions, admin client) bypass RLS +-- entirely and are unaffected. +-- +-- SCOPE +-- Deliberately limited to the two tables that hold credentials. The same +-- role-blind FOR ALL pattern exists on other baseline tables and is tracked +-- separately; doing those needs the same per-table review of write paths and +-- is not safe to do blind. + +-- 1. Role-aware membership helper -------------------------------------------- +-- +-- Mirrors is_member_of but constrains the member's role. SECURITY DEFINER for +-- the same reason is_member_of is: it must read workspace_members while that +-- table's own RLS is in force. search_path is pinned, with pg_temp last. +-- +-- Like is_member_of, this has to stay executable by `authenticated` because +-- RLS policies calling it are evaluated as the querying role. That is the same +-- documented trade-off recorded in +-- 20260702110000_advisor_security_and_fk_index_remediation.sql. + +create or replace function public.has_workspace_role(_workspace_id uuid, _roles text[]) +returns boolean +language sql +stable +security definer +set search_path = public, pg_temp +as $$ + select exists ( + select 1 + from public.workspace_members + where workspace_id = _workspace_id + and user_id = auth.uid() + and role = any(_roles) + ); +$$; + +revoke all on function public.has_workspace_role(uuid, text[]) from public, anon; +grant execute on function public.has_workspace_role(uuid, text[]) to authenticated; + +-- 2. workspace_settings ------------------------------------------------------- +-- +-- The worker-role policies on these tables are intentionally left in place; +-- only the role-blind member policy is replaced. + +drop policy if exists "Member access settings" on public.workspace_settings; + +create policy "workspace_settings_member_select" + on public.workspace_settings + for select + using (public.is_member_of(workspace_id)); + +create policy "workspace_settings_admin_insert" + on public.workspace_settings + for insert + to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +create policy "workspace_settings_admin_update" + on public.workspace_settings + for update + to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +create policy "workspace_settings_admin_delete" + on public.workspace_settings + for delete + to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +-- 3. social_accounts ---------------------------------------------------------- + +drop policy if exists "Member access social_accounts" on public.social_accounts; + +create policy "social_accounts_member_select" + on public.social_accounts + for select + using (public.is_member_of(workspace_id)); + +create policy "social_accounts_admin_insert" + on public.social_accounts + for insert + to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +create policy "social_accounts_admin_update" + on public.social_accounts + for update + to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +create policy "social_accounts_admin_delete" + on public.social_accounts + for delete + to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])); From 49b831a057d97d6526d19dcbea4afde5099f8e00 Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:32:50 +0200 Subject: [PATCH 6/8] Bind OAuth redirect_uri to registered clients and make codes single-use The Developer API OAuth connector had two flaws in the same subsystem. redirect_uri was unbound. /register returned a client_id but persisted nothing, so /authorize had no registered set to compare against and validated only that the URI started with "https://". Because the authorization code encrypts the operator's raw Developer API key, and /api/developer/mcp unwraps it back into a Bearer header, an attacker could send a workspace owner an authorize link pointing at their own host. The consent page renders on the genuine SwiftFlow origin asking for exactly what the product legitimately asks for, so approving it hands the attacker a code that exchanges into full workspace API access. PKCE does not help: it binds the code to whoever made the request, which in that flow is the attacker. Authorization codes were replayable. verifyDeveloperOAuthCode only decrypted and checked exp, with no server-side record, so a code stayed valid for its full 10 minute TTL even after the legitimate client redeemed it. Changes: - New developer_oauth_clients and developer_oauth_used_codes tables, both service_role only. - /register now persists client_id with its redirect_uris and rejects a registration that has no usable HTTPS URI. - /authorize resolves client_id and requires redirect_uri to exactly match a registered URI, on both GET and POST. Failures render an error page and never redirect, so an unregistered URI cannot be used to bounce the user. - Codes carry a jti; /token claims it after all other checks pass, so a replay is refused while a failed PKCE attempt does not burn a valid code. jti is optional on the payload type so codes minted before this deploy still verify; they expire within the 10 minute TTL. Matching is exact string comparison with no normalization, prefix matching or wildcards, since those are what make redirect_uri validation bypassable. Verified nothing is actively connected before changing the flow: 2 API keys exist, both used, but last use was 2026-07-23. Existing access and refresh tokens are unaffected either way, as they verify by decryption rather than client lookup. 570 tests pass, up from 565: adds regression coverage for unregistered redirect_uri, unknown client_id, registration without HTTPS redirect, code replay, and non-consumption on PKCE failure. --- app/api/developer/oauth/authorize/route.ts | 49 ++++- app/api/developer/oauth/register/route.ts | 39 +++- app/api/developer/oauth/token/route.ts | 22 ++ lib/developer-api/oauth-clients.ts | 101 +++++++++ lib/developer-api/oauth.ts | 11 + ...00_add_developer_oauth_client_registry.sql | 53 +++++ tests/developer-api/oauth-routes.test.ts | 204 +++++++++++++++--- 7 files changed, 446 insertions(+), 33 deletions(-) create mode 100644 lib/developer-api/oauth-clients.ts create mode 100644 supabase/migrations/20260912150000_add_developer_oauth_client_registry.sql diff --git a/app/api/developer/oauth/authorize/route.ts b/app/api/developer/oauth/authorize/route.ts index 324fdbde..504c7651 100644 --- a/app/api/developer/oauth/authorize/route.ts +++ b/app/api/developer/oauth/authorize/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from "next/server" import { createDeveloperOAuthCode, getDeveloperOAuthScope, normalizeDeveloperOAuthResource } from "@/lib/developer-api/oauth" import { getDeveloperApiKeyPepper } from "@/lib/developer-api/key-format" +import { + getDeveloperOAuthClient, + isAcceptableRedirectUri, + isRegisteredRedirectUri, +} from "@/lib/developer-api/oauth-clients" export const runtime = "nodejs" @@ -44,7 +49,41 @@ function validateAuthorizeParams(searchParams: URLSearchParams) { if (searchParams.get("response_type") !== "code") return "response_type must be code" if (searchParams.get("code_challenge_method") !== "S256") return "code_challenge_method must be S256" const redirectUri = searchParams.get("redirect_uri") || "" - if (!redirectUri.startsWith("https://")) return "redirect_uri must be HTTPS" + if (!isAcceptableRedirectUri(redirectUri)) return "redirect_uri must be HTTPS and carry no fragment" + return null +} + +/** + * Binds redirect_uri to the client that registered it. + * + * Without this the authorization code — which encrypts the operator's raw + * Developer API key — could be delivered to any HTTPS host an attacker chose, + * while the consent page rendered on the genuine SwiftFlow origin. PKCE does + * not help there: it binds the code to whoever made the request, which in that + * attack is the attacker. Exact matching against the registered set is the + * control that closes it. + * + * Failures render an error page and never redirect, so an unregistered URI + * cannot be used to bounce the user somewhere. + */ +async function resolveAuthorizeClient(params: URLSearchParams): Promise { + const clientId = params.get("client_id") || "" + const redirectUri = params.get("redirect_uri") || "" + + let client + try { + client = await getDeveloperOAuthClient(clientId) + } catch (error) { + console.error("[oauth/authorize] Client lookup failed:", error) + return "Could not verify the connector registration. Try again." + } + + if (!client) { + return "Unknown client_id. Register the connector before authorizing." + } + if (!isRegisteredRedirectUri(client, redirectUri)) { + return "redirect_uri does not match a registered redirect URI for this client." + } return null } @@ -60,6 +99,9 @@ export async function GET(request: NextRequest) { const error = validateAuthorizeParams(request.nextUrl.searchParams) if (error) return errorPage(error) + const clientError = await resolveAuthorizeClient(request.nextUrl.searchParams) + if (clientError) return errorPage(clientError) + const hiddenFields = Array.from(request.nextUrl.searchParams.entries()) .map(([key, value]) => ``) .join("\n") @@ -98,6 +140,11 @@ export async function POST(request: NextRequest) { const error = validateAuthorizeParams(params) if (error) return errorPage(error) + // Re-checked on POST as well: the GET check guards the page render, but the + // form fields are attacker-controllable on the way back in. + const clientError = await resolveAuthorizeClient(params) + if (clientError) return errorPage(clientError) + const apiKey = form.get("api_key") if (typeof apiKey !== "string" || !apiKey.startsWith("sf_live_")) { return errorPage("Enter a valid SwiftFlow Developer API key") diff --git a/app/api/developer/oauth/register/route.ts b/app/api/developer/oauth/register/route.ts index b0f4b864..a178e700 100644 --- a/app/api/developer/oauth/register/route.ts +++ b/app/api/developer/oauth/register/route.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto" import { NextRequest, NextResponse } from "next/server" import { getDeveloperOAuthScope } from "@/lib/developer-api/oauth" +import { isAcceptableRedirectUri, registerDeveloperOAuthClient } from "@/lib/developer-api/oauth-clients" export const runtime = "nodejs" @@ -23,12 +24,42 @@ async function readRegistrationMetadata(request: NextRequest): Promise { + const supabase = createAdminClient() + const { error } = await supabase.from("developer_oauth_clients").insert({ + client_id: input.clientId, + client_name: input.clientName, + redirect_uris: input.redirectUris, + scope: input.scope, + }) + if (error) throw new Error(`Failed to register OAuth client: ${error.message}`) +} + +export async function getDeveloperOAuthClient(clientId: string): Promise { + if (!clientId) return null + const supabase = createAdminClient() + const { data, error } = await supabase + .from("developer_oauth_clients") + .select("client_id, client_name, redirect_uris, scope") + .eq("client_id", clientId) + .maybeSingle() + + if (error) throw new Error(`Failed to load OAuth client: ${error.message}`) + if (!data) return null + + return { + clientId: data.client_id as string, + clientName: (data.client_name as string | null) ?? null, + redirectUris: (data.redirect_uris as string[] | null) ?? [], + scope: (data.scope as string | null) ?? null, + } +} + +/** + * Exact string comparison against the registered set. Deliberately no + * normalization, prefix matching or wildcards — those are what make + * redirect_uri validation bypassable. + */ +export function isRegisteredRedirectUri(client: DeveloperOAuthClient, redirectUri: string): boolean { + return client.redirectUris.some((registered) => registered === redirectUri) +} + +/** + * Marks an authorization code as redeemed. Returns false when the code was + * already redeemed, which the token endpoint treats as invalid_grant. + * + * The primary key on jti makes this atomic: a concurrent second redemption + * loses the insert rather than both succeeding. + */ +export async function claimDeveloperOAuthCode(input: { + jti: string + clientId: string + expiresAt: Date +}): Promise { + const supabase = createAdminClient() + const { error } = await supabase.from("developer_oauth_used_codes").insert({ + jti: input.jti, + client_id: input.clientId, + expires_at: input.expiresAt.toISOString(), + }) + + if (!error) return true + // 23505 = unique_violation: the code has already been redeemed. + if (error.code === "23505") return false + throw new Error(`Failed to record authorization code redemption: ${error.message}`) +} diff --git a/lib/developer-api/oauth.ts b/lib/developer-api/oauth.ts index b06760f7..bc3ab1d7 100644 --- a/lib/developer-api/oauth.ts +++ b/lib/developer-api/oauth.ts @@ -16,6 +16,12 @@ type DeveloperOAuthCodePayload = DeveloperOAuthPayload & { clientId: string redirectUri: string codeChallenge: string + /** + * Single-use identifier. Optional so codes minted before this field existed + * still verify during a deploy; those expire within the 10 minute code TTL. + * The token endpoint enforces single use whenever it is present. + */ + jti?: string } type DeveloperOAuthRefreshPayload = DeveloperOAuthPayload & { @@ -107,6 +113,10 @@ export function buildDeveloperMcpAuthChallenge(origin: string, error = "invalid_ return `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="${OAUTH_SCOPE}", error="${error}", error_description="${description}"` } +export function getDeveloperOAuthCodeTtlSeconds() { + return CODE_TTL_SECONDS +} + export function createDeveloperOAuthCode(input: CreateOAuthCodeInput): string { return encryptPayload("sf_oauth_code", { apiKey: input.apiKey, @@ -115,6 +125,7 @@ export function createDeveloperOAuthCode(input: CreateOAuthCodeInput): string { codeChallenge: input.codeChallenge, scope: input.scope, resource: normalizeDeveloperOAuthResource(input.resource), + jti: input.jti ?? crypto.randomUUID(), exp: (input.now ?? Math.floor(Date.now() / 1000)) + CODE_TTL_SECONDS, }, input.pepper) } diff --git a/supabase/migrations/20260912150000_add_developer_oauth_client_registry.sql b/supabase/migrations/20260912150000_add_developer_oauth_client_registry.sql new file mode 100644 index 00000000..9ef3e1ed --- /dev/null +++ b/supabase/migrations/20260912150000_add_developer_oauth_client_registry.sql @@ -0,0 +1,53 @@ +-- Backing store for the Developer API OAuth connector. +-- +-- Two gaps this closes: +-- +-- 1. /api/developer/oauth/register returned a client_id but persisted nothing, +-- so /authorize had no registered redirect_uri set to compare against and +-- accepted any URI that merely started with "https://". Because the +-- authorization code encrypts the operator's raw Developer API key, an +-- attacker could craft an authorize link pointing at their own host, have a +-- workspace owner approve it on the genuine SwiftFlow origin, and receive a +-- code that exchanges into full API access. Exact redirect_uri matching +-- against a registered client is the control that prevents this. +-- +-- 2. Authorization codes were verified purely by decryption, with no server +-- side record, so a code stayed replayable for its full 10 minute TTL even +-- after the legitimate client redeemed it. +-- +-- Both tables are service_role only. The OAuth routes reach them through the +-- admin client; no browser role needs access. + +create table if not exists public.developer_oauth_clients ( + client_id text primary key, + client_name text, + redirect_uris text[] not null default '{}', + scope text, + created_at timestamptz not null default timezone('utc'::text, now()) +); + +comment on table public.developer_oauth_clients is + 'Dynamically registered OAuth clients for the Developer API connector. redirect_uris is the exact-match allowlist enforced by /api/developer/oauth/authorize.'; + +alter table public.developer_oauth_clients enable row level security; +revoke all on table public.developer_oauth_clients from public, anon, authenticated; +grant all on table public.developer_oauth_clients to service_role; + +-- Single-use authorization codes. A code carries a jti; redeeming it inserts +-- that jti here, and the primary key makes a second redemption fail. +create table if not exists public.developer_oauth_used_codes ( + jti uuid primary key, + client_id text, + redeemed_at timestamptz not null default timezone('utc'::text, now()), + expires_at timestamptz not null +); + +comment on table public.developer_oauth_used_codes is + 'Redeemed authorization code identifiers. Rows may be pruned once expires_at has passed; the code TTL is 10 minutes.'; + +create index if not exists developer_oauth_used_codes_expires_at_idx + on public.developer_oauth_used_codes (expires_at); + +alter table public.developer_oauth_used_codes enable row level security; +revoke all on table public.developer_oauth_used_codes from public, anon, authenticated; +grant all on table public.developer_oauth_used_codes to service_role; diff --git a/tests/developer-api/oauth-routes.test.ts b/tests/developer-api/oauth-routes.test.ts index be53bbe9..a629b764 100644 --- a/tests/developer-api/oauth-routes.test.ts +++ b/tests/developer-api/oauth-routes.test.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server" -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { POST as authorizePost } from "@/app/api/developer/oauth/authorize/route" import { POST as tokenPost } from "@/app/api/developer/oauth/token/route" import { POST as registerPost } from "@/app/api/developer/oauth/register/route" @@ -7,6 +7,40 @@ import { createDeveloperOAuthCode, createDeveloperOAuthRefreshToken } from "@/li const origin = "https://social.swiftdigital-s.com" const pepper = "test-pepper" +const testClientId = "chatgpt-test-client" +const testRedirectUri = "https://chatgpt.com/connector/oauth/callback-test" + +// In-memory stand-ins for the two service_role tables backing the connector. +// The pure helpers (isAcceptableRedirectUri, isRegisteredRedirectUri) stay real +// so the exact-match rule itself is what these tests exercise. +const registeredClients = new Map() +const redeemedCodes = new Set() + +vi.mock("@/lib/developer-api/oauth-clients", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + registerDeveloperOAuthClient: vi.fn(async (input: { + clientId: string + clientName: string | null + redirectUris: string[] + scope: string | null + }) => { + registeredClients.set(input.clientId, input) + }), + getDeveloperOAuthClient: vi.fn(async (clientId: string) => registeredClients.get(clientId) ?? null), + claimDeveloperOAuthCode: vi.fn(async ({ jti }: { jti: string }) => { + if (redeemedCodes.has(jti)) return false + redeemedCodes.add(jti) + return true + }), + } +}) function setPepper() { process.env.DEVELOPER_API_KEY_PEPPER = pepper @@ -15,8 +49,8 @@ function setPepper() { function createCode() { return createDeveloperOAuthCode({ apiKey: "sf_live_test_key", - clientId: "chatgpt-test-client", - redirectUri: "https://chatgpt.com/connector/oauth/callback-test", + clientId: testClientId, + redirectUri: testRedirectUri, codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", scope: "swiftflow.developer_api", resource: `${origin}/api/developer/mcp/`, @@ -27,14 +61,47 @@ function createCode() { function createRefreshToken() { return createDeveloperOAuthRefreshToken({ apiKey: "sf_live_test_key", - clientId: "chatgpt-test-client", + clientId: testClientId, scope: "swiftflow.developer_api", resource: `${origin}/api/developer/mcp/`, pepper, }) } +function authorizeForm(overrides: Record = {}) { + return new URLSearchParams({ + client_id: testClientId, + redirect_uri: testRedirectUri, + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + state: "oauth_s_test", + api_key: "sf_live_test_key", + ...overrides, + }) +} + +function postAuthorize(body: URLSearchParams) { + return authorizePost(new NextRequest(`${origin}/api/developer/oauth/authorize`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + })) +} + describe("developer API OAuth routes", () => { + beforeEach(() => { + registeredClients.clear() + redeemedCodes.clear() + // The happy-path tests act as an already-registered connector. + registeredClients.set(testClientId, { + clientId: testClientId, + clientName: "SwiftFlow Test", + redirectUris: [testRedirectUri], + scope: "swiftflow.developer_api", + }) + }) + afterEach(() => { vi.restoreAllMocks() }) @@ -42,47 +109,74 @@ describe("developer API OAuth routes", () => { it("returns a GET-following redirect from the API key consent form", async () => { setPepper() vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })) - const body = new URLSearchParams({ - client_id: "chatgpt-test-client", - redirect_uri: "https://chatgpt.com/connector/oauth/callback-test", - response_type: "code", - code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", - code_challenge_method: "S256", - state: "oauth_s_test", - api_key: "sf_live_test_key", - }) - const response = await authorizePost(new NextRequest(`${origin}/api/developer/oauth/authorize`, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: body.toString(), - })) + const response = await postAuthorize(authorizeForm()) expect(response.status).toBe(303) - expect(response.headers.get("location")).toContain("https://chatgpt.com/connector/oauth/callback-test?code=sf_oauth_code.") + expect(response.headers.get("location")).toContain(`${testRedirectUri}?code=sf_oauth_code.`) expect(response.headers.get("location")).toContain("state=oauth_s_test") }) - it("echoes dynamic client registration metadata for ChatGPT", async () => { + it("persists dynamic client registration metadata", async () => { const response = await registerPost(new NextRequest(`${origin}/api/developer/oauth/register`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_name: "SwiftFlow Test", - redirect_uris: ["https://chatgpt.com/connector/oauth/callback-test"], + redirect_uris: [testRedirectUri], token_endpoint_auth_method: "none", }), })) expect(response.status).toBe(201) - await expect(response.json()).resolves.toMatchObject({ + const body = await response.json() + expect(body).toMatchObject({ client_id: expect.stringMatching(/^swiftflow-mcp-/), client_name: "SwiftFlow Test", - redirect_uris: ["https://chatgpt.com/connector/oauth/callback-test"], + redirect_uris: [testRedirectUri], token_endpoint_auth_method: "none", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], }) + // The returned client_id must actually be stored, or /authorize has + // nothing to match redirect_uri against. + expect(registeredClients.get(body.client_id)?.redirectUris).toEqual([testRedirectUri]) + }) + + it("rejects registration without a usable HTTPS redirect_uri", async () => { + const response = await registerPost(new NextRequest(`${origin}/api/developer/oauth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_name: "No Redirects", redirect_uris: ["http://evil.example/cb"] }), + })) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: "invalid_redirect_uri" }) + }) + + it("refuses an unregistered redirect_uri instead of redirecting to it", async () => { + setPepper() + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })) + + const response = await postAuthorize(authorizeForm({ + redirect_uri: "https://evil.example/callback", + })) + + // Must render an error page, never a 303 carrying the code. + expect(response.status).toBe(400) + expect(response.headers.get("location")).toBeNull() + await expect(response.text()).resolves.toContain("does not match a registered redirect URI") + }) + + it("refuses an unknown client_id", async () => { + setPepper() + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })) + + const response = await postAuthorize(authorizeForm({ client_id: "never-registered" })) + + expect(response.status).toBe(400) + expect(response.headers.get("location")).toBeNull() + await expect(response.text()).resolves.toContain("Unknown client_id") }) it("accepts a ChatGPT token request without redirect_uri and with resource", async () => { @@ -91,7 +185,7 @@ describe("developer API OAuth routes", () => { grant_type: "authorization_code", code: createCode(), code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", - client_id: "chatgpt-test-client", + client_id: testClientId, resource: `${origin}/api/developer/mcp`, }) @@ -120,21 +214,75 @@ describe("developer API OAuth routes", () => { grant_type: "authorization_code", code: createCode(), code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", - client_id: "chatgpt-test-client", - redirect_uri: "https://chatgpt.com/connector/oauth/callback-test", + client_id: testClientId, + redirect_uri: testRedirectUri, }), })) expect(response.status).toBe(200) }) + it("rejects a second exchange of the same authorization code", async () => { + setPepper() + const code = createCode() + const exchange = () => tokenPost(new NextRequest(`${origin}/api/developer/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + client_id: testClientId, + }), + })) + + await expect(exchange()).resolves.toMatchObject({ status: 200 }) + + const replay = await exchange() + expect(replay.status).toBe(400) + await expect(replay.json()).resolves.toMatchObject({ + error: "invalid_grant", + error_description: "Authorization code has already been redeemed", + }) + }) + + it("does not consume the code when PKCE verification fails", async () => { + setPepper() + const code = createCode() + + const failed = await tokenPost(new NextRequest(`${origin}/api/developer/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + code_verifier: "wrong-verifier", + client_id: testClientId, + }), + })) + expect(failed.status).toBe(400) + + // The legitimate client must still be able to redeem it. + const succeeded = await tokenPost(new NextRequest(`${origin}/api/developer/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + client_id: testClientId, + }), + })) + expect(succeeded.status).toBe(200) + }) + it("refreshes connector access tokens without requiring a new API key", async () => { setPepper() vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })) const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: createRefreshToken(), - client_id: "chatgpt-test-client", + client_id: testClientId, resource: `${origin}/api/developer/mcp`, }) @@ -164,7 +312,7 @@ describe("developer API OAuth routes", () => { body: JSON.stringify({ grant_type: "refresh_token", refresh_token: createRefreshToken(), - client_id: "chatgpt-test-client", + client_id: testClientId, }), })) From 3648f287f1658626cb48a70f44cb461dbf9d4de8 Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:37:38 +0200 Subject: [PATCH 7/8] Extend role-aware RLS to the remaining role-blind tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the work started in 20260912140000. Eleven more baseline tables had policies that resolved workspace membership but not role, so any member — viewer included — could bypass the application through PostgREST and write content, automations, inbox rows, brand profiles, and third-party service credentials. Write roles are taken from the permission each table's own routes already enforce via requireWorkspacePermission, per WORKSPACE_PERMISSION_ROLE_MAP: content:write (owner/admin/editor) - posts, comments, conversations, messages automation:write (owner/admin) - automations, automation_logs, automation_scheduled_executions, processed_comments settings:write (owner/admin) - external_services, workspace_brand_profiles analytics:sync (owner/admin) - analytics_snapshots Reads are unchanged: every member keeps SELECT everywhere. Service-role callers bypass RLS, and the existing "Service role has full access" policies are left alone — each is gated by an auth.jwt() role check in its USING clause, so it never widens access for a normal user. Worker-role policies are untouched. automation_logs and automation_scheduled_executions carry no workspace_id, so both are scoped through their parent automation, matching the shape of the policies they replace. This was caught by checking the live schema rather than assuming the column existed. Not changed: workspaces, workspace_members, workspace_invites and publishing_automations already constrain role in their own policies, and chat_sessions is scoped per user rather than by workspace role. 570/570 tests pass. Not yet applied. --- ...160000_role_aware_rls_remaining_tables.sql | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 supabase/migrations/20260912160000_role_aware_rls_remaining_tables.sql diff --git a/supabase/migrations/20260912160000_role_aware_rls_remaining_tables.sql b/supabase/migrations/20260912160000_role_aware_rls_remaining_tables.sql new file mode 100644 index 00000000..ddfcf8bf --- /dev/null +++ b/supabase/migrations/20260912160000_role_aware_rls_remaining_tables.sql @@ -0,0 +1,246 @@ +-- Extends the role-aware RLS model from +-- 20260912140000_role_aware_rls_settings_and_social_accounts.sql to the +-- remaining baseline tables whose policies resolve membership but not role. +-- +-- Each table's write roles are taken from the permission its own routes +-- already enforce via requireWorkspacePermission, per +-- WORKSPACE_PERMISSION_ROLE_MAP in lib/workspace-rbac.ts: +-- +-- content:write -> owner, admin, editor +-- automation:write -> owner, admin +-- settings:write -> owner, admin +-- analytics:sync -> owner, admin +-- +-- | table | permission | enforced by | +-- |---------------------------------|------------------|------------------------------------------| +-- | posts | content:write | (service-role writes only today) | +-- | comments | content:write | app/api/posts-media/comments/route.ts | +-- | conversations, messages | content:write | app/api/messages/route.ts | +-- | automations | automation:write | app/api/automations/* | +-- | automation_logs | automation:write | (service-role writes only today) | +-- | automation_scheduled_executions | automation:write | (service-role writes only today) | +-- | processed_comments | automation:write | app/api/automations/route.ts | +-- | external_services | settings:write | app/api/external-services/* | +-- | workspace_brand_profiles | settings:write | app/api/brand-profile/route.ts | +-- | analytics_snapshots | analytics:sync | app/api/sync-analytics/route.ts | +-- +-- Reads are unchanged everywhere: every member, viewer included, keeps SELECT. +-- Service-role callers bypass RLS entirely, and the existing +-- "Service role has full access" policies are left alone — they are scoped by +-- an auth.jwt() role check in their USING clause, not by role grant, so they +-- never widen access for a normal user. +-- +-- Worker-role policies (swiftflow_action_executor, swiftflow_webhook_comparison) +-- are likewise untouched. +-- +-- Tables deliberately NOT changed here: +-- workspaces, workspace_members, workspace_invites, publishing_automations +-- - already role-constrained (owner/admin) in their existing policies. +-- chat_sessions +-- - scoped per user (auth.uid() = user_id), not by workspace role. + +-- --------------------------------------------------------------------------- +-- posts : content:write +-- --------------------------------------------------------------------------- +drop policy if exists "Member access posts" on public.posts; + +create policy "posts_member_select" on public.posts + for select using (public.is_member_of(workspace_id)); +create policy "posts_editor_insert" on public.posts + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "posts_editor_update" on public.posts + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "posts_editor_delete" on public.posts + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); + +-- --------------------------------------------------------------------------- +-- analytics_snapshots : analytics:sync +-- --------------------------------------------------------------------------- +drop policy if exists "Member access analytics" on public.analytics_snapshots; + +create policy "analytics_snapshots_member_select" on public.analytics_snapshots + for select using (public.is_member_of(workspace_id)); +create policy "analytics_snapshots_admin_insert" on public.analytics_snapshots + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "analytics_snapshots_admin_update" on public.analytics_snapshots + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "analytics_snapshots_admin_delete" on public.analytics_snapshots + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +-- --------------------------------------------------------------------------- +-- automations : automation:write +-- The blanket ALL policy is dropped; the existing member SELECT policy and the +-- two worker SELECT policies are kept. +-- --------------------------------------------------------------------------- +drop policy if exists "workspace_automations_policy" on public.automations; +drop policy if exists "Users can create automations in their workspaces" on public.automations; +drop policy if exists "Users can update automations in their workspaces" on public.automations; +drop policy if exists "Users can delete automations in their workspaces" on public.automations; + +create policy "automations_admin_insert" on public.automations + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "automations_admin_update" on public.automations + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "automations_admin_delete" on public.automations + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +-- --------------------------------------------------------------------------- +-- automation_logs : automation:write (scoped through the parent automation) +-- --------------------------------------------------------------------------- +drop policy if exists "Users can create automation logs" on public.automation_logs; +drop policy if exists "Users can update automation logs" on public.automation_logs; + +create policy "automation_logs_admin_insert" on public.automation_logs + for insert to authenticated + with check (exists ( + select 1 from public.automations a + where a.id = automation_logs.automation_id + and public.has_workspace_role(a.workspace_id, array['owner', 'admin']) + )); +create policy "automation_logs_admin_update" on public.automation_logs + for update to authenticated + using (exists ( + select 1 from public.automations a + where a.id = automation_logs.automation_id + and public.has_workspace_role(a.workspace_id, array['owner', 'admin']) + )); + +-- --------------------------------------------------------------------------- +-- automation_scheduled_executions : automation:write +-- --------------------------------------------------------------------------- +drop policy if exists "Users can create scheduled executions in their workspaces" on public.automation_scheduled_executions; +drop policy if exists "Users can update scheduled executions in their workspaces" on public.automation_scheduled_executions; +drop policy if exists "Users can delete scheduled executions in their workspaces" on public.automation_scheduled_executions; + +-- This table carries no workspace_id; it is scoped through its parent +-- automation, the same way automation_logs is. +create policy "automation_scheduled_executions_admin_insert" on public.automation_scheduled_executions + for insert to authenticated + with check (exists ( + select 1 from public.automations a + where a.id = automation_scheduled_executions.automation_id + and public.has_workspace_role(a.workspace_id, array['owner', 'admin']) + )); +create policy "automation_scheduled_executions_admin_update" on public.automation_scheduled_executions + for update to authenticated + using (exists ( + select 1 from public.automations a + where a.id = automation_scheduled_executions.automation_id + and public.has_workspace_role(a.workspace_id, array['owner', 'admin']) + )); +create policy "automation_scheduled_executions_admin_delete" on public.automation_scheduled_executions + for delete to authenticated + using (exists ( + select 1 from public.automations a + where a.id = automation_scheduled_executions.automation_id + and public.has_workspace_role(a.workspace_id, array['owner', 'admin']) + )); + +-- --------------------------------------------------------------------------- +-- processed_comments : automation:write (INSERT only; no user UPDATE/DELETE existed) +-- --------------------------------------------------------------------------- +drop policy if exists "Users can create processed comments in their workspaces" on public.processed_comments; + +create policy "processed_comments_admin_insert" on public.processed_comments + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +-- --------------------------------------------------------------------------- +-- comments : content:write +-- --------------------------------------------------------------------------- +drop policy if exists "Users can insert comments in their workspace" on public.comments; +drop policy if exists "Users can update comments in their workspace" on public.comments; +drop policy if exists "Users can delete comments in their workspace" on public.comments; + +create policy "comments_editor_insert" on public.comments + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "comments_editor_update" on public.comments + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "comments_editor_delete" on public.comments + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); + +-- --------------------------------------------------------------------------- +-- conversations : content:write +-- --------------------------------------------------------------------------- +drop policy if exists "Users can insert conversations in their workspace" on public.conversations; +drop policy if exists "Users can update conversations in their workspace" on public.conversations; +drop policy if exists "Users can delete conversations in their workspace" on public.conversations; + +create policy "conversations_editor_insert" on public.conversations + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "conversations_editor_update" on public.conversations + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "conversations_editor_delete" on public.conversations + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); + +-- --------------------------------------------------------------------------- +-- messages : content:write +-- --------------------------------------------------------------------------- +drop policy if exists "Users can insert messages in their workspace" on public.messages; +drop policy if exists "Users can update messages in their workspace" on public.messages; +drop policy if exists "Users can delete messages in their workspace" on public.messages; + +create policy "messages_editor_insert" on public.messages + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "messages_editor_update" on public.messages + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); +create policy "messages_editor_delete" on public.messages + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin', 'editor'])); + +-- --------------------------------------------------------------------------- +-- external_services : settings:write +-- This table stores encrypted third-party credentials (api_key, password), so +-- the reveal endpoint already requires settings:write. Writes now match. +-- --------------------------------------------------------------------------- +drop policy if exists "Users can create external services in their workspaces" on public.external_services; +drop policy if exists "Users can update external services in their workspaces" on public.external_services; +drop policy if exists "Users can delete external services in their workspaces" on public.external_services; + +create policy "external_services_admin_insert" on public.external_services + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "external_services_admin_update" on public.external_services + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "external_services_admin_delete" on public.external_services + for delete to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])); + +-- --------------------------------------------------------------------------- +-- workspace_brand_profiles : settings:write (no user DELETE policy existed) +-- --------------------------------------------------------------------------- +drop policy if exists "Users can create brand profiles for their workspaces" on public.workspace_brand_profiles; +drop policy if exists "Users can update brand profiles of their workspaces" on public.workspace_brand_profiles; + +create policy "workspace_brand_profiles_admin_insert" on public.workspace_brand_profiles + for insert to authenticated + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); +create policy "workspace_brand_profiles_admin_update" on public.workspace_brand_profiles + for update to authenticated + using (public.has_workspace_role(workspace_id, array['owner', 'admin'])) + with check (public.has_workspace_role(workspace_id, array['owner', 'admin'])); From 70a56672efbf4a32f6a669c87366579f4cf1b170 Mon Sep 17 00:00:00 2001 From: Ali Saad <59366979+aliihsaad@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:59:25 +0200 Subject: [PATCH 8/8] Bound pg_cron run-history growth and document the size reclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Supabase 0.5 GB warning is not application data. At 492 MB total, the public schema — all SwiftFlow data — was 15 MB (3%). The rest was byproducts of scheduler-tick-cron firing every minute: - cron.job_run_details: 289,479 real rows back to 2026-02-23 (195 MB). pg_cron has no built-in retention, so this grows unbounded at ~1440 rows/day. - net._http_response: only 360 live rows over ~6 hours (pg_net expires its own rows) but 271 MB of heap with 0 dead tuples — bloat that will never shrink on its own. This migration schedules purge-cron-run-history nightly to trim cron.job_run_details to 7 days, which stops the growth. It is idempotent: the job is unscheduled first so a re-run or db reset cannot duplicate it. Reclaiming the space already on disk is deliberately NOT in the migration — TRUNCATE is destructive and does not belong in schema history. It is documented in docs/operations/database-size-reclaim.md with the reasoning for why each truncate is safe. VACUUM FULL is not an option: both tables are owned by supabase_admin rather than postgres. TRUNCATE is, and returns the space immediately. Worth recording: the existing retention-cleanup edge function and workspace_retention_policies target public-schema tables totalling ~15 MB, so enabling them would not have addressed this warning. --- docs/operations/database-size-reclaim.md | 115 ++++++++++++++++++ .../20260912170000_prune_cron_run_history.sql | 45 +++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/operations/database-size-reclaim.md create mode 100644 supabase/migrations/20260912170000_prune_cron_run_history.sql diff --git a/docs/operations/database-size-reclaim.md b/docs/operations/database-size-reclaim.md new file mode 100644 index 00000000..c9e586a8 --- /dev/null +++ b/docs/operations/database-size-reclaim.md @@ -0,0 +1,115 @@ +# Reclaiming database size + +One-time runbook for the Supabase warning: + +> You have projects that are exceeding 0.5 GB of database size. + +## Diagnosis (2026-09-12) + +Total database: **492 MB**. Almost none of it was application data. + +| Schema | Size | Share | What it is | +|---|---:|---:|---| +| `net` | 272 MB | 55% | pg_net HTTP response log | +| `cron` | 202 MB | 41% | pg_cron job run history | +| `pg_catalog` | 18 MB | 4% | system catalogs | +| **`public`** | **15 MB** | **3%** | **all SwiftFlow application data** | + +Both large tables are byproducts of `scheduler-tick-cron`, which fires every +minute and therefore writes ~1,440 rows/day to each. + +They are two different problems: + +- **`cron.job_run_details` — 289,479 real rows** spanning 2026-02-23 to now. + pg_cron has no built-in retention, so this is genuine unbounded accumulation. +- **`net._http_response` — only 360 live rows** covering the last ~6 hours + (pg_net expires its own rows), but **271 MB of heap with 0 dead tuples**. + That is bloat: space freed by past deletes that was never returned to the OS. + It will not keep growing much, but it will never shrink on its own either. + +### What this is NOT + +The `retention-cleanup` edge function and `workspace_retention_policies` target +`oauth_page_sessions`, `workspace_invites`, `webhook_events` and similar +**public-schema** tables. Those total ~15 MB. Enabling retention cleanup is +worth doing on its own merits, but it would reclaim about 3% here and would not +resolve the warning. + +## Prevention (already in the repo) + +`supabase/migrations/20260912170000_prune_cron_run_history.sql` schedules +`purge-cron-run-history`, which trims `cron.job_run_details` to 7 days nightly +at 03:17 UTC. Apply it with `npx supabase db push --linked`. + +That stops future growth. It does not reclaim what is already on disk, because +`DELETE` marks space reusable rather than returning it — and `VACUUM FULL` is +not available here: both tables are owned by `supabase_admin`, not `postgres`. + +## One-time reclaim + +`postgres` holds TRUNCATE on both tables, and TRUNCATE returns the space +immediately without needing table ownership. Run in the Supabase dashboard SQL +editor. + +### 1. pg_net response log — reclaims ~271 MB + +```sql +truncate net._http_response; +``` + +Safe because: the table is `UNLOGGED`, pg_net already expires rows on a ~6 hour +TTL, and nothing in this codebase reads it. The `scheduler-tick-cron` command +calls `net.http_post(...)` without reading the response back, so discarding +responses cannot affect scheduling. Worst case is losing the response record of +a request in flight at that instant, which nothing consumes. + +### 2. pg_cron run history — reclaims ~195 MB + +Simplest, and what is recommended: + +```sql +truncate cron.job_run_details; +``` + +Safe because: pg_cron never reads this table to decide anything — it is a log. +No application code queries it. Scheduling, job definitions and the +`scheduler-tick` heartbeat are stored in `cron.job`, which this does not touch. + +If you would rather keep recent history, this variant preserves the last two +days. It is slightly more involved and briefly races the every-minute tick +(harmless — at worst one run's row is re-inserted or missed): + +```sql +create temp table cron_history_keep as + select * from cron.job_run_details + where end_time > now() - interval '2 days'; + +truncate cron.job_run_details; + +insert into cron.job_run_details select * from cron_history_keep; +drop table cron_history_keep; +``` + +### 3. Verify + +```sql +select pg_size_pretty(pg_database_size(current_database())) as total_db_size; + +select n.nspname as schema, pg_size_pretty(sum(pg_total_relation_size(c.oid))) as size +from pg_class c join pg_namespace n on n.oid = c.relnamespace +where c.relkind in ('r','m','i') +group by n.nspname +order by sum(pg_total_relation_size(c.oid)) desc +limit 6; +``` + +Expect the total to drop from ~492 MB to well under 50 MB. + +## If it grows back + +The nightly purge bounds `cron.job_run_details`. If `net._http_response` +bloats again over months, re-run the truncate in step 1 — it is safe to repeat. + +The root driver is the every-minute tick. Reducing that frequency would cut +both logs proportionally, but it directly increases automation latency for +delay-node resumes, so it is not recommended as a size fix. diff --git a/supabase/migrations/20260912170000_prune_cron_run_history.sql b/supabase/migrations/20260912170000_prune_cron_run_history.sql new file mode 100644 index 00000000..050c5e5e --- /dev/null +++ b/supabase/migrations/20260912170000_prune_cron_run_history.sql @@ -0,0 +1,45 @@ +-- Bounds the growth of pg_cron's run history. +-- +-- WHY +-- The scheduler-tick-cron job runs every minute, and pg_cron records every +-- execution in cron.job_run_details forever — it has no built-in retention. +-- By 2026-09-12 that table held 289,479 rows (195 MB) going back to +-- 2026-02-23, which together with pg_net's response log accounted for 96% of +-- a 492 MB database whose application data (the whole public schema) was only +-- 15 MB. +-- +-- This migration only stops the growth. Reclaiming the space already consumed +-- is a one-time operation documented in +-- docs/operations/database-size-reclaim.md, because TRUNCATE is destructive +-- and does not belong in a schema migration. +-- +-- SAFETY +-- cron.job_run_details is pure operational log: pg_cron does not read it back +-- to decide anything, and nothing in this codebase queries it. Deleting old +-- rows cannot affect job scheduling or automation behaviour. Seven days is +-- kept so a failed overnight run is still diagnosable. + +do $$ +begin + -- Idempotent: unschedule first so re-running this migration (or a + -- db reset) does not create a duplicate job. + perform cron.unschedule('purge-cron-run-history'); +exception + when others then + -- unschedule raises if the job does not exist; that is the normal path on + -- a first run. + null; +end; +$$; + +select cron.schedule( + 'purge-cron-run-history', + '17 3 * * *', + $job$ + delete from cron.job_run_details + where end_time < now() - interval '7 days' + $job$ +); + +comment on extension pg_cron is + 'Scheduled jobs. purge-cron-run-history trims cron.job_run_details to 7 days nightly; without it the table grows unbounded at ~1440 rows/day from scheduler-tick-cron.';