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/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 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/lib/developer-api/oauth-clients.ts b/lib/developer-api/oauth-clients.ts new file mode 100644 index 00000000..fcfd8d3a --- /dev/null +++ b/lib/developer-api/oauth-clients.ts @@ -0,0 +1,101 @@ +import "server-only" + +import { createAdminClient } from "@/utils/supabase/admin" + +/** + * Persistence for the Developer API OAuth connector: the registered-client + * allowlist that /authorize matches redirect_uri against, and the redeemed + * authorization codes that make a code single-use. + * + * Both tables are service_role only, so every call here goes through the admin + * client. These routes are unauthenticated by design (OAuth endpoints), which + * is exactly why the redirect_uri allowlist matters. + */ + +export interface DeveloperOAuthClient { + clientId: string + clientName: string | null + redirectUris: string[] + scope: string | null +} + +/** A redirect_uri is only usable if it is HTTPS and carries no fragment. */ +export function isAcceptableRedirectUri(value: unknown): value is string { + if (typeof value !== "string" || !value) return false + try { + const url = new URL(value) + return url.protocol === "https:" && !url.hash + } catch { + return false + } +} + +export async function registerDeveloperOAuthClient(input: { + clientId: string + clientName: string | null + redirectUris: string[] + scope: string | null +}): 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/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" } } 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", 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; 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; 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'])); 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/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'])); 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.'; 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, }), }))