From b3647ecccbb54ad5809ce78a64c75bea3c577fc9 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 10 Dec 2025 19:12:59 +0300 Subject: [PATCH 01/22] Add @astro/db package and manually configure --- .vscode/settings.json | 1 + _TODO.md | 4 - astro.config.ts | 3 + db/config.ts | 6 + db/seed.ts | 6 + package-lock.json | 420 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 7 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 db/config.ts create mode 100644 db/seed.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index fd674104a..ccbfb233f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -112,6 +112,7 @@ "tsdoc", "tseslint", "tshirt", + "Tursa", "Turso", "tydata", "unspaced", diff --git a/_TODO.md b/_TODO.md index 078cbddc0..0711c7630 100644 --- a/_TODO.md +++ b/_TODO.md @@ -123,10 +123,6 @@ fatal: /home/kevin/Repos/Webstack: '/home/kevin/Repos/Webstack' is outside repos Can we add a markdown page to explain what each tag is, and use those pages instead of src/content/_tagList.ts to define the tags available? -## Social Media Preview Cards - -Looking at the social-card endpoint implementation, it's designed to work with third-party screenshot services, not the social networks themselves. - ## Use Confetti on CTA forms `canvas-confetti` diff --git a/astro.config.ts b/astro.config.ts index ee5f3de6c..8d9cba981 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,4 +1,5 @@ import AstroPWA from '@vite-pwa/astro' +import db from '@astrojs/db' import icon from 'astro-icon' import linkValidator from 'astro-link-validator' import lit from '@semantic-ui/astro-lit' @@ -47,6 +48,8 @@ const sharedTestIntegrations = [ const standardIntegrations = [ AstroPWA(pwaConfig), + /** Astro DB - uses Tursa for backing store in production */ + db(), ...sharedTestIntegrations, mdx(markdownConfig), /** Generate favicons and PWA icons from source SVG */ diff --git a/db/config.ts b/db/config.ts new file mode 100644 index 000000000..b5a05eb95 --- /dev/null +++ b/db/config.ts @@ -0,0 +1,6 @@ +import { defineDb } from 'astro:db' + +// https://astro.build/db/config +export default defineDb({ + tables: {} +}) diff --git a/db/seed.ts b/db/seed.ts new file mode 100644 index 000000000..fffc464b0 --- /dev/null +++ b/db/seed.ts @@ -0,0 +1,6 @@ +import { db } from 'astro:db'; + +// https://astro.build/db/seed +export default async function seed() { + // TODO +} diff --git a/package-lock.json b/package-lock.json index aeb0b4eb8..74d1b0725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@astrojs/check": "0.9.6", + "@astrojs/db": "^0.18.3", "@astrojs/mdx": "4.3.12", "@astrojs/preact": "4.1.3", "@astrojs/rss": "4.0.14", @@ -265,6 +266,49 @@ "license": "MIT", "peer": true }, + "node_modules/@astrojs/db": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@astrojs/db/-/db-0.18.3.tgz", + "integrity": "sha512-iTK50jUgyj25oa/JiXSN1/IVp5kTmPuioLlve06LE8/HzWGv3JpVgCKIV9HHf3kOVi1HV/uauXnyWzkB+yHLSQ==", + "license": "MIT", + "dependencies": { + "@libsql/client": "^0.15.15", + "deep-diff": "^1.0.2", + "drizzle-orm": "^0.42.0", + "nanoid": "^5.1.6", + "piccolore": "^0.1.3", + "prompts": "^2.4.2", + "yargs-parser": "^21.1.1", + "zod": "^3.25.76" + } + }, + "node_modules/@astrojs/db/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@astrojs/db/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@astrojs/internal-helpers": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", @@ -4709,6 +4753,195 @@ "dev": true, "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", + "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@libsql/core": "^0.15.14", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.22", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.15.15.tgz", + "integrity": "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.22.tgz", + "integrity": "sha512-4B8ZlX3nIDPndfct7GNe0nI3Yw6ibocEicWdC4fvQbSs/jdq/RC2oCsoJxJ4NzXkvktX70C1J4FcmmoBy069UA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.22.tgz", + "integrity": "sha512-ny2HYWt6lFSIdNFzUFIJ04uiW6finXfMNJ7wypkAD8Pqdm6nAByO+Fdqu8t7sD0sqJGeUCiOg480icjyQ2/8VA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@libsql/hrana-client/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.22.tgz", + "integrity": "sha512-3Uo3SoDPJe/zBnyZKosziRGtszXaEtv57raWrZIahtQDsjxBVjuzYQinCm9LRCJCUT5t2r5Z5nLDPJi2CwZVoA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.22.tgz", + "integrity": "sha512-LCsXh07jvSojTNJptT9CowOzwITznD+YFGGW+1XxUr7fS+7/ydUrpDfsMX7UqTqjm7xG17eq86VkWJgHJfvpNg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.22.tgz", + "integrity": "sha512-KSdnOMy88c9mpOFKUEzPskSaF3VLflfSUCBwas/pn1/sV3pEhtMF6H8VUCd2rsedwoukeeCSEONqX7LLnQwRMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.22.tgz", + "integrity": "sha512-mCHSMAsDTLK5YH//lcV3eFEgiR23Ym0U9oEvgZA0667gqRZg/2px+7LshDvErEKv2XZ8ixzw3p1IrBzLQHGSsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.22.tgz", + "integrity": "sha512-kNBHaIkSg78Y4BqAdgjcR2mBilZXs4HYkAmi58J+4GRwDQZh5fIUWbnQvB9f95DkWUIGVeenqLRFY2pcTmlsew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.22.tgz", + "integrity": "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.22.tgz", + "integrity": "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@lit-labs/ssr": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.3.1.tgz", @@ -4914,6 +5147,12 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8024,6 +8263,7 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -11520,6 +11760,12 @@ } } }, + "node_modules/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "license": "MIT" + }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -11864,6 +12110,127 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-orm": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.42.0.tgz", + "integrity": "sha512-pS8nNJm2kBNZwrOjTHJfdKkaU+KuUQmV/vk5D57NojDq4FG+0uAYGMulXtYT///HfgsMF0hnFFvu1ezI3OwOkg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -15521,6 +15888,12 @@ "integrity": "sha512-CiRVjMKBUp6VYtGwzRjrdnro41yMwKGOWdP9ATXqmixdz2n7KHNwdqthTYSSbOKj/Ha79Gct1sA8ZQpse55TYA==", "license": "BSD-3-Clause" }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -15907,6 +16280,47 @@ "integrity": "sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==", "license": "MIT" }, + "node_modules/libsql": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz", + "integrity": "sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.22", + "@libsql/darwin-x64": "0.5.22", + "@libsql/linux-arm-gnueabihf": "0.5.22", + "@libsql/linux-arm-musleabihf": "0.5.22", + "@libsql/linux-arm64-gnu": "0.5.22", + "@libsql/linux-arm64-musl": "0.5.22", + "@libsql/linux-x64-gnu": "0.5.22", + "@libsql/linux-x64-musl": "0.5.22", + "@libsql/win32-x64-msvc": "0.5.22" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -18962,6 +19376,12 @@ "node": ">=0.4.0" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index ce84e727f..f73f55944 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ }, "dependencies": { "@astrojs/check": "0.9.6", + "@astrojs/db": "^0.18.3", "@astrojs/mdx": "4.3.12", "@astrojs/preact": "4.1.3", "@astrojs/rss": "4.0.14", From c890a6f64fb84b5888a85e12b0dee6c0e3b1c32d Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 10 Dec 2025 22:49:56 +0300 Subject: [PATCH 02/22] Update dependencies --- .vscode/settings.json | 1 + _TODO.md | 42 ++- package-lock.json | 632 +++++++++++++++++++----------------------- package.json | 22 +- 4 files changed, 338 insertions(+), 359 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ccbfb233f..67fbcbb99 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -90,6 +90,7 @@ "repost", "reposts", "RGAA", + "rodgtr", "Ryuk", "samp", "SCORM", diff --git a/_TODO.md b/_TODO.md index 0711c7630..7aeccb4c2 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,13 +1,47 @@ # TODO -## Fix E2E mock container test runner, migrate DB providers +[Astro DB](https://docs.astro.build/en/guides/astro-db/) -Refactor from using Suprabase and Upstash to using Astro DB + Turso +Turso Setup Guidance -[Astro DB](https://docs.astro.build/en/guides/astro-db/) +Wait until config.ts + seed.ts are committed; then create a Turso database named webstack-corporate-prod (or similar) via turso db create webstack-corporate-prod. + +Generate an app token (turso db tokens create webstack-corporate-prod -n astro-app) and store the ASTRO_DB_REMOTE_URL/ASTRO_DB_APP_TOKEN secrets. + +After the DB exists, run ASTRO_DB_REMOTE_URL=... ASTRO_DB_APP_TOKEN=... npx astro db push --remote from the project root to apply the schema. That command is when you'll also need to restart your dev server. + +Let me know if you'd like similar migrations for the other Supabase-backed endpoints or want the fallback flag renamed now that we're on Astro DB. + +No seed data was added for DSAR requests yet, but schema changes require astro db push (local + Turso) before the endpoint can execute against the new table. + +Run npx astro db push locally, then ASTRO_DB_REMOTE_URL=... ASTRO_DB_APP_TOKEN=... npx astro db push --remote so the new dsarRequests table exists remotely. + +We still have a handful of "real" Supabase/Upstash touchpoints under src: + +`suprabase.ts` plus its re-export in index.ts still create the Supabase admin client via `getSuprabaseApiUrl()` / `getSuprabaseApiServiceRoleKey()`. + +Anything that imports supabaseAdmin (below) rides on that helper, so those env getters/validators (`environmentApi.ts`, `environmentalVariableValidation.ts`)` also remain in play. + +API routes that still execute Supabase queries: + +- `export.ts` - reads consent_records directly from Supabase when packaging DSAR exports. + +- `cleanup-dsar-requests.ts` - deletes fulfilled/expired DSAR requests in Supabase. + +- `cleanup-confirmations.ts` - deletes expired/old newsletter_confirmations rows in Supabase. + +- `ping-integrations.ts` - the Supabase half of the keep-alive check still runs a select against newsletter_confirmations. + +Client-side module supabase.ts still instantiates a public Supabase client (it isn't imported anywhere else now, but the implementation is present). + +For Upstash, the only active usage under src is the same ping-integrations route: it fetches `getUpstashApiUrl()` / `getUpstashApiToken()` and performs a REST call against the Upstash proxy key. + +Those env helpers and the validation entries (`KV_REST_API_URL`, `KV_REST_API_TOKEN`, etc.) are therefore still part of the implementation. + +Outside these files, the remaining "supabase/upstash" mentions are in docs, tests, or tooling scripts only. If we plan to finish the Astro DB migration, those are the concrete spots left to refactor (and we can drop the unused client helper afterwards). -## Mobile Social Shares +## Mobile Social Shares UI See the example image in Social Shares. diff --git a/package-lock.json b/package-lock.json index 74d1b0725..929d5be01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@astrojs/check": "0.9.6", "@astrojs/db": "^0.18.3", - "@astrojs/mdx": "4.3.12", + "@astrojs/mdx": "4.3.13", "@astrojs/preact": "4.1.3", "@astrojs/rss": "4.0.14", "@astrojs/sitemap": "^3.6.0", @@ -24,7 +24,7 @@ "@sentry/astro": "^10.29.0", "@sentry/browser": "^10.29.0", "@shikijs/transformers": "^3.19.0", - "@supabase/supabase-js": "^2.86.2", + "@supabase/supabase-js": "^2.87.1", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.17", @@ -34,7 +34,7 @@ "@upstash/redis": "^1.35.7", "@vite-pwa/astro": "^1.2.0", "@webcomponents/template-shadowroot": "^0.2.1", - "astro": "5.16.4", + "astro": "5.16.5", "astro-icon": "^1.1.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.7.2", @@ -45,9 +45,9 @@ "embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "focus-trap": "7.6.6", - "gsap": "^3.13.0", + "gsap": "^3.14.1", "js-cookie": "^3.0.5", - "jsdom": "^27.2.0", + "jsdom": "^27.3.0", "libphonenumber-js": "1.12.31", "lit": "^3.3.1", "nanostores": "^1.1.0", @@ -61,7 +61,7 @@ "remark-breaks": "^4.0.0", "remark-emoji": "^5.0.2", "remark-linkify-regex": "^1.2.1", - "resend": "^6.5.2", + "resend": "^6.6.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "tailwindcss": "^4.1.17", @@ -89,14 +89,14 @@ "@types/glidejs__glide": "^3.6.6", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^27.0.0", - "@types/node": "^24.10.1", + "@types/node": "^25.0.0", "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.7", "@types/to-ico": "1.1.3", "@types/uuid": "^11.0.0", "@types/yargs": "17.0.35", - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", "@vitest/coverage-v8": "^4.0.15", "confusing-browser-globals": "1.0.11", "cross-spawn": "7.0.6", @@ -128,11 +128,11 @@ "stylelint-config-standard": "^39.0.1", "stylelint-declaration-block-no-ignored-properties": "2.8.0", "stylelint-order": "7.0.0", - "supabase": "^2.65.6", + "supabase": "^2.65.8", "temp-dir": "3.0.0", "tslib": "2.8.1", "typescript": "5.9.3", - "typescript-eslint": "8.48.1", + "typescript-eslint": "8.49.0", "unist-util-inspect": "^8.1.0", "unist-util-visit": "^5.0.0", "vitest": "4.0.15", @@ -147,9 +147,9 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.24", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", - "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", + "version": "0.9.28", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.28.tgz", + "integrity": "sha512-LuS6IVEivI75vKN8S04qRD+YySP0RmU/cV8UNukhQZvprxF+76Z43TNo/a08eCodaGhT1Us8etqS1ZRY9/Or0A==", "license": "MIT" }, "node_modules/@antfu/install-pkg": { @@ -205,32 +205,32 @@ } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", - "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" + "lru-cache": "^11.2.4" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -263,8 +263,7 @@ "version": "2.13.0", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@astrojs/db": { "version": "0.18.3", @@ -357,9 +356,9 @@ } }, "node_modules/@astrojs/markdown-remark": { - "version": "6.3.9", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.9.tgz", - "integrity": "sha512-hX2cLC/KW74Io1zIbn92kI482j9J7LleBLGCVU9EP3BeH5MVrnFawOnqD0t/q6D1Z+ZNeQG2gNKMslCcO36wng==", + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", + "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.7.5", @@ -368,7 +367,7 @@ "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -376,8 +375,8 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "shiki": "^3.13.0", - "smol-toml": "^1.4.2", + "shiki": "^3.19.0", + "smol-toml": "^1.5.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", @@ -386,12 +385,12 @@ } }, "node_modules/@astrojs/mdx": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.12.tgz", - "integrity": "sha512-pL3CVPtuQrPnDhWjy7zqbOibNyPaxP4VpQS8T8spwKqKzauJ4yoKyNkVTD8jrP7EAJHmBhZ7PTmUGZqOpKKp8g==", + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.13.tgz", + "integrity": "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==", "license": "MIT", "dependencies": { - "@astrojs/markdown-remark": "6.3.9", + "@astrojs/markdown-remark": "6.3.10", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.0", @@ -466,7 +465,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -639,7 +637,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -655,7 +653,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -668,7 +666,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -682,7 +680,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -696,7 +694,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -711,7 +709,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -721,7 +719,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -733,7 +731,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -746,7 +744,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -760,7 +758,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -876,7 +874,7 @@ "version": "3.932.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.932.0.tgz", "integrity": "sha512-AS8gypYQCbNojwgjvZGkJocC2CoEICDx9ZJ15ILsv+MlcCVLtUJSRSx3VzJOUY2EEIaGLRrPNlIqyn/9/fySvA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1027,9 +1025,8 @@ "version": "3.933.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.933.0.tgz", "integrity": "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", @@ -1047,7 +1044,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.930.0.tgz", "integrity": "sha512-x30jmm3TLu7b/b+67nMyoV0NlbnCVT5DI57yDrhXAPCtdgM1KtdLWt45UcHpKOm1JsaIkmYRh2WYu7Anx4MG0g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1063,7 +1060,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.930.0.tgz", "integrity": "sha512-vh4JBWzMCBW8wREvAwoSqB2geKsZwSHTa0nSt0OMOLp2PdTYIZDi0ZiVMmpfnjcx9XbS6aSluLv9sKx4RrG46A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1078,7 +1075,7 @@ "version": "3.933.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.933.0.tgz", "integrity": "sha512-qgrMlkVKzTCAdNw2A05DC2sPBo0KRQ7wk+lbYSRJnWVzcrceJhnmhoZVV5PFv7JtchK7sHVcfm9lcpiyd+XaCA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1121,7 +1118,7 @@ "version": "3.932.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.932.0.tgz", "integrity": "sha512-9BGTbJyA/4PTdwQWE9hAFIJGpsYkyEW20WON3i15aDqo5oRZwZmqaVageOD57YYqG8JDJjvcwKyDdR4cc38dvg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "3.932.0", @@ -1140,7 +1137,7 @@ "version": "3.933.0", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.933.0.tgz", "integrity": "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -1190,7 +1187,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.930.0.tgz", "integrity": "sha512-KL2JZqH6aYeQssu1g1KuWsReupdfOoxD6f1as2VC+rdwYFUu4LfzMsFfXnBvvQWWqQ7rZHWOw1T+o5gJmg7Dzw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1244,7 +1241,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.930.0.tgz", "integrity": "sha512-we/vaAgwlEFW7IeftmCLlLMw+6hFs3DzZPJw7lVHbj/5HJ0bz9gndxEsS2lQoeJ1zhiiLqAqvXxmM43s0MBg0A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -1271,7 +1268,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.930.0.tgz", "integrity": "sha512-M2oEKBzzNAYr136RRc6uqw3aWlwCxqTP1Lawps9E1d2abRPvl1p1ztQmmXp1Ak4rv8eByIZ+yQyKQ3zPdRG5dw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1288,7 +1285,7 @@ "version": "3.893.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1301,7 +1298,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.930.0.tgz", "integrity": "sha512-q6lCRm6UAe+e1LguM5E4EqM9brQlDem4XDcQ87NzEvlTW6GzmNCO0w1jS0XgCFXQHjDxjdlNFX+5sRbHijwklg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1314,7 +1311,7 @@ "version": "3.932.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.932.0.tgz", "integrity": "sha512-/kC6cscHrZL74TrZtgiIL5jJNbVsw9duGGPurmaVgoCbP7NnxyaSWEurbNV3VPNPhNE3bV3g4Ci+odq+AlsYQg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-user-agent": "3.932.0", @@ -1339,7 +1336,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -1354,7 +1351,7 @@ "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -1373,7 +1370,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.0.tgz", "integrity": "sha512-D1jAmAZQYMoPiacfgNf7AWhg3DFN3Wq/vQv3WINt9znwjzHp2x+WzdJFxxj7xZL7V1U79As6G8f7PorMYWBKsQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -1419,7 +1416,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2921,7 +2917,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3043,7 +3038,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3055,6 +3049,7 @@ "version": "1.0.19", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.19.tgz", "integrity": "sha512-QW5/SM2ARltEhoKcmRI1LoLf3/C7dHGswwCnfLcoMgqurBT4f8GvwXMgAbK/FwcxthmJRK5MGTtddj0yQn0J9g==", + "dev": true, "funding": [ { "type": "github", @@ -3085,7 +3080,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4758,7 +4752,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", @@ -5193,7 +5186,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5215,7 +5207,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -5228,7 +5219,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -5244,7 +5234,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -5632,7 +5621,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5649,7 +5637,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -5667,7 +5654,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -6675,54 +6661,54 @@ } }, "node_modules/@shikijs/core": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.15.0.tgz", - "integrity": "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.19.0.tgz", + "integrity": "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.15.0.tgz", - "integrity": "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.19.0.tgz", + "integrity": "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.3" + "oniguruma-to-es": "^4.3.4" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz", - "integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.19.0.tgz", + "integrity": "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz", - "integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.19.0.tgz", + "integrity": "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0" + "@shikijs/types": "3.19.0" } }, "node_modules/@shikijs/themes": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz", - "integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.19.0.tgz", + "integrity": "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0" + "@shikijs/types": "3.19.0" } }, "node_modules/@shikijs/transformers": { @@ -6735,19 +6721,7 @@ "@shikijs/types": "3.19.0" } }, - "node_modules/@shikijs/transformers/node_modules/@shikijs/core": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.19.0.tgz", - "integrity": "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.19.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@shikijs/transformers/node_modules/@shikijs/types": { + "node_modules/@shikijs/types": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.19.0.tgz", "integrity": "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==", @@ -6757,16 +6731,6 @@ "@types/hast": "^3.0.4" } }, - "node_modules/@shikijs/types": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz", - "integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", @@ -6802,7 +6766,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6816,7 +6780,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -6834,7 +6798,7 @@ "version": "3.18.4", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.4.tgz", "integrity": "sha512-o5tMqPZILBvvROfC8vC+dSVnWJl9a0u9ax1i1+Bq8515eYjUJqqk5XjjEsDLoeL5dSqGSh6WGdVx1eJ1E/Nwhw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.2.6", @@ -6856,7 +6820,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -6873,7 +6837,7 @@ "version": "5.3.6", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.5", @@ -6890,7 +6854,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6906,7 +6870,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6920,7 +6884,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6933,7 +6897,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.5", @@ -6948,7 +6912,7 @@ "version": "4.3.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.11.tgz", "integrity": "sha512-eJXq9VJzEer1W7EQh3HY2PDJdEcEUnv6sKuNt4eVjyeNWcQFS4KmnY+CKkYOIR6tSqarn6bjjCqg1UB+8UJiPQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.18.4", @@ -6968,7 +6932,7 @@ "version": "4.4.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.11.tgz", "integrity": "sha512-EL5OQHvFOKneJVRgzRW4lU7yidSwp/vRJOe542bHgExN3KNThr1rlg0iE4k4SnA+ohC+qlUxoK+smKeAYPzfAQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -6989,7 +6953,7 @@ "version": "4.2.6", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.5", @@ -7004,7 +6968,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7018,7 +6982,7 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.5", @@ -7034,7 +6998,7 @@ "version": "4.4.5", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.5", @@ -7051,7 +7015,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7065,7 +7029,7 @@ "version": "5.3.5", "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7079,7 +7043,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7094,7 +7058,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7108,7 +7072,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0" @@ -7121,7 +7085,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7135,7 +7099,7 @@ "version": "5.3.5", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", @@ -7155,7 +7119,7 @@ "version": "4.9.7", "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.7.tgz", "integrity": "sha512-pskaE4kg0P9xNQWihfqlTMyxyFR3CH6Sr6keHYghgyqqDXzjl2QJg5lAzuVe/LzZiOzcbcVtxKYi1/fZPt/3DA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.18.4", @@ -7174,7 +7138,7 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7187,7 +7151,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/querystring-parser": "^4.2.5", @@ -7202,7 +7166,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7217,7 +7181,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7230,7 +7194,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7243,7 +7207,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", @@ -7257,7 +7221,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7270,7 +7234,7 @@ "version": "4.3.10", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.10.tgz", "integrity": "sha512-3iA3JVO1VLrP21FsZZpMCeF93aqP3uIOMvymAT3qHIJz2YlgDeRvNUspFwCNqd/j3qqILQJGtsVQnJZICh/9YA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.5", @@ -7286,7 +7250,7 @@ "version": "4.2.13", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.13.tgz", "integrity": "sha512-PTc6IpnpSGASuzZAgyUtaVfOFpU0jBD2mcGwrgDuHf7PlFgt5TIPxCYBDbFQs06jxgeV3kd/d/sok1pzV0nJRg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.3", @@ -7305,7 +7269,7 @@ "version": "3.2.5", "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -7320,7 +7284,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7333,7 +7297,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7347,7 +7311,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.5", @@ -7362,7 +7326,7 @@ "version": "4.5.6", "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", @@ -7382,7 +7346,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7395,7 +7359,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7409,7 +7373,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7432,9 +7396,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.86.2", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.86.2.tgz", - "integrity": "sha512-7k8IAhgSnZuD9Zex2+ohHKY3aWGDd4ls0xlxMGl3/jPyHSSXrIYfmtJyUH0+DPd4B3psBqHC0Ev0/nZEHdW58w==", + "version": "2.87.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.87.1.tgz", + "integrity": "sha512-6RDeOf5TVoaXFtEstN188ykp3pXLZaU9qoAWfx8dc50FFAAqt+kcFJ96V0IvSmcpb4mDAWcpTJ7BegmVDn/WIw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -7444,9 +7408,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.86.2", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.86.2.tgz", - "integrity": "sha512-OLpy3NIlj7q3yGMFwUpPkDPJbRx4aU+u73SiXqiMnA5ARwzVcOReSzI2u4oOqioE+3ud0fRx7sRsfoklBwYOmg==", + "version": "2.87.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.87.1.tgz", + "integrity": "sha512-rWmYo4gRD0XAjMhYDlz7IH67bp4TIQ1UE4VqwIQtl1gGPwtLDq6wcRnu7jLKlXx0Gtrknw/eoiHYG9//XrCTzQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -7456,9 +7420,9 @@ } }, "node_modules/@supabase/postgrest-js": { - "version": "2.86.2", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.86.2.tgz", - "integrity": "sha512-KVgOF2QASvUfQnzMGAmxR7f3ZF/eZ8PFp2F5Q7SAPQlmB83FEaZ7C/QMzfVXXqkMbotfh96xcaBNSKnxowFObA==", + "version": "2.87.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.87.1.tgz", + "integrity": "sha512-Yzu5eL3iGmZW0C/8x+vEojAOou63FI9oVw8HI8YOq63+5yM8g8aGh7Y1E2vbXFb7+gHGsPqLnaC6dPhrYt7qBA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -7468,9 +7432,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.86.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.86.2.tgz", - "integrity": "sha512-uLUYrOMeK1qXHISxdMFVfBs0sGV5PmqYewIHvLBnMYbb//LERojxfKlVSJBgZ+aAwxANmtQKcprjGZI7DJ6lNQ==", + "version": "2.87.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.87.1.tgz", + "integrity": "sha512-XvLtEznxmYZXA7LYuy5zbSXpSYjDLJq2wQeRh3MzON2OR4U8Kq+RtPz2E2Wi8HEzvBfsc+nNu1TG8LQ9+3DRkA==", "license": "MIT", "dependencies": { "@types/phoenix": "^1.6.6", @@ -7483,12 +7447,12 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.86.2", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.86.2.tgz", - "integrity": "sha512-zyR4PkO7R4f4/xRBVJho3Dm7y4512BoCqGmD7LjNV2GVtWt8vEmambiuMB2Ty3l76mqw+ynQyHY8yFWSERrHXA==", + "version": "2.87.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.87.1.tgz", + "integrity": "sha512-0Uc8tNV4yzkNNmp1inpXru0RB4a7ECq05G2S6BDvSpMxTxJrDVJ4vVDwyhqB8ZZ+O9+8prHaQYoByQeuDnwpFQ==", "license": "MIT", "dependencies": { - "iceberg-js": "^0.8.0", + "iceberg-js": "^0.8.1", "tslib": "2.8.1" }, "engines": { @@ -7496,16 +7460,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.86.2", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.86.2.tgz", - "integrity": "sha512-KXoiqFf7zZhL/+lj7oBFFUvVDQ6gy03v9wQ5E++f7xiJUuqmI4DuBhrv8uFo6B2EGTQTA3vkXjbxmYIug/zfWw==", + "version": "2.87.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.87.1.tgz", + "integrity": "sha512-tVgqZqnHZVum584KuUKSQZgcy6ZkhVd6gG8QWg2QfIXH9HmXdamauxdVsLXwaNPJxEdOyfAfwIyi5XUsiVYWtg==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.86.2", - "@supabase/functions-js": "2.86.2", - "@supabase/postgrest-js": "2.86.2", - "@supabase/realtime-js": "2.86.2", - "@supabase/storage-js": "2.86.2" + "@supabase/auth-js": "2.87.1", + "@supabase/functions-js": "2.87.1", + "@supabase/postgrest-js": "2.87.1", + "@supabase/realtime-js": "2.87.1", + "@supabase/storage-js": "2.87.1" }, "engines": { "node": ">=20.0.0" @@ -7891,7 +7855,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -8238,11 +8201,10 @@ } }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.0.tgz", + "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -8263,7 +8225,6 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8280,9 +8241,9 @@ } }, "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", "license": "MIT" }, "node_modules/@types/pubsub-js": { @@ -8419,18 +8380,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -8443,7 +8403,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -8459,17 +8419,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -8485,14 +8444,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -8507,14 +8466,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8525,9 +8484,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -8542,15 +8501,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -8567,9 +8526,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -8581,16 +8540,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -8622,16 +8581,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8646,13 +8605,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -8980,7 +8939,6 @@ "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.7.tgz", "integrity": "sha512-bdCdKhke+kYUjcLLuGWSeQw7OLuWIx3eyKksyToLBAlGIMX9qiII0ptp8E0y7VFE1yuBxBd/3kSzJ8774Q4g+A==", "license": "MIT", - "peer": true, "dependencies": { "uncrypto": "^0.1.3" } @@ -9507,8 +9465,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@webcomponents/template-shadowroot/-/template-shadowroot-0.2.1.tgz", "integrity": "sha512-fXL/vIUakyZL62hyvUh+EMwbVoTc0hksublmRz6ai6et8znHkJa6gtqMUZo1oc7dIz46exHSIImml9QTdknMHg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@webgpu/types": { "version": "0.1.21", @@ -9530,7 +9487,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9915,15 +9871,14 @@ } }, "node_modules/astro": { - "version": "5.16.4", - "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.4.tgz", - "integrity": "sha512-rgXI/8/tnO3Y9tfAaUyg/8beKhlIMltbiC8Q6jCoAfEidOyaue4KYKzbe0gJIb6qEdEaG3Kf3BY3EOSLkbWOLg==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.5.tgz", + "integrity": "sha512-QeuM4xzTR0QuXFDNlGVW0BW7rcquKFIkylaPeM4ufii0/RRiPTYtwxDYVZ3KfiMRuuc+nbLD0214kMKTvz/yvQ==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", - "@astrojs/markdown-remark": "6.3.9", + "@astrojs/markdown-remark": "6.3.10", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^3.0.1", "@oslojs/encoding": "^1.1.0", @@ -10383,7 +10338,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10705,7 +10659,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/boxen": { @@ -10779,7 +10733,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -11563,19 +11516,41 @@ } }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.4.tgz", + "integrity": "sha512-KyOS/kJMEq5O9GdPnaf82noigg5X5DYn0kZPJTaAsCUaBizp6Xa1y9D4Qoqf/JazEXWuruErHgVXwjN5391ZJw==", "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "@asamuzakjp/css-color": "^4.1.0", + "@csstools/css-syntax-patches-for-csstree": "1.0.14", "css-tree": "^3.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/cssstyle/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -12303,8 +12278,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -12697,7 +12671,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12950,7 +12923,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -14285,17 +14257,10 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/gsap": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", - "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.1.tgz", + "integrity": "sha512-nOFRJpVu+U0IPgKzfC5MEdZrJ1iVidGaOuxK0+Un+7vMOHa5rw/SKOaMaRRyHZiJKaeAEALTBwnj6R3gifyGBw==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/h3": { @@ -15877,7 +15842,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -15938,14 +15902,14 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", @@ -16326,7 +16290,6 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16583,7 +16546,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -18111,7 +18073,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -18490,9 +18451,9 @@ "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz", - "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", "license": "MIT", "dependencies": { "oniguruma-parser": "^0.12.1", @@ -19062,8 +19023,8 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -19108,7 +19069,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19255,7 +19215,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -19285,7 +19244,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19300,9 +19258,8 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz", "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", @@ -19697,9 +19654,9 @@ } }, "node_modules/regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", - "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -20212,9 +20169,9 @@ "license": "MIT" }, "node_modules/resend": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.5.2.tgz", - "integrity": "sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz", + "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==", "license": "MIT", "dependencies": { "svix": "1.76.1" @@ -20562,7 +20519,7 @@ "version": "0.0.15", "resolved": "https://registry.npmjs.org/s.color/-/s.color-0.0.15.tgz", "integrity": "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/safe-array-concat": { @@ -20657,7 +20614,7 @@ "version": "0.7.9", "resolved": "https://registry.npmjs.org/sass-formatter/-/sass-formatter-0.7.9.tgz", "integrity": "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "suf-log": "^2.5.3" @@ -20829,17 +20786,17 @@ } }, "node_modules/shiki": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz", - "integrity": "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.19.0.tgz", + "integrity": "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.15.0", - "@shikijs/engine-javascript": "3.15.0", - "@shikijs/engine-oniguruma": "3.15.0", - "@shikijs/langs": "3.15.0", - "@shikijs/themes": "3.15.0", - "@shikijs/types": "3.15.0", + "@shikijs/core": "3.19.0", + "@shikijs/engine-javascript": "3.19.0", + "@shikijs/engine-oniguruma": "3.19.0", + "@shikijs/langs": "3.19.0", + "@shikijs/themes": "3.19.0", + "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } @@ -21539,7 +21496,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -21769,7 +21725,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -21810,16 +21765,16 @@ "version": "2.5.3", "resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz", "integrity": "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "s.color": "0.0.15" } }, "node_modules/supabase": { - "version": "2.65.6", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.65.6.tgz", - "integrity": "sha512-PKeKFwIpx/H65WZ8BVqvQU1cve2n2Er++Yo0EGjSfV/vNwnLkpdnlpD9o8ZJJLDF/hxlduOCwxHiTnBimnbXdA==", + "version": "2.65.8", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.65.8.tgz", + "integrity": "sha512-ZEk9Mq1iambcg+GX1JT258eR2LpoNF2a6a6h1VZEPFNNSB54Yg7Z9bd8WlUM/XtB8p8sE37+AfzVXShNzcMIYg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -22154,8 +22109,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -22258,7 +22212,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -22344,7 +22297,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22672,8 +22624,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22704,16 +22656,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -23130,7 +23082,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -23412,7 +23363,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -23565,7 +23515,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23598,7 +23547,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -24350,7 +24298,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24487,7 +24434,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -24938,7 +24884,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -25101,7 +25046,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f73f55944..19160ee79 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "dependencies": { "@astrojs/check": "0.9.6", "@astrojs/db": "^0.18.3", - "@astrojs/mdx": "4.3.12", + "@astrojs/mdx": "4.3.13", "@astrojs/preact": "4.1.3", "@astrojs/rss": "4.0.14", "@astrojs/sitemap": "^3.6.0", @@ -78,7 +78,7 @@ "@sentry/astro": "^10.29.0", "@sentry/browser": "^10.29.0", "@shikijs/transformers": "^3.19.0", - "@supabase/supabase-js": "^2.86.2", + "@supabase/supabase-js": "^2.87.1", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.17", @@ -88,7 +88,7 @@ "@upstash/redis": "^1.35.7", "@vite-pwa/astro": "^1.2.0", "@webcomponents/template-shadowroot": "^0.2.1", - "astro": "5.16.4", + "astro": "5.16.5", "astro-icon": "^1.1.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.7.2", @@ -99,9 +99,9 @@ "embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "focus-trap": "7.6.6", - "gsap": "^3.13.0", + "gsap": "^3.14.1", "js-cookie": "^3.0.5", - "jsdom": "^27.2.0", + "jsdom": "^27.3.0", "libphonenumber-js": "1.12.31", "lit": "^3.3.1", "nanostores": "^1.1.0", @@ -115,7 +115,7 @@ "remark-breaks": "^4.0.0", "remark-emoji": "^5.0.2", "remark-linkify-regex": "^1.2.1", - "resend": "^6.5.2", + "resend": "^6.6.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "tailwindcss": "^4.1.17", @@ -143,14 +143,14 @@ "@types/glidejs__glide": "^3.6.6", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^27.0.0", - "@types/node": "^24.10.1", + "@types/node": "^25.0.0", "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.7", "@types/to-ico": "1.1.3", "@types/uuid": "^11.0.0", "@types/yargs": "17.0.35", - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", "@vitest/coverage-v8": "^4.0.15", "confusing-browser-globals": "1.0.11", "cross-spawn": "7.0.6", @@ -182,11 +182,11 @@ "stylelint-config-standard": "^39.0.1", "stylelint-declaration-block-no-ignored-properties": "2.8.0", "stylelint-order": "7.0.0", - "supabase": "^2.65.6", + "supabase": "^2.65.8", "temp-dir": "3.0.0", "tslib": "2.8.1", "typescript": "5.9.3", - "typescript-eslint": "8.48.1", + "typescript-eslint": "8.49.0", "unist-util-inspect": "^8.1.0", "unist-util-visit": "^5.0.0", "vitest": "4.0.15", From eec5333847792539fbf8934980d8624d8027e40b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 10 Dec 2025 23:01:09 +0300 Subject: [PATCH 03/22] Add Astro DB and database schemas --- _TODO.md | 14 +------ db/config.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++- db/seed.ts | 47 +++++++++++++++++++++++- 3 files changed, 145 insertions(+), 17 deletions(-) diff --git a/_TODO.md b/_TODO.md index 7aeccb4c2..06231968d 100644 --- a/_TODO.md +++ b/_TODO.md @@ -23,19 +23,7 @@ We still have a handful of "real" Supabase/Upstash touchpoints under src: Anything that imports supabaseAdmin (below) rides on that helper, so those env getters/validators (`environmentApi.ts`, `environmentalVariableValidation.ts`)` also remain in play. -API routes that still execute Supabase queries: - -- `export.ts` - reads consent_records directly from Supabase when packaging DSAR exports. - -- `cleanup-dsar-requests.ts` - deletes fulfilled/expired DSAR requests in Supabase. - -- `cleanup-confirmations.ts` - deletes expired/old newsletter_confirmations rows in Supabase. - -- `ping-integrations.ts` - the Supabase half of the keep-alive check still runs a select against newsletter_confirmations. - -Client-side module supabase.ts still instantiates a public Supabase client (it isn't imported anywhere else now, but the implementation is present). - -For Upstash, the only active usage under src is the same ping-integrations route: it fetches `getUpstashApiUrl()` / `getUpstashApiToken()` and performs a REST call against the Upstash proxy key. +Client-side module `supabase.ts` still instantiates a public Supabase client (it isn't imported anywhere else now, but the implementation is present). Those env helpers and the validation entries (`KV_REST_API_URL`, `KV_REST_API_TOKEN`, etc.) are therefore still part of the implementation. diff --git a/db/config.ts b/db/config.ts index b5a05eb95..46c671b10 100644 --- a/db/config.ts +++ b/db/config.ts @@ -1,6 +1,103 @@ -import { defineDb } from 'astro:db' +import { column, defineDb, defineTable } from 'astro:db' + +const consentEvents = defineTable({ + columns: { + id: column.text({ primaryKey: true }), + dataSubjectId: column.text({ notNull: true }), + email: column.text(), + purposes: column.json({ notNull: true }), + source: column.text({ notNull: true }), + userAgent: column.text({ notNull: true }), + ipAddress: column.text(), + privacyPolicyVersion: column.text({ notNull: true }), + consentText: column.text(), + verified: column.boolean({ notNull: true, default: false }), + createdAt: column.date({ notNull: true }), + }, + indexes: { + subjectCreatedAt: { + on: ['dataSubjectId', 'createdAt'], + }, + ipCreatedAt: { + on: ['ipAddress', 'createdAt'], + }, + }, +}) + +const rateLimitWindows = defineTable({ + columns: { + id: column.text({ primaryKey: true }), + scope: column.text({ notNull: true }), + identifier: column.text({ notNull: true }), + hits: column.number({ notNull: true }), + limit: column.number({ notNull: true }), + windowMs: column.number({ notNull: true }), + windowExpiresAt: column.number({ notNull: true }), + updatedAt: column.date({ notNull: true }), + }, + indexes: { + scopeIdentifier: { + on: ['scope', 'identifier'], + unique: true, + }, + }, +}) + +const dsarRequests = defineTable({ + columns: { + id: column.text({ primaryKey: true }), + token: column.text({ notNull: true }), + email: column.text({ notNull: true }), + requestType: column.text({ notNull: true }), + expiresAt: column.date({ notNull: true }), + fulfilledAt: column.date(), + createdAt: column.date({ notNull: true }), + }, + indexes: { + tokenUnique: { + on: ['token'], + unique: true, + }, + emailRequestType: { + on: ['email', 'requestType'], + }, + }, +}) + +const newsletterConfirmations = defineTable({ + columns: { + id: column.text({ primaryKey: true }), + token: column.text({ notNull: true }), + email: column.text({ notNull: true }), + dataSubjectId: column.text({ notNull: true }), + firstName: column.text(), + source: column.text({ notNull: true }), + userAgent: column.text(), + ipAddress: column.text(), + consentTimestamp: column.date({ notNull: true }), + expiresAt: column.date({ notNull: true }), + confirmedAt: column.date(), + createdAt: column.date({ notNull: true }), + }, + indexes: { + tokenUnique: { + on: ['token'], + unique: true, + }, + emailSubject: { + on: ['email', 'dataSubjectId'], + }, + }, +}) // https://astro.build/db/config export default defineDb({ - tables: {} + tables: { + consentEvents, + rateLimitWindows, + dsarRequests, + newsletterConfirmations, + }, }) + +export { consentEvents, rateLimitWindows, dsarRequests, newsletterConfirmations } diff --git a/db/seed.ts b/db/seed.ts index fffc464b0..045aaabed 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -1,6 +1,49 @@ -import { db } from 'astro:db'; +import { randomUUID } from 'node:crypto' +import { consentEvents, rateLimitWindows, db } from 'astro:db' // https://astro.build/db/seed export default async function seed() { - // TODO + const now = new Date() + + await db.insert(consentEvents).values([ + { + id: randomUUID(), + dataSubjectId: '00000000-0000-0000-0000-000000000001', + email: 'seed@example.com', + purposes: ['analytics', 'contact'], + source: 'seed_script', + userAgent: 'seed-agent', + ipAddress: '127.0.0.1', + privacyPolicyVersion: 'seed', + consentText: 'Seeded consent record', + verified: true, + createdAt: now, + }, + { + id: randomUUID(), + dataSubjectId: '00000000-0000-0000-0000-000000000002', + email: null, + purposes: ['marketing'], + source: 'seed_script', + userAgent: 'seed-agent', + ipAddress: null, + privacyPolicyVersion: 'seed', + consentText: null, + verified: false, + createdAt: now, + }, + ]) + + await db.insert(rateLimitWindows).values([ + { + id: randomUUID(), + scope: 'seed', + identifier: 'local', + hits: 0, + limit: 1, + windowMs: 60000, + windowExpiresAt: Date.now() + 60000, + updatedAt: now, + }, + ]) } From da5132aee5bf8d8e6b25c34a2342e6a1dd696ce8 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 11 Dec 2025 00:15:18 +0300 Subject: [PATCH 04/22] Refactor pages/api endpoints to use Astro DB and remove Suprabase / UPstash --- .cache/pages.json | 12 +- @types/gsap/velocity-tracker.d.ts | 17 + _TODO.md | 12 +- db/config.ts | 20 +- db/dev.db | Bin 0 -> 77824 bytes package.json | 2 +- src/pages/api/_contracts/gdpr.contracts.ts | 2 +- .../api/_utils/__tests__/rateLimit.spec.ts | 300 ++++++++---------- src/pages/api/_utils/rateLimit.ts | 103 ++++-- src/pages/api/_utils/rateLimitStore.ts | 95 ++++++ src/pages/api/contact/__tests__/index.spec.ts | 4 + src/pages/api/cron/__tests__/cleanup.spec.ts | 170 +++------- src/pages/api/cron/cleanup-confirmations.ts | 53 ++-- src/pages/api/cron/cleanup-dsar-requests.ts | 66 ++-- src/pages/api/cron/ping-integrations.ts | 115 ++----- .../api/gdpr/__tests__/request-data.spec.ts | 112 +++++++ src/pages/api/gdpr/_utils/consentStore.ts | 104 ++++++ src/pages/api/gdpr/_utils/dsarStore.ts | 73 +++++ src/pages/api/gdpr/consent.ts | 110 +++---- src/pages/api/gdpr/export.ts | 34 +- src/pages/api/gdpr/request-data.ts | 37 +-- src/pages/api/gdpr/verify.ts | 112 +++---- .../api/newsletter/__tests__/_confirm.spec.ts | 118 +++---- src/pages/api/newsletter/_token.ts | 109 ++++--- src/pages/api/newsletter/confirm.ts | 18 +- src/pages/api/newsletter/index.ts | 2 +- test/e2e/helpers/cronHealth.ts | 36 +-- test/e2e/specs/15-cron/cron.spec.ts | 86 +---- tsconfig.json | 4 +- 29 files changed, 988 insertions(+), 938 deletions(-) create mode 100644 @types/gsap/velocity-tracker.d.ts create mode 100644 db/dev.db create mode 100644 src/pages/api/_utils/rateLimitStore.ts create mode 100644 src/pages/api/gdpr/__tests__/request-data.spec.ts create mode 100644 src/pages/api/gdpr/_utils/consentStore.ts create mode 100644 src/pages/api/gdpr/_utils/dsarStore.ts diff --git a/.cache/pages.json b/.cache/pages.json index a5936162f..5f4910f3c 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,16 +22,10 @@ "contact", "offline", { - "privacy": [ - "my-data" - ] + "privacy": ["my-data"] }, { - "services": [ - "consulting", - "overview", - "web-development" - ] + "services": ["consulting", "overview", "web-development"] }, { "tags": [ @@ -53,4 +47,4 @@ "typescript" ] } -] \ No newline at end of file +] diff --git a/@types/gsap/velocity-tracker.d.ts b/@types/gsap/velocity-tracker.d.ts new file mode 100644 index 000000000..ef5ec7d36 --- /dev/null +++ b/@types/gsap/velocity-tracker.d.ts @@ -0,0 +1,17 @@ +type VelocityTrackerModule = typeof import('gsap/types/utils/velocity-tracker') extends { + VelocityTracker: infer T +} + ? T + : unknown + +declare module 'gsap/utils/VelocityTracker' { + const VelocityTracker: VelocityTrackerModule + export { VelocityTracker } + export default VelocityTracker +} + +declare module 'gsap/utils/VelocityTracker.js' { + const VelocityTracker: VelocityTrackerModule + export { VelocityTracker } + export default VelocityTracker +} diff --git a/_TODO.md b/_TODO.md index 06231968d..068fa9c90 100644 --- a/_TODO.md +++ b/_TODO.md @@ -5,11 +5,17 @@ Turso Setup Guidance -Wait until config.ts + seed.ts are committed; then create a Turso database named webstack-corporate-prod (or similar) via turso db create webstack-corporate-prod. +We need to run `npx astro db push` with production Astro DB credentials on GitHub as part of the merge to main Action workflow. We'll have two sets of env vars then: development for the dev server, and production for the DB push: -Generate an app token (turso db tokens create webstack-corporate-prod -n astro-app) and store the ASTRO_DB_REMOTE_URL/ASTRO_DB_APP_TOKEN secrets. +ASTRO_DB_REMOTE_URL="libsql://webstack-corporate-prod-webstackdev.aws-us-east-1.turso.io" \ +ASTRO_DB_APP_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJpYXQiOjE3NjUzODU2NzQsImlkIjoiOTc2OTFlZWMtZGFhMC00YjI0LTg2M2QtY2JkYjU2ZDczMjE0IiwicmlkIjoiNWNmZTdkZjAtZGIzMC00ZDJhLTlmNjItMzUwZjkyNGEzOGE2In0.dCxUeBL-zSFW7ckng3QCqIHX9X2DQsBv7yboP_GdbtBtpTkxvdmPwQkRAxqG2l1Iy7J7aExX7xIBIPsAWVMvBg" \ +npx astro db push -After the DB exists, run ASTRO_DB_REMOTE_URL=... ASTRO_DB_APP_TOKEN=... npx astro db push --remote from the project root to apply the schema. That command is when you'll also need to restart your dev server. +ASTRO_DB_REMOTE_URL="file:./db/dev.db" \ +ASTRO_DB_APP_TOKEN="" \ +npx astro db push + +ASTRO_DATABASE_FILE="./db/dev.db" Let me know if you'd like similar migrations for the other Supabase-backed endpoints or want the fallback flag renamed now that we're on Astro DB. diff --git a/db/config.ts b/db/config.ts index 46c671b10..b3b8452ec 100644 --- a/db/config.ts +++ b/db/config.ts @@ -4,13 +4,13 @@ const consentEvents = defineTable({ columns: { id: column.text({ primaryKey: true }), dataSubjectId: column.text({ notNull: true }), - email: column.text(), + email: column.text({ optional: true }), purposes: column.json({ notNull: true }), source: column.text({ notNull: true }), userAgent: column.text({ notNull: true }), - ipAddress: column.text(), + ipAddress: column.text({ optional: true }), privacyPolicyVersion: column.text({ notNull: true }), - consentText: column.text(), + consentText: column.text({ optional: true }), verified: column.boolean({ notNull: true, default: false }), createdAt: column.date({ notNull: true }), }, @@ -50,11 +50,11 @@ const dsarRequests = defineTable({ email: column.text({ notNull: true }), requestType: column.text({ notNull: true }), expiresAt: column.date({ notNull: true }), - fulfilledAt: column.date(), + fulfilledAt: column.date({ optional: true }), createdAt: column.date({ notNull: true }), }, indexes: { - tokenUnique: { + dsarTokenUnique: { on: ['token'], unique: true, }, @@ -70,17 +70,17 @@ const newsletterConfirmations = defineTable({ token: column.text({ notNull: true }), email: column.text({ notNull: true }), dataSubjectId: column.text({ notNull: true }), - firstName: column.text(), + firstName: column.text({ optional: true }), source: column.text({ notNull: true }), - userAgent: column.text(), - ipAddress: column.text(), + userAgent: column.text({ optional: true }), + ipAddress: column.text({ optional: true }), consentTimestamp: column.date({ notNull: true }), expiresAt: column.date({ notNull: true }), - confirmedAt: column.date(), + confirmedAt: column.date({ optional: true }), createdAt: column.date({ notNull: true }), }, indexes: { - tokenUnique: { + newsletterTokenUnique: { on: ['token'], unique: true, }, diff --git a/db/dev.db b/db/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..fa1915d79825874396d84c664fbee18114ac24bf GIT binary patch literal 77824 zcmeI3-EP}P7RRN0NdAnIEf%^8z;KaGgvf236zJBu=qjnYFk(B2Ew>GdKue3Gcv6aF zIAmpeqKB!~L2Q!@>!{)MC!hnzG2`JFT8%+SvE z({=7Mt><`s!`JSWu9nK>(w{Z0R4QGdzaP-w!DoTKTpC=^|I3;0mzRmW9#eAQ64;{P5W$xyVEq?cKtJQ3`w6)DITHk1T zwui1g-qiFl{dDbi^swI0bkp#Sc$hU)ub<3ct=2vz6)T~N)I!_&j@dgle-*H_QIT^k zmb0_D_H-vvl|G!YaH>3Cj;7b&l&(~3_wSc~tog>SHE!T^2u06Qtf$?4u-=*ogPXeM zvjbmyvbDC++v7k zjW4x~p+GRN`GVPg@+5bgrs**ul9n#HpPp#*J`=vtcZcP$1D8{mW^5%p0Cko@#LU4> z64UjV;ioOh7LRWC-Uvj2=0T z*pvPot>T;IH`l|6%^J^spI2iudrMBEkbul07z)P;;n54M9=EJC7$$|sr}LHCqnqQx zBRj9oO7?2u`&shI@*vxZuH&*blR}#JIP*M;#dY52{@3I&XJ5>X^h>T&sI;4#W1QA(aOaoHJ+4v6M{({dnj-+wTX1Of#*7c#bqP$DBU?@ z?a6i-E<&!yUmM-SCyvFthhH&I(57U#5XCcVQ+-X!*K6juv4J z=K6L^VXIEI{@JBUZR6IsuvB<aX9=@;Lo|~QJChhXt zt-rLkvU?P*xzm2Uwn+vyTAS?#Jx5@r7qoWkoA7!ttoC62@q@Y9`XbG`T={p2{@?=w zAOHd&00O@b0{{9``Eu#TPo29T-~D9y<9o|1caQYA;OVP++Nw8b$G=BgA$|2oS1Nzh zUt;aWeCQ3COlgep)_mnJOT$ulXG4wZr5=vW)!tr zlL1aqYqFjZSbiq6r7t{FZInP+L1`p4KT+AS^kVp)f==lqWate9waJR|ZLVC9(ZNd; z{kb%ezEqKBqAtqv$UIKZ4;3?N%fGjghN!B&^I|zPuA1ovu@t>VBTeV7*YWE>wPc^7 zVtl^MyvHj-T*h23a(=eL@NMGi5pD852+#jZ|1HrUd_VvMKmY_l00ck)1V8`;KmY_l z00e&R1b$z>xH3EZ%LMZLPrvk!4+ww&2!H?xfB*=900@8p2!H?xfWQK=l_TS5C8!X009sH0T2KI5C8!X009t~ zegZiEPrr{bLl6J~5C8!X009sH0T2KI5C8!X!1*6B00JNY0w4eaAOHd&00JNY0w4ea z(@y~Z{(t&?j2VIe2!H?xfB*=900@8p2!H?xfB=5}j~D;}5C8!X009sH0T2KI5C8!X z0D0T2KI5C8!X009sH0T2KI5C8$3{}BTq00JNY0w4eaAOHd&00JNY z0wD0)AfWq)F4I?aJLvB+PjBd=yT|&5zIvnwHh&e+y`EtS*3eDndaP^s%uJeWqc8jJ zabHl+u47rO>vPAZHjf@$=Y8&f&27`!m-k(d_YLpx5j#u_IIeu$u;PZ}{3wd{a}t`%L!Wost0ect1=Bq$VRM1M;wgKm&Kp5A+&D)PO6NlQb^M79aT+hVks z1T7S@M=K@h+u>JEt0{?fpB?zh{Lj1u$xLpZ%9!fn8%Xt??-x@!%SI#vaG!#^sy03zQCoFnx7|N%U)cj;2L?x43-0K^%?*3xAXx#AcB`uvz%zMjF=aokb-`EazU(!x&t(e-P zTFiy}dF4_t3V$=)M;4G-Q)|V@98^l-1YVc6x&=s#O%?lw)6Hm70YiTC&C*z+XTidEUA%t$2LyifZzqwi*r7Fbn>Mmggo!cEe9D){3T zyVOMUk%i@*)&8vgw?LKF>2&cH>BVH1ab`{Gjvz()_WOc3&CB>y_XKLj9 ({ +type MockWindow = { + id: string + scope: string + identifier: string + hits: number + limit: number + windowMs: number + windowExpiresAt: number + updatedAt: Date +} + +type WindowState = { + current: MockWindow | undefined + resetWindow: ReturnType + incrementHits: ReturnType +} + +const { + mockIsDev, + mockIsTest, + mockIsDbError, + mockWithRateLimitWindow, +} = vi.hoisted(() => ({ mockIsDev: vi.fn(() => false), mockIsTest: vi.fn(() => false), + mockIsDbError: vi.fn(() => false), + mockWithRateLimitWindow: vi.fn(), })) +const windowStates = new Map() + // Mock server environment helpers that get re-exported through environmentApi vi.mock('@lib/config/environmentServer', () => ({ isCI: vi.fn(() => false), @@ -20,61 +46,89 @@ vi.mock('@lib/config/environmentServer', () => ({ // Mock environment utilities BEFORE importing the module under test vi.mock('@pages/api/_environment/environmentApi', () => ({ - getUpstashApiToken: vi.fn(() => 'test-token'), - getUpstashApiUrl: vi.fn(() => 'https://test-redis.upstash.io'), isDev: mockIsDev, isTest: mockIsTest, })) -import { rateLimiters, checkRateLimit, checkContactRateLimit } from '@pages/api/_utils/rateLimit' - -// Mock the Upstash Redis and Ratelimit modules -vi.mock('@upstash/redis', () => ({ - Redis: vi.fn(function RedisMock(_config) { - return {} - }), +vi.mock('astro:db', () => ({ + isDbError: mockIsDbError, })) -vi.mock('@upstash/ratelimit', () => { - const mockLimitFn = vi.fn() - - const RatelimitConstructor = vi.fn(function RatelimitMock(config) { - return { - limit: mockLimitFn, - config, - } - }) +vi.mock('@pages/api/_utils/rateLimitStore', () => ({ + withRateLimitWindow: mockWithRateLimitWindow, +})) - // Add static methods to the constructor - Object.assign(RatelimitConstructor, { - slidingWindow: vi.fn((requests, window) => ({ requests, window })), - fixedWindow: vi.fn((requests, window) => ({ requests, window })), - }) +import { rateLimiters, checkRateLimit, checkContactRateLimit } from '@pages/api/_utils/rateLimit' +function buildWindow(scope: string, identifier: string, overrides?: Partial): MockWindow { return { - Ratelimit: RatelimitConstructor, + id: overrides?.id ?? `${scope}-${identifier}`, + scope, + identifier, + hits: overrides?.hits ?? 0, + limit: overrides?.limit ?? 10, + windowMs: overrides?.windowMs ?? 60_000, + windowExpiresAt: overrides?.windowExpiresAt ?? Date.now() + 60_000, + updatedAt: overrides?.updatedAt ?? new Date(), } -}) +} + +function createWindowState(scope: string, identifier: string, initial?: MockWindow): WindowState { + const state: WindowState = { + current: initial, + resetWindow: vi.fn(async ({ hits, limit, windowMs, windowExpiresAt }) => { + state.current = buildWindow(scope, identifier, { + hits, + limit, + windowMs, + windowExpiresAt, + }) + return state.current + }), + incrementHits: vi.fn(async () => { + if (!state.current) { + throw new TestError('Rate limit window missing') + } + state.current = { + ...state.current, + hits: state.current.hits + 1, + updatedAt: new Date(), + } + return state.current + }), + } + return state +} -describe('Rate Limit Utils', () => { - // Get reference to the mock function after module initialization - let mockLimit: ReturnType - - beforeEach(async () => { - // Get the mock function from the created rate limiters - const { Ratelimit } = await import('@upstash/ratelimit') - const { Redis } = await import('@upstash/redis') - const rateLimiterInstance = new Ratelimit({ - redis: new Redis({ - url: 'https://test-redis.upstash.io', - token: 'test-token', - }), - limiter: Ratelimit.slidingWindow(1, '1 m'), - }) - mockLimit = rateLimiterInstance.limit as ReturnType +function ensureWindowState(scope: string, identifier: string): WindowState { + const key = `${scope}:${identifier}` + if (!windowStates.has(key)) { + windowStates.set(key, createWindowState(scope, identifier)) + } + return windowStates.get(key) as WindowState +} + +function seedWindow(scope: string, identifier: string, overrides?: Partial): WindowState { + const state = createWindowState(scope, identifier, buildWindow(scope, identifier, overrides)) + windowStates.set(`${scope}:${identifier}`, state) + return state +} - // Reset all mocks +describe('Rate Limit Utils', () => { + beforeEach(() => { vi.clearAllMocks() + windowStates.clear() + mockIsDev.mockReturnValue(false) + mockIsTest.mockReturnValue(false) + mockIsDbError.mockReturnValue(false) + mockWithRateLimitWindow.mockImplementation(async (scope, identifier, handler) => { + const state = ensureWindowState(scope as string, identifier) + return handler({ + window: state.current, + resetWindow: state.resetWindow, + incrementHits: state.incrementHits, + }) + }) }) describe('rateLimiters configuration', () => { @@ -105,157 +159,79 @@ describe('Rate Limit Utils', () => { }) describe('checkRateLimit', () => { - beforeEach(() => { - // Create a mock rate limiter - const mockRateLimiter = { - limit: mockLimit, - config: {}, - } - - // Ensure each exported limiter uses the shared mock implementation - Object.keys(rateLimiters).forEach((key) => { - const mapKey = key as keyof typeof rateLimiters - Object.assign(rateLimiters[mapKey], mockRateLimiter) - }) - }) - it('should return success when rate limit is not exceeded', async () => { - const mockResult = { - success: true, - limit: 10, - remaining: 9, - reset: Date.now() + 60000, - } - mockLimit.mockResolvedValue(mockResult) - const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - expect(mockLimit).toHaveBeenCalledWith('192.168.1.1') - expect(result).toEqual({ - success: true, - reset: mockResult.reset, - }) + const state = ensureWindowState('consent', '192.168.1.1') + expect(state.resetWindow).toHaveBeenCalledTimes(1) + expect(result.success).toBe(true) + expect(typeof result.reset).toBe('number') }) it('should return failure when rate limit is exceeded', async () => { - const mockResult = { - success: false, + seedWindow('consent', '192.168.1.1', { + hits: 10, limit: 10, - remaining: 0, - reset: Date.now() + 60000, - } - mockLimit.mockResolvedValue(mockResult) - + windowExpiresAt: Date.now() + 60000, + }) const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - expect(mockLimit).toHaveBeenCalledWith('192.168.1.1') - expect(result).toEqual({ - success: false, - reset: mockResult.reset, - }) + const state = ensureWindowState('consent', '192.168.1.1') + expect(state.incrementHits).not.toHaveBeenCalled() + expect(result.success).toBe(false) + expect(typeof result.reset).toBe('number') }) it('should handle different identifiers', async () => { - const mockResult = { - success: true, - limit: 5, - remaining: 4, - reset: Date.now() + 60000, - } - mockLimit.mockResolvedValue(mockResult) - await checkRateLimit(rateLimiters.export, 'user@example.com') - expect(mockLimit).toHaveBeenCalledWith('user@example.com') + expect(mockWithRateLimitWindow).toHaveBeenCalledWith('export', 'user@example.com', expect.any(Function)) await checkRateLimit(rateLimiters.export, '10.0.0.1') - expect(mockLimit).toHaveBeenCalledWith('10.0.0.1') - - expect(mockLimit).toHaveBeenCalledTimes(2) + expect(mockWithRateLimitWindow).toHaveBeenCalledWith('export', '10.0.0.1', expect.any(Function)) }) it('should propagate rate limiter errors', async () => { - const error = new TestError('Redis connection failed') - mockLimit.mockRejectedValue(error) + const error = new TestError('DB offline') + mockWithRateLimitWindow.mockRejectedValueOnce(error) await expect( checkRateLimit(rateLimiters.consent, '192.168.1.1') - ).rejects.toThrow('Redis connection failed') - - expect(mockLimit).toHaveBeenCalledWith('192.168.1.1') + ).rejects.toThrow('DB offline') }) it('should work with different rate limiter configurations', async () => { - const mockResults = [ - { - success: true, - limit: 10, - remaining: 9, - reset: Date.now() + 60000, - }, - { - success: true, - limit: 30, - remaining: 29, - reset: Date.now() + 60000, - }, - { - success: false, - limit: 5, - remaining: 0, - reset: Date.now() + 60000, - }, - ] - - // Test consent limiter (10 requests per minute) - Object.assign(rateLimiters.consent, { limit: mockLimit }) - mockLimit.mockResolvedValueOnce(mockResults[0]) + seedWindow('consent', 'test-ip', { + hits: 0, + limit: 10, + windowExpiresAt: Date.now() + 60000, + }) let result = await checkRateLimit(rateLimiters.consent, 'test-ip') expect(result.success).toBe(true) - // Test consentRead limiter (30 requests per minute) - Object.assign(rateLimiters.consentRead, { limit: mockLimit }) - mockLimit.mockResolvedValueOnce(mockResults[1]) + seedWindow('consentRead', 'test-ip', { + hits: 0, + limit: 30, + windowExpiresAt: Date.now() + 60000, + }) result = await checkRateLimit(rateLimiters.consentRead, 'test-ip') expect(result.success).toBe(true) - // Test export limiter (5 requests per minute) - Object.assign(rateLimiters.export, { limit: mockLimit }) - mockLimit.mockResolvedValueOnce(mockResults[2]) + seedWindow('export', 'test-ip', { + hits: 5, + limit: 5, + windowExpiresAt: Date.now() + 60000, + }) result = await checkRateLimit(rateLimiters.export, 'test-ip') expect(result.success).toBe(false) - - expect(mockLimit).toHaveBeenCalledTimes(3) }) - it('should handle missing reset timestamp', async () => { - const mockResult = { - success: true, - limit: 10, - remaining: 9, - // reset is undefined - } - mockLimit.mockResolvedValue(mockResult) + it('should return fallback response on database errors', async () => { + mockIsDbError.mockReturnValue(true) + mockWithRateLimitWindow.mockRejectedValueOnce(new Error('db failure')) const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - expect(result).toEqual({ - success: true, - reset: undefined, - }) - }) - - it('should handle edge case with zero remaining requests but success true', async () => { - const mockResult = { - success: true, - limit: 10, - remaining: 0, - reset: Date.now() + 60000, - } - mockLimit.mockResolvedValue(mockResult) - - const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - - expect(result.success).toBe(true) + expect(result.success).toBe(false) expect(typeof result.reset).toBe('number') }) }) @@ -264,13 +240,9 @@ describe('Rate Limit Utils', () => { let isDev: ReturnType let isTest: ReturnType - beforeEach(async () => { - // Get the mocked functions - const utils = await import('@lib/config/environmentServer') - isDev = utils.isDev as ReturnType - isTest = utils.isTest as ReturnType - - // Reset to production mode by default + beforeEach(() => { + isDev = mockIsDev + isTest = mockIsTest isDev.mockReturnValue(false) isTest.mockReturnValue(false) }) diff --git a/src/pages/api/_utils/rateLimit.ts b/src/pages/api/_utils/rateLimit.ts index 46466e25a..1f325fca0 100644 --- a/src/pages/api/_utils/rateLimit.ts +++ b/src/pages/api/_utils/rateLimit.ts @@ -1,11 +1,6 @@ -import { Ratelimit } from '@upstash/ratelimit' -import { Redis } from '@upstash/redis' -import { - getUpstashApiToken, - getUpstashApiUrl, - isDev, - isTest, -} from '@pages/api/_environment/environmentApi' +import { isDbError } from 'astro:db' +import { isDev, isTest } from '@pages/api/_environment/environmentApi' +import { withRateLimitWindow } from '@pages/api/_utils/rateLimitStore' export type RateLimiter = { limit: (_identifier: string) => Promise<{ success: boolean; reset: number | undefined }> @@ -15,17 +10,29 @@ export type RateLimiterKey = 'consent' | 'consentRead' | 'export' | 'delete' | ' export type RateLimiterMap = Record -const redis = createRedisClient() - // Simple in-memory rate limiting (use Redis in production) const rateLimitStore = new Map() +type RateLimiterConfig = { + scope: RateLimiterKey + limit: number + windowMs: number +} + +const limiterConfigs: Record = { + consent: { scope: 'consent', limit: 10, windowMs: 60_000 }, + consentRead: { scope: 'consentRead', limit: 30, windowMs: 60_000 }, + export: { scope: 'export', limit: 5, windowMs: 60_000 }, + delete: { scope: 'delete', limit: 3, windowMs: 60_000 }, + contact: { scope: 'contact', limit: 5, windowMs: 15 * 60 * 1000 }, +} + export const rateLimiters: RateLimiterMap = { - consent: createLimiter(Ratelimit.slidingWindow(10, '1 m')), - consentRead: createLimiter(Ratelimit.slidingWindow(30, '1 m')), - export: createLimiter(Ratelimit.slidingWindow(5, '1 m')), - delete: createLimiter(Ratelimit.slidingWindow(3, '1 m')), - contact: createLimiter(Ratelimit.slidingWindow(5, '15 m')), + consent: createLimiter(limiterConfigs.consent), + consentRead: createLimiter(limiterConfigs.consentRead), + export: createLimiter(limiterConfigs.export), + delete: createLimiter(limiterConfigs.delete), + contact: createLimiter(limiterConfigs.contact), } export async function checkRateLimit( @@ -66,28 +73,60 @@ export function checkContactRateLimit(ipFingerprint: string): boolean { rateLimitStore.set(key, validRequests) return true } - -function createRedisClient(): Redis | undefined { - if (isDev() || isTest()) { - return undefined +function createLimiter(config: RateLimiterConfig): RateLimiter { + return { + limit: identifier => applyRateLimit(config, identifier), } - - return new Redis({ - url: getUpstashApiUrl(), - token: getUpstashApiToken(), - }) } -function createLimiter(window: ReturnType): RateLimiter { - if (!redis) { +async function applyRateLimit( + config: RateLimiterConfig, + identifier: string, +): Promise<{ success: boolean; reset: number | undefined }> { + if (isDev() || isTest()) { return { - limit: async () => ({ success: true, reset: Date.now() }), + success: true, + reset: Date.now() + config.windowMs, } } - return new Ratelimit({ - redis, - limiter: window, - analytics: true, - }) + if (!identifier) { + return { success: true, reset: Date.now() + config.windowMs } + } + + try { + return await withRateLimitWindow(config.scope, identifier, async context => { + const now = Date.now() + const currentWindow = context.window + + if (!currentWindow || currentWindow.windowExpiresAt <= now) { + const reset = now + config.windowMs + const nextWindow = await context.resetWindow({ + hits: 1, + limit: config.limit, + windowMs: config.windowMs, + windowExpiresAt: reset, + }) + return { success: true, reset: nextWindow.windowExpiresAt } + } + + if (currentWindow.hits < config.limit) { + const updatedWindow = await context.incrementHits() + return { success: true, reset: updatedWindow.windowExpiresAt } + } + + return { + success: false, + reset: currentWindow.windowExpiresAt, + } + }) + } catch (error) { + if (isDbError(error)) { + return { + success: false, + reset: Date.now() + config.windowMs, + } + } + throw error + } } diff --git a/src/pages/api/_utils/rateLimitStore.ts b/src/pages/api/_utils/rateLimitStore.ts new file mode 100644 index 000000000..c7636db12 --- /dev/null +++ b/src/pages/api/_utils/rateLimitStore.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto' +import { and, db, eq, rateLimitWindows } from 'astro:db' + +export type RateLimitWindowRecord = typeof rateLimitWindows.$inferSelect + +export type RateLimitWindowResetInput = { + hits: number + limit: number + windowMs: number + windowExpiresAt: number +} + +export type RateLimitWindowContext = { + window: RateLimitWindowRecord | undefined + resetWindow: (_input: RateLimitWindowResetInput) => Promise + incrementHits: () => Promise +} + +export async function withRateLimitWindow( + scope: string, + identifier: string, + handler: (_context: RateLimitWindowContext) => Promise, +): Promise { + return db.transaction(async tx => { + const result = await tx + .select() + .from(rateLimitWindows) + .where(and(eq(rateLimitWindows.scope, scope), eq(rateLimitWindows.identifier, identifier))) + .limit(1) + + let currentWindow = result[0] + + const resetWindow = async (input: RateLimitWindowResetInput): Promise => { + const upsertId = currentWindow?.id ?? randomUUID() + const payload: RateLimitWindowRecord = { + id: upsertId, + scope, + identifier, + hits: input.hits, + limit: input.limit, + windowMs: input.windowMs, + windowExpiresAt: input.windowExpiresAt, + updatedAt: new Date(), + } + + await tx + .insert(rateLimitWindows) + .values(payload) + .onConflictDoUpdate({ + target: [rateLimitWindows.scope, rateLimitWindows.identifier], + set: { + hits: input.hits, + limit: input.limit, + windowMs: input.windowMs, + windowExpiresAt: input.windowExpiresAt, + updatedAt: payload.updatedAt, + }, + }) + + currentWindow = payload + return payload + } + + const incrementHits = async (): Promise => { + if (!currentWindow) { + throw new Error('Rate limit window has not been initialized') + } + + const nextHits = currentWindow.hits + 1 + const updatedAt = new Date() + + await tx + .update(rateLimitWindows) + .set({ + hits: nextHits, + updatedAt, + }) + .where(and(eq(rateLimitWindows.scope, scope), eq(rateLimitWindows.identifier, identifier))) + + currentWindow = { + ...currentWindow, + hits: nextHits, + updatedAt, + } + + return currentWindow + } + + return handler({ + window: currentWindow, + resetWindow, + incrementHits, + }) + }) +} diff --git a/src/pages/api/contact/__tests__/index.spec.ts b/src/pages/api/contact/__tests__/index.spec.ts index d025cf9bc..6e65f0171 100644 --- a/src/pages/api/contact/__tests__/index.spec.ts +++ b/src/pages/api/contact/__tests__/index.spec.ts @@ -4,6 +4,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { POST, OPTIONS } from '@pages/api/contact/index' +vi.mock('astro:db', () => ({ + isDbError: () => false, +})) + // Mock Resend before importing the module const mockSend = vi.fn().mockResolvedValue({ data: { id: 'test-email-id' } }) diff --git a/src/pages/api/cron/__tests__/cleanup.spec.ts b/src/pages/api/cron/__tests__/cleanup.spec.ts index 59bf107b0..b6b1f4b1a 100644 --- a/src/pages/api/cron/__tests__/cleanup.spec.ts +++ b/src/pages/api/cron/__tests__/cleanup.spec.ts @@ -7,16 +7,21 @@ import { GET as cleanupDsar } from '@pages/api/cron/cleanup-dsar-requests' import { GET as cleanupConfirmations } from '@pages/api/cron/cleanup-confirmations' import { GET as pingIntegrations } from '@pages/api/cron/ping-integrations' -const supabaseAdminMock = vi.hoisted(() => ({ - from: vi.fn(), -})) - +const dbDeleteMock = vi.hoisted(() => vi.fn()) +const dbSelectMock = vi.hoisted(() => vi.fn()) const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) -const getUpstashApiUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.upstash.io')) -const getUpstashApiTokenMock = vi.hoisted(() => vi.fn(() => 'upstash-token')) -vi.mock('@pages/api/_utils', () => ({ - supabaseAdmin: supabaseAdminMock, +vi.mock('astro:db', () => ({ + db: { + delete: dbDeleteMock, + select: dbSelectMock, + }, + dsarRequests: {}, + newsletterConfirmations: {}, + lt: vi.fn((...args) => ({ op: 'lt', args })), + gt: vi.fn((...args) => ({ op: 'gt', args })), + isNull: vi.fn((...args) => ({ op: 'isNull', args })), + and: vi.fn((...args) => ({ op: 'and', args })), })) vi.mock('@pages/api/_environment/environmentApi', async () => { @@ -26,8 +31,6 @@ vi.mock('@pages/api/_environment/environmentApi', async () => { return { ...actual, getCronSecret: getCronSecretMock, - getUpstashApiUrl: getUpstashApiUrlMock, - getUpstashApiToken: getUpstashApiTokenMock, } }) @@ -40,51 +43,37 @@ const buildContext = (request: Request) => ({ }) type DeleteChain = { - delete: () => DeleteChain - not: () => DeleteChain - lt: () => DeleteChain - is: () => DeleteChain - select: () => Promise<{ data?: unknown[]; error?: unknown }> + where: ReturnType } const createDeleteChain = (options?: { data?: unknown[]; error?: unknown }): DeleteChain => { - const chain: DeleteChain = { - delete: () => chain, - not: () => chain, - lt: () => chain, - is: () => chain, - select: () => - Promise.resolve({ - data: options?.data ?? [], - error: options?.error ?? null, - }), + const returning = () => { + if (options?.error) { + return Promise.reject(options.error) + } + return Promise.resolve(options?.data ?? []) } - return chain + return { + where: vi.fn(() => ({ returning })), + } } type SelectChain = { - select: (..._args: unknown[]) => SelectChain - limit: (..._args: unknown[]) => Promise<{ data?: unknown[]; error?: unknown; count?: number }> + from: ReturnType } -const createSelectChain = (options?: { - data?: unknown[] - error?: unknown - count?: number -}): SelectChain => { - const response = { - data: options?.data ?? [], - error: options?.error ?? null, - count: options?.count ?? options?.data?.length ?? 0, +const createSelectChain = (options?: { data?: unknown[]; error?: unknown }): SelectChain => { + const limit = () => { + if (options?.error) { + return Promise.reject(options.error) + } + return Promise.resolve(options?.data ?? []) } - const chain: SelectChain = { - select: () => chain, - limit: () => Promise.resolve({ ...response }), + return { + from: vi.fn(() => ({ limit })), } - - return chain } describe('Cron cleanup endpoints', () => { @@ -93,7 +82,8 @@ describe('Cron cleanup endpoints', () => { beforeEach(() => { vi.clearAllMocks() - supabaseAdminMock.from.mockReset() + dbDeleteMock.mockReset() + dbSelectMock.mockReset() getCronSecretMock.mockClear() warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -118,11 +108,11 @@ describe('Cron cleanup endpoints', () => { expect(response.status).toBe(401) expect(body.error.code).toBe('UNAUTHORIZED') - expect(supabaseAdminMock.from).not.toHaveBeenCalled() + expect(dbDeleteMock).not.toHaveBeenCalled() }) - it('summarizes deletions when supabase succeeds', async () => { - supabaseAdminMock.from + it('summarizes deletions when astro db succeeds', async () => { + dbDeleteMock .mockReturnValueOnce(createDeleteChain({ data: [{ id: 1 }, { id: 2 }] })) .mockReturnValueOnce(createDeleteChain({ data: [{ id: 3 }] })) @@ -138,11 +128,11 @@ describe('Cron cleanup endpoints', () => { expect(response.status).toBe(200) expect(body.deleted).toEqual({ fulfilled: 2, expired: 1, total: 3 }) - expect(supabaseAdminMock.from).toHaveBeenCalledTimes(2) + expect(dbDeleteMock).toHaveBeenCalledTimes(2) }) it('returns 500 when removing fulfilled requests fails', async () => { - supabaseAdminMock.from + dbDeleteMock .mockReturnValueOnce(createDeleteChain({ error: { message: 'boom' } })) .mockReturnValueOnce(createDeleteChain()) @@ -175,7 +165,7 @@ describe('Cron cleanup endpoints', () => { }) it('reports counts when cleanup succeeds', async () => { - supabaseAdminMock.from + dbDeleteMock .mockReturnValueOnce(createDeleteChain({ data: [{ id: 1 }, { id: 2 }] })) .mockReturnValueOnce(createDeleteChain({ data: [{ id: 3 }, { id: 4 }] })) @@ -193,8 +183,8 @@ describe('Cron cleanup endpoints', () => { expect(body.deleted).toEqual({ expired: 2, oldConfirmed: 2, total: 4 }) }) - it('surfaces supabase errors for old confirmations', async () => { - supabaseAdminMock.from + it('surfaces astro db errors for old confirmations', async () => { + dbDeleteMock .mockReturnValueOnce(createDeleteChain()) .mockReturnValueOnce(createDeleteChain({ error: { message: 'bad' } })) @@ -217,8 +207,6 @@ describe('Cron cleanup endpoints', () => { const run = (request: Request) => pingIntegrations(buildContext(request) as unknown as Parameters[0]) - let fetchMock: ReturnType - const buildRequest = (headers?: HeadersInit) => { const init: RequestInit = { method: 'GET' } if (typeof headers !== 'undefined') { @@ -227,46 +215,18 @@ describe('Cron cleanup endpoints', () => { return new Request('http://localhost/api/cron/ping-integrations', init) } - const createFetchResponse = (overrides?: Partial): Response => ({ - ok: true, - status: 200, - json: vi.fn().mockResolvedValue({ result: null }), - text: vi.fn().mockResolvedValue(''), - headers: new Headers(), - redirected: false, - statusText: 'OK', - type: 'basic', - url: getUpstashApiUrlMock(), - clone: vi.fn(() => createFetchResponse(overrides)), - body: null, - bodyUsed: false, - arrayBuffer: vi.fn(), - blob: vi.fn(), - formData: vi.fn(), - ...overrides, - }) as unknown as Response - - beforeEach(() => { - fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - it('rejects requests without valid secret', async () => { const response = await run(buildRequest()) const body = await response.json() expect(response.status).toBe(401) expect(body.error.code).toBe('UNAUTHORIZED') - expect(fetchMock).not.toHaveBeenCalled() }) - it('pings Upstash and Supabase once authorized', async () => { - fetchMock.mockResolvedValue(createFetchResponse()) - supabaseAdminMock.from.mockReturnValueOnce(createSelectChain({ count: 5 })) + it('pings Astro DB once authorized', async () => { + dbSelectMock.mockReturnValueOnce( + createSelectChain({ data: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }] }) + ) const response = await run( buildRequest({ @@ -277,42 +237,12 @@ describe('Cron cleanup endpoints', () => { expect(response.status).toBe(200) expect(body.success).toBe(true) - expect(typeof body.upstash.durationMs).toBe('number') - expect(body.supabase.rowsChecked).toBe(5) - expect(fetchMock).toHaveBeenCalledTimes(1) - - const fetchArgs = fetchMock.mock.calls[0]![1] as RequestInit | undefined - const headers = fetchArgs?.headers as Record | undefined - expect(headers?.['Authorization'] ?? headers?.['authorization']).toBe('Bearer upstash-token') - }) - - it('returns upstream error details when Upstash responds with failure', async () => { - fetchMock.mockResolvedValue( - createFetchResponse({ - ok: false, - status: 503, - statusText: 'Service Unavailable', - json: vi.fn().mockResolvedValue({ error: 'unavailable' }), - }), - ) - supabaseAdminMock.from.mockReturnValueOnce(createSelectChain()) - - const response = await run( - buildRequest({ - authorization: 'Bearer cron-secret', - }), - ) - const body = await response.json() - - expect(response.status).toBe(503) - expect(body.error.code).toBe('CRON_UPSTASH_PING_FAILED') + expect(typeof body.astroDb.durationMs).toBe('number') + expect(body.astroDb.rowsChecked).toBe(5) }) - it('surfaces Supabase errors when ping fails', async () => { - fetchMock.mockResolvedValue(createFetchResponse()) - supabaseAdminMock.from.mockReturnValueOnce( - createSelectChain({ error: { message: 'boom' } }), - ) + it('surfaces Astro DB errors when ping fails', async () => { + dbSelectMock.mockReturnValueOnce(createSelectChain({ error: { message: 'boom' } })) const response = await run( buildRequest({ @@ -322,7 +252,7 @@ describe('Cron cleanup endpoints', () => { const body = await response.json() expect(response.status).toBe(500) - expect(body.error.code).toBe('CRON_SUPABASE_PING_FAILED') + expect(body.error.code).toBe('CRON_ASTRO_DB_PING_FAILED') }) }) }) diff --git a/src/pages/api/cron/cleanup-confirmations.ts b/src/pages/api/cron/cleanup-confirmations.ts index 1c4a24f2f..6da7b7ae5 100644 --- a/src/pages/api/cron/cleanup-confirmations.ts +++ b/src/pages/api/cron/cleanup-confirmations.ts @@ -1,6 +1,6 @@ import type { APIRoute } from 'astro' +import { db, lt, newsletterConfirmations } from 'astro:db' import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { supabaseAdmin } from '@pages/api/_utils' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' @@ -56,49 +56,44 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { const now = new Date() const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) - // Delete expired tokens - const { data: expiredData, error: expiredError } = await supabaseAdmin - .from('newsletter_confirmations') - .delete() - .lt('expires_at', now.toISOString()) - .select() + let expiredCount = 0 + try { + const expiredData = await db + .delete(newsletterConfirmations) + .where(lt(newsletterConfirmations.expiresAt, now)) + .returning({ id: newsletterConfirmations.id }) - if (expiredError) { - throw new ApiFunctionError({ - message: `Failed to delete expired tokens: ${expiredError.message}`, - cause: expiredError, - code: 'CRON_DELETE_EXPIRED_TOKENS_FAILED', - status: 500, + expiredCount = expiredData.length + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'deleteExpiredTokens', + status: 500, + code: 'CRON_DELETE_EXPIRED_TOKENS_FAILED', }) } - // Delete old confirmed records (7+ days old) - const { data: confirmedData, error: confirmedError } = await supabaseAdmin - .from('newsletter_confirmations') - .delete() - .not('confirmed_at', 'is', null) - .lt('created_at', sevenDaysAgo.toISOString()) - .select() + let confirmedCount = 0 + try { + const confirmedData = await db + .delete(newsletterConfirmations) + .where(lt(newsletterConfirmations.confirmedAt, sevenDaysAgo)) + .returning({ id: newsletterConfirmations.id }) - if (confirmedError) { - throw new ApiFunctionError({ - message: `Failed to delete old confirmed records: ${confirmedError.message}`, - cause: confirmedError, - code: 'CRON_DELETE_CONFIRMED_TOKENS_FAILED', - status: 500, + confirmedCount = confirmedData.length + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'deleteConfirmedRecords', + status: 500, + code: 'CRON_DELETE_CONFIRMED_TOKENS_FAILED', }) } - const expiredCount = expiredData?.length || 0 - const confirmedCount = confirmedData?.length || 0 const totalDeleted = expiredCount + confirmedCount console.log( - `Newsletter confirmations cleanup: deleted ${expiredCount} expired + ${confirmedCount} old confirmed = ${totalDeleted} total` + `Newsletter confirmations cleanup: deleted ${expiredCount} expired + ${confirmedCount} old confirmed = ${totalDeleted} total`, ) return new Response( diff --git a/src/pages/api/cron/cleanup-dsar-requests.ts b/src/pages/api/cron/cleanup-dsar-requests.ts index 9c91b675b..c69a34a80 100644 --- a/src/pages/api/cron/cleanup-dsar-requests.ts +++ b/src/pages/api/cron/cleanup-dsar-requests.ts @@ -1,6 +1,6 @@ import type { APIRoute } from 'astro' +import { and, db, dsarRequests, isNull, lt } from 'astro:db' import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { supabaseAdmin } from '@pages/api/_utils' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' @@ -15,13 +15,6 @@ const buildErrorResponse = ( fallbackMessage: string, ) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage }) -/** - * Cron job to clean up DSAR requests - * - Removes fulfilled requests older than 30 days - * - Removes expired unfulfilled requests (created > 7 days ago AND fulfilled_at IS NULL) - * - * Scheduled via vercel.json to run daily - */ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { const { context: apiContext } = createApiFunctionContext({ route: ROUTE, @@ -56,50 +49,49 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) - // Delete old fulfilled requests (30+ days) - const { data: fulfilledData, error: fulfilledError } = await supabaseAdmin - .from('dsar_requests') - .delete() - .not('fulfilled_at', 'is', null) - .lt('fulfilled_at', thirtyDaysAgo.toISOString()) - .select() + let fulfilledCount = 0 + try { + const fulfilledData = await db + .delete(dsarRequests) + .where(lt(dsarRequests.fulfilledAt, thirtyDaysAgo)) + .returning({ id: dsarRequests.id }) - if (fulfilledError) { - throw new ApiFunctionError({ - message: `Failed to delete fulfilled requests: ${fulfilledError.message}`, - cause: fulfilledError, - code: 'CRON_DELETE_FULFILLED_DSAR_FAILED', - status: 500, + fulfilledCount = fulfilledData.length + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'deleteFulfilledRequests', + status: 500, + code: 'CRON_DELETE_FULFILLED_DSAR_FAILED', }) } - // Delete expired unfulfilled requests (7+ days old, never fulfilled) - const { data: expiredData, error: expiredError } = await supabaseAdmin - .from('dsar_requests') - .delete() - .is('fulfilled_at', null) - .lt('created_at', sevenDaysAgo.toISOString()) - .select() + let expiredCount = 0 + try { + const expiredData = await db + .delete(dsarRequests) + .where( + and( + isNull(dsarRequests.fulfilledAt), + lt(dsarRequests.createdAt, sevenDaysAgo), + ), + ) + .returning({ id: dsarRequests.id }) - if (expiredError) { - throw new ApiFunctionError({ - message: `Failed to delete expired requests: ${expiredError.message}`, - cause: expiredError, - code: 'CRON_DELETE_EXPIRED_DSAR_FAILED', - status: 500, + expiredCount = expiredData.length + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'deleteExpiredRequests', + status: 500, + code: 'CRON_DELETE_EXPIRED_DSAR_FAILED', }) } - const fulfilledCount = fulfilledData?.length || 0 - const expiredCount = expiredData?.length || 0 const totalDeleted = fulfilledCount + expiredCount console.log( - `DSAR requests cleanup: deleted ${fulfilledCount} old fulfilled + ${expiredCount} expired unfulfilled = ${totalDeleted} total` + `DSAR requests cleanup: deleted ${fulfilledCount} old fulfilled + ${expiredCount} expired unfulfilled = ${totalDeleted} total`, ) return new Response( diff --git a/src/pages/api/cron/ping-integrations.ts b/src/pages/api/cron/ping-integrations.ts index 48908319a..60e25f048 100644 --- a/src/pages/api/cron/ping-integrations.ts +++ b/src/pages/api/cron/ping-integrations.ts @@ -1,16 +1,12 @@ /** * Cron API Route to ping third-party integrations to keep them awake. - * Currently pings: - * - Upstash (key-value store) - * - Supabase (database) + * Currently pings Astro DB (newsletter confirmations table). */ import type { APIRoute } from 'astro' +import { db, newsletterConfirmations } from 'astro:db' import { getCronSecret, - getUpstashApiToken, - getUpstashApiUrl, } from '@pages/api/_environment/environmentApi' -import { supabaseAdmin } from '@pages/api/_utils' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' @@ -18,43 +14,6 @@ import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false const ROUTE = '/api/cron/ping-integrations' -const UPSTASH_KEY = '__cron_keepalive__' - -const decodeUpstashResult = (payload: unknown) => { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - return payload - } - - const typedPayload = payload as Record - const value = typedPayload['result'] - - if (typeof value !== 'string') { - return payload - } - - const candidate = value.trim() - const base64Pattern = /^[A-Za-z0-9+/]+={0,2}$/ - - if (!candidate || candidate.length % 4 !== 0 || !base64Pattern.test(candidate)) { - return payload - } - - try { - const decoded = Buffer.from(candidate, 'base64').toString('utf-8') - const reencoded = Buffer.from(decoded, 'utf-8').toString('base64') - - if (!decoded || reencoded !== candidate) { - return payload - } - - return { - ...typedPayload, - result: decoded, - } - } catch { - return payload - } -} const buildErrorResponse = ( error: unknown, @@ -62,61 +21,27 @@ const buildErrorResponse = ( fallbackMessage: string, ) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage }) -const pingUpstash = async () => { +const pingAstroDb = async () => { const start = Date.now() - const endpoint = new URL(`/get/${encodeURIComponent(UPSTASH_KEY)}`, getUpstashApiUrl()) - const response = await fetch(endpoint.toString(), { - headers: { - Authorization: `Bearer ${getUpstashApiToken()}`, - }, - }) - - if (!response.ok) { - throw new ApiFunctionError({ - message: `Upstash ping failed with status ${response.status}`, - status: response.status, - code: 'CRON_UPSTASH_PING_FAILED', - route: ROUTE, - operation: 'pingUpstash', - }) - } - - let payload: unknown - try { - payload = await response.json() - } catch { - payload = await response.text() - } + const data = await db + .select({ id: newsletterConfirmations.id }) + .from(newsletterConfirmations) + .limit(1) - return { - payload: decodeUpstashResult(payload), - durationMs: Date.now() - start, - } -} - -const pingSupabase = async () => { - const start = Date.now() - const { data, error, count } = await supabaseAdmin - .from('newsletter_confirmations') - .select('id', { count: 'exact' }) - .limit(1) - - if (error) { - throw new ApiFunctionError({ - message: `Supabase ping failed: ${error.message ?? 'Unknown error'}`, - cause: error, + return { + rowsChecked: data.length, + durationMs: Date.now() - start, + } + } catch (error) { + throw new ApiFunctionError(error, { + message: 'Astro DB ping failed', status: 500, - code: 'CRON_SUPABASE_PING_FAILED', + code: 'CRON_ASTRO_DB_PING_FAILED', route: ROUTE, - operation: 'pingSupabase', + operation: 'pingAstroDb', }) } - - return { - rowsChecked: typeof count === 'number' ? count : data?.length ?? 0, - durationMs: Date.now() - start, - } } export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { @@ -150,16 +75,12 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { } try { - const [upstashResult, supabaseResult] = await Promise.all([ - pingUpstash(), - pingSupabase(), - ]) + const astroDbResult = await pingAstroDb() return new Response( JSON.stringify({ success: true, - upstash: upstashResult, - supabase: supabaseResult, + astroDb: astroDbResult, timestamp: new Date().toISOString(), }), { diff --git a/src/pages/api/gdpr/__tests__/request-data.spec.ts b/src/pages/api/gdpr/__tests__/request-data.spec.ts new file mode 100644 index 000000000..432cfdff8 --- /dev/null +++ b/src/pages/api/gdpr/__tests__/request-data.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { AstroCookies } from 'astro' +import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts' + +const MOCK_TOKEN = 'mock-token' + +const mockCheckRateLimit = vi.fn() +const mockSendEmail = vi.fn() +const mockFindActiveRequest = vi.fn() +const mockCreateDsarRequest = vi.fn() + +vi.mock('uuid', () => ({ + v4: vi.fn(() => MOCK_TOKEN), +})) + +vi.mock('@pages/api/_utils', () => ({ + rateLimiters: { + export: { name: 'export' }, + }, + checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args), +})) + +vi.mock('@pages/api/gdpr/_dsarVerificationEmails', () => ({ + sendDSARVerificationEmail: (...args: unknown[]) => mockSendEmail(...args), +})) + +vi.mock('@pages/api/gdpr/_utils/dsarStore', () => ({ + findActiveRequestByEmail: (...args: unknown[]) => mockFindActiveRequest(...args), + createDsarRequest: (...args: unknown[]) => mockCreateDsarRequest(...args), +})) + +import { POST } from '../request-data' + +const defaultRequestBody: DSARRequestInput = { + email: 'User@Example.com', + requestType: 'ACCESS', +} + +const createRequest = (body: DSARRequestInput) => + new Request('http://localhost/api/gdpr/request-data', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }) + +const cookies = { + get: vi.fn(() => undefined), +} as unknown as AstroCookies + +type PostArgs = Parameters[0] + +const createContext = (overrides?: Partial) => ({ + request: createRequest(defaultRequestBody), + clientAddress: '127.0.0.1', + cookies, + ...overrides, +}) as PostArgs + +describe('POST /api/gdpr/request-data', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ success: true, reset: undefined }) + mockSendEmail.mockResolvedValue(undefined) + mockFindActiveRequest.mockResolvedValue(undefined) + mockCreateDsarRequest.mockResolvedValue(undefined) + }) + + it('reuses an existing DSAR token when one is still active', async () => { + mockFindActiveRequest.mockResolvedValue({ token: 'existing-token' }) + + const response = await POST(createContext()) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload).toEqual({ + success: true, + message: expect.stringContaining('Verification email sent'), + }) + expect(mockFindActiveRequest).toHaveBeenCalledWith('user@example.com', 'ACCESS') + expect(mockCreateDsarRequest).not.toHaveBeenCalled() + expect(mockSendEmail).toHaveBeenCalledWith('user@example.com', 'existing-token', 'ACCESS') + }) + + it('creates a new DSAR request when none exist', async () => { + mockFindActiveRequest.mockResolvedValue(undefined) + + const response = await POST( + createContext({ + request: createRequest({ + email: 'requester@example.com', + requestType: 'DELETE', + }), + }), + ) + const payload = await response.json() + + expect(response.status).toBe(201) + expect(payload).toEqual({ + success: true, + message: expect.stringContaining('Verification email sent'), + }) + expect(mockCreateDsarRequest).toHaveBeenCalledWith({ + token: MOCK_TOKEN, + email: 'requester@example.com', + requestType: 'DELETE', + expiresAt: expect.any(Date), + }) + expect(mockSendEmail).toHaveBeenLastCalledWith('requester@example.com', MOCK_TOKEN, 'DELETE') + }) +}) diff --git a/src/pages/api/gdpr/_utils/consentStore.ts b/src/pages/api/gdpr/_utils/consentStore.ts new file mode 100644 index 000000000..7c8362ab7 --- /dev/null +++ b/src/pages/api/gdpr/_utils/consentStore.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto' +import { and, consentEvents, db, desc, eq } from 'astro:db' + +type DbConsentRecord = typeof consentEvents.$inferSelect + +export type ConsentEventRecord = Omit & { + purposes: string[] +} + +const toConsentRecord = (record: DbConsentRecord): ConsentEventRecord => ({ + ...record, + purposes: record.purposes as string[], +}) + +export type CreateConsentRecordInput = { + dataSubjectId: string + email: string | null + purposes: string[] + source: string + userAgent: string + ipAddress: string | null + privacyPolicyVersion: string + consentText: string | null + verified: boolean +} + +export async function createConsentRecord(input: CreateConsentRecordInput): Promise { + const [record] = await db + .insert(consentEvents) + .values({ + id: randomUUID(), + ...input, + createdAt: new Date(), + }) + .returning() + + if (!record) { + throw new Error('Failed to create consent record') + } + + return toConsentRecord(record) +} + +export async function findConsentRecords(dataSubjectId: string): Promise { + const records = await db + .select() + .from(consentEvents) + .where(eq(consentEvents.dataSubjectId, dataSubjectId)) + .orderBy(desc(consentEvents.createdAt)) + + return records.map(toConsentRecord) +} + +export async function deleteConsentRecords(dataSubjectId: string): Promise { + const deleted = await db + .delete(consentEvents) + .where(eq(consentEvents.dataSubjectId, dataSubjectId)) + .returning({ id: consentEvents.id }) + + return deleted.length +} + +const normalizeEmail = (email: string): string => email.trim().toLowerCase() + +export async function findConsentRecordsByEmail(email: string): Promise { + const normalizedEmail = normalizeEmail(email) + const records = await db + .select() + .from(consentEvents) + .where(eq(consentEvents.email, normalizedEmail)) + .orderBy(desc(consentEvents.createdAt)) + + return records.map(toConsentRecord) +} + +export async function deleteConsentRecordsByEmail(email: string): Promise { + const normalizedEmail = normalizeEmail(email) + const deleted = await db + .delete(consentEvents) + .where(eq(consentEvents.email, normalizedEmail)) + .returning({ id: consentEvents.id }) + + return deleted.length +} + +export async function markConsentRecordsVerified( + email: string, + dataSubjectId: string, +): Promise { + const normalizedEmail = normalizeEmail(email) + const updated = await db + .update(consentEvents) + .set({ verified: true }) + .where( + and( + eq(consentEvents.email, normalizedEmail), + eq(consentEvents.dataSubjectId, dataSubjectId), + eq(consentEvents.verified, false), + ), + ) + .returning({ id: consentEvents.id }) + + return updated.length +} diff --git a/src/pages/api/gdpr/_utils/dsarStore.ts b/src/pages/api/gdpr/_utils/dsarStore.ts new file mode 100644 index 000000000..81df67872 --- /dev/null +++ b/src/pages/api/gdpr/_utils/dsarStore.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto' +import { and, db, dsarRequests, eq, gt, isNull } from 'astro:db' +import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts' + +export type DsarRequestRecord = typeof dsarRequests.$inferSelect + +type RequestType = DSARRequestInput['requestType'] + +export type CreateDsarRequestInput = { + token: string + email: string + requestType: RequestType + expiresAt: Date +} + +export async function findActiveRequestByEmail( + email: string, + requestType: RequestType, +): Promise { + const [record] = await db + .select() + .from(dsarRequests) + .where( + and( + eq(dsarRequests.email, email), + eq(dsarRequests.requestType, requestType), + isNull(dsarRequests.fulfilledAt), + gt(dsarRequests.expiresAt, new Date()), + ), + ) + .limit(1) + + return record +} + +export async function createDsarRequest( + input: CreateDsarRequestInput, +): Promise { + const [record] = await db + .insert(dsarRequests) + .values({ + id: randomUUID(), + token: input.token, + email: input.email, + requestType: input.requestType, + expiresAt: input.expiresAt, + createdAt: new Date(), + }) + .returning() + + if (!record) { + throw new Error('Failed to create DSAR request') + } + + return record +} + +export async function findDsarRequestByToken(token: string): Promise { + const [record] = await db + .select() + .from(dsarRequests) + .where(eq(dsarRequests.token, token)) + .limit(1) + + return record +} + +export async function markDsarRequestFulfilled(token: string): Promise { + await db + .update(dsarRequests) + .set({ fulfilledAt: new Date() }) + .where(eq(dsarRequests.token, token)) +} diff --git a/src/pages/api/gdpr/consent.ts b/src/pages/api/gdpr/consent.ts index 05fbe696c..b8736e769 100644 --- a/src/pages/api/gdpr/consent.ts +++ b/src/pages/api/gdpr/consent.ts @@ -1,12 +1,18 @@ import { randomUUID } from 'node:crypto' import type { APIRoute } from 'astro' import { getPrivacyPolicyVersion, isSupabaseFallbackEnabled } from '@pages/api/_environment/environmentApi' -import { rateLimiters, checkRateLimit, supabaseAdmin } from '@pages/api/_utils' +import { rateLimiters, checkRateLimit } from '@pages/api/_utils' import { validate as uuidValidate } from 'uuid' import type { ConsentRequest, ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' +import { + createConsentRecord, + deleteConsentRecords, + findConsentRecords, + type ConsentEventRecord, +} from '@pages/api/gdpr/_utils/consentStore' export const prerender = false // Force SSR for this endpoint @@ -42,20 +48,6 @@ const normalizeNullableString = (value?: string | null): string | null => { const normalizeUserAgent = (value?: string | null): string => normalizeNullableString(value) ?? DEFAULT_USER_AGENT -type ConsentRecordRow = { - id: string - data_subject_id: string - email: string | null - purposes: string[] - timestamp: string - source: string | null - user_agent: string | null - ip_address: string | null - privacy_policy_version: string | null - consent_text: string | null - verified: boolean -} - const jsonResponse = (body: unknown, status: number, headers?: Record) => new Response(JSON.stringify(body), { status, @@ -76,19 +68,19 @@ const buildRateLimitError = (reset: number | undefined, message?: string) => { }) } -const mapConsentRecord = (record: ConsentRecordRow): ConsentResponse['record'] => { +const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] => { const normalizedEmail = normalizeNullableString(record.email) - const normalizedIpAddress = normalizeNullableString(record.ip_address) - const normalizedConsentText = normalizeNullableString(record.consent_text) + const normalizedIpAddress = normalizeNullableString(record.ipAddress) + const normalizedConsentText = normalizeNullableString(record.consentText) const mapped: ConsentResponse['record'] = { id: record.id, - DataSubjectId: record.data_subject_id, + DataSubjectId: record.dataSubjectId, purposes: sanitizePurposes(record.purposes), - timestamp: record.timestamp, + timestamp: record.createdAt instanceof Date ? record.createdAt.toISOString() : new Date(record.createdAt).toISOString(), source: sanitizeSource(record.source), - userAgent: normalizeUserAgent(record.user_agent), - privacyPolicyVersion: record.privacy_policy_version ?? getPrivacyPolicyVersion(), + userAgent: normalizeUserAgent(record.userAgent), + privacyPolicyVersion: record.privacyPolicyVersion ?? getPrivacyPolicyVersion(), verified: record.verified, } @@ -199,23 +191,20 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { let record: ConsentResponse['record'] try { - const { data, error } = await supabaseAdmin - .from('consent_records') - .insert({ - data_subject_id: body.DataSubjectId, - email: normalizedEmail, - purposes: normalizedPurposes, - source: normalizedSource, - user_agent: normalizedUserAgent, - ip_address: normalizedIpAddress, - privacy_policy_version: getPrivacyPolicyVersion(), - consent_text: normalizedConsentText, - verified: body.verified ?? false, - }) - .select() - .single() - - if (error) { + const dbRecord = await createConsentRecord({ + dataSubjectId: body.DataSubjectId, + email: normalizedEmail, + purposes: normalizedPurposes, + source: normalizedSource, + userAgent: normalizedUserAgent, + ipAddress: normalizedIpAddress, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: normalizedConsentText, + verified: body.verified ?? false, + }) + record = mapConsentRecord(dbRecord) + } catch (error) { + if (!isSupabaseFallbackEnabled()) { throw new ApiFunctionError(error, { route: ROUTE, operation: 'insert-consent', @@ -226,13 +215,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { }, }) } - - record = mapConsentRecord(data as ConsentRecordRow) - } catch (error) { - if (!isSupabaseFallbackEnabled()) { - throw error - } - console.warn('[gdpr/consent] Supabase unavailable, returning mocked consent record for e2e tests.') + console.warn('[gdpr/consent] Astro DB unavailable, returning mocked consent record for e2e tests.') record = buildMockConsentRecord(body) } @@ -276,18 +259,10 @@ export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => }) } - let query = supabaseAdmin - .from('consent_records') - .select('*') - .eq('data_subject_id', DataSubjectId) - - if (purpose) { - query = query.contains('purposes', [purpose]) - } - - const { data, error } = await query - - if (error) { + let fetchRecords: ConsentEventRecord[] + try { + fetchRecords = await findConsentRecords(DataSubjectId) + } catch (error) { throw new ApiFunctionError(error, { route: ROUTE, operation: 'GET.fetch-consent-records', @@ -298,8 +273,10 @@ export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => }, }) } - - const records = data.map(record => mapConsentRecord(record as ConsentRecordRow)) + const filteredRecords = purpose + ? fetchRecords.filter(record => record.purposes.includes(purpose)) + : fetchRecords + const records = filteredRecords.map(mapConsentRecord) return jsonResponse( { @@ -347,13 +324,10 @@ export const DELETE: APIRoute = async ({ clientAddress, url, request, cookies }) }) } - const { data, error } = await supabaseAdmin - .from('consent_records') - .delete() - .eq('data_subject_id', DataSubjectId) - .select() - - if (error) { + let deletedCount: number + try { + deletedCount = await deleteConsentRecords(DataSubjectId) + } catch (error) { throw new ApiFunctionError(error, { route: ROUTE, operation: 'DELETE.remove-consent', @@ -367,7 +341,7 @@ export const DELETE: APIRoute = async ({ clientAddress, url, request, cookies }) return jsonResponse( { success: true, - deletedCount: data?.length || 0, + deletedCount, }, 200, ) diff --git a/src/pages/api/gdpr/export.ts b/src/pages/api/gdpr/export.ts index 7310b381d..93b4c2f48 100644 --- a/src/pages/api/gdpr/export.ts +++ b/src/pages/api/gdpr/export.ts @@ -1,9 +1,10 @@ import type { APIRoute } from 'astro' -import { rateLimiters, checkRateLimit, supabaseAdmin } from '@pages/api/_utils' +import { rateLimiters, checkRateLimit } from '@pages/api/_utils' import { validate as uuidValidate } from 'uuid' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' +import { findConsentRecords } from '@pages/api/gdpr/_utils/consentStore' export const prerender = false // Force SSR for this endpoint @@ -68,24 +69,19 @@ export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => }) } - const { data, error } = await supabaseAdmin - .from('consent_records') - .select('*') - .eq('data_subject_id', DataSubjectId) - - if (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'GET.fetch-consent-records', - status: 500, - details: { - dataSubjectId: DataSubjectId, - }, - }) - } - - // Remove sensitive fields (ip_address) - const exportData = data.map(({ ip_address: _ip, ...record }) => record) + const consentRecords = await findConsentRecords(DataSubjectId) + const exportData = consentRecords.map((record) => ({ + id: record.id, + data_subject_id: record.dataSubjectId, + email: record.email, + purposes: record.purposes, + source: record.source, + user_agent: record.userAgent, + privacy_policy_version: record.privacyPolicyVersion, + consent_text: record.consentText, + verified: record.verified, + created_at: record.createdAt.toISOString(), + })) return new Response(JSON.stringify(exportData, null, 2), { status: 200, diff --git a/src/pages/api/gdpr/request-data.ts b/src/pages/api/gdpr/request-data.ts index b1a88867b..3371baccb 100644 --- a/src/pages/api/gdpr/request-data.ts +++ b/src/pages/api/gdpr/request-data.ts @@ -1,11 +1,12 @@ import type { APIRoute } from 'astro' import { v4 as uuidv4 } from 'uuid' -import { rateLimiters, checkRateLimit, supabaseAdmin } from '@pages/api/_utils' +import { rateLimiters, checkRateLimit } from '@pages/api/_utils' import { sendDSARVerificationEmail } from '@pages/api/gdpr/_dsarVerificationEmails' import type { DSARRequestInput, DSARResponse } from '@pages/api/_contracts/gdpr.contracts' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' +import { createDsarRequest, findActiveRequestByEmail } from '@pages/api/gdpr/_utils/dsarStore' export const prerender = false // Force SSR for this endpoint @@ -109,18 +110,11 @@ export const POST: APIRoute = async ({ request, clientAddress, cookies }) => { const token = uuidv4() const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - // Check for existing unfulfilled request for this email - const { data: existing, error: existingError } = await supabaseAdmin - .from('dsar_requests') - .select('*') - .eq('email', email) - .eq('request_type', body.requestType) - .is('fulfilled_at', null) - .gt('expires_at', new Date().toISOString()) - .maybeSingle() - - if (existingError) { - throw new ApiFunctionError(existingError, { + let existingRequest + try { + existingRequest = await findActiveRequestByEmail(email, body.requestType) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'fetch-existing-request', status: 500, @@ -131,9 +125,9 @@ export const POST: APIRoute = async ({ request, clientAddress, cookies }) => { }) } - if (existing) { + if (existingRequest) { // Resend verification email with existing token - await sendDSARVerificationEmail(email, existing.token, body.requestType) + await sendDSARVerificationEmail(email, existingRequest.token, body.requestType) return jsonResponse( { @@ -144,17 +138,14 @@ export const POST: APIRoute = async ({ request, clientAddress, cookies }) => { ) } - // Create new DSAR request (database uses snake_case columns) - const { error } = await supabaseAdmin - .from('dsar_requests') - .insert({ + try { + await createDsarRequest({ token, email, - request_type: body.requestType, - expires_at: expiresAt.toISOString() + requestType: body.requestType, + expiresAt, }) - - if (error) { + } catch (error) { throw new ApiFunctionError(error, { route: ROUTE, operation: 'create-request', diff --git a/src/pages/api/gdpr/verify.ts b/src/pages/api/gdpr/verify.ts index 8bdcc7a7b..80bbf4c05 100644 --- a/src/pages/api/gdpr/verify.ts +++ b/src/pages/api/gdpr/verify.ts @@ -1,9 +1,18 @@ import type { APIRoute } from 'astro' -import { rateLimiters, checkRateLimit, supabaseAdmin } from '@pages/api/_utils' +import { rateLimiters, checkRateLimit } from '@pages/api/_utils' import type { DSARRequest } from '@pages/api/_contracts/gdpr.contracts' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' +import { + deleteConsentRecordsByEmail, + findConsentRecordsByEmail, +} from '@pages/api/gdpr/_utils/consentStore' +import { + findDsarRequestByToken, + markDsarRequestFulfilled, +} from '@pages/api/gdpr/_utils/dsarStore' +import { deleteNewsletterConfirmationsByEmail } from '@pages/api/newsletter/_token' export const prerender = false // Force SSR for this endpoint @@ -78,37 +87,20 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect apiContext.extra = { ...(apiContext.extra || {}), token } try { - // Find the DSAR request - const { data: dsarRequestData, error: fetchError } = await supabaseAdmin - .from('dsar_requests') - .select('*') - .eq('token', token) - .maybeSingle() - - if (fetchError) { - throw new ApiFunctionError(fetchError, { - route: ROUTE, - operation: 'fetch-request', - status: 500, - details: { - token, - }, - }) - } + const dbRequest = await findDsarRequestByToken(token) - if (!dsarRequestData) { + if (!dbRequest) { return redirect('/privacy/my-data?status=invalid') } - // Type the DSAR request data properly const dsarRequest: DSARRequest = { - id: dsarRequestData.id, - token: dsarRequestData.token, - email: dsarRequestData.email, - requestType: dsarRequestData.request_type, - expiresAt: dsarRequestData.expires_at, - fulfilledAt: dsarRequestData.fulfilled_at, - createdAt: dsarRequestData.created_at, + id: dbRequest.id, + token: dbRequest.token, + email: dbRequest.email, + requestType: dbRequest.requestType as DSARRequest['requestType'], + expiresAt: dbRequest.expiresAt.toISOString(), + fulfilledAt: dbRequest.fulfilledAt?.toISOString(), + createdAt: dbRequest.createdAt.toISOString(), } // Check if already fulfilled @@ -125,14 +117,11 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect const requestType = dsarRequest.requestType if (requestType === 'ACCESS') { - // Export all consent data for this email - const { data: consentRecords, error: consentError } = await supabaseAdmin - .from('consent_records') - .select('*') - .eq('email', email) - - if (consentError) { - throw new ApiFunctionError(consentError, { + let consentRecords + try { + consentRecords = await findConsentRecordsByEmail(email) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'fetch-consent-records', status: 500, @@ -142,14 +131,10 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect }) } - // Mark request as fulfilled - const { error: fulfillError } = await supabaseAdmin - .from('dsar_requests') - .update({ fulfilled_at: new Date().toISOString() }) - .eq('token', token) - - if (fulfillError) { - throw new ApiFunctionError(fulfillError, { + try { + await markDsarRequestFulfilled(token) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'mark-request-fulfilled', status: 500, @@ -164,7 +149,10 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect const exportData = { email, requestDate: dsarRequest.createdAt, - consentRecords: consentRecords?.map(({ ip_address: _ip, ...record }) => record) || [] + consentRecords: consentRecords.map(({ ipAddress: _ip, ...record }) => ({ + ...record, + createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, + })), } return new Response(JSON.stringify(exportData, null, 2), { @@ -175,14 +163,10 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect } }) } else if (requestType === 'DELETE') { - // Delete all consent records for this email - const { error: deleteConsentsError } = await supabaseAdmin - .from('consent_records') - .delete() - .eq('email', email) - - if (deleteConsentsError) { - throw new ApiFunctionError(deleteConsentsError, { + try { + await deleteConsentRecordsByEmail(email) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'delete-consent-records', status: 500, @@ -192,14 +176,10 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect }) } - // Delete newsletter confirmations - const { error: deleteConfirmationsError } = await supabaseAdmin - .from('newsletter_confirmations') - .delete() - .eq('email', email) - - if (deleteConfirmationsError) { - throw new ApiFunctionError(deleteConfirmationsError, { + try { + await deleteNewsletterConfirmationsByEmail(email) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'delete-newsletter-confirmations', status: 500, @@ -209,14 +189,10 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect }) } - // Mark request as fulfilled - const { error: deleteFulfillError } = await supabaseAdmin - .from('dsar_requests') - .update({ fulfilled_at: new Date().toISOString() }) - .eq('token', token) - - if (deleteFulfillError) { - throw new ApiFunctionError(deleteFulfillError, { + try { + await markDsarRequestFulfilled(token) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'mark-delete-request-fulfilled', status: 500, diff --git a/src/pages/api/newsletter/__tests__/_confirm.spec.ts b/src/pages/api/newsletter/__tests__/_confirm.spec.ts index 81425eec5..6f384cb7e 100644 --- a/src/pages/api/newsletter/__tests__/_confirm.spec.ts +++ b/src/pages/api/newsletter/__tests__/_confirm.spec.ts @@ -3,11 +3,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' import type { APIContext } from 'astro' +import type { PendingSubscription } from '@pages/api/newsletter/_token' import { TestError } from '@test/errors' import { GET } from '@pages/api/newsletter/confirm' -const supabaseMocks = vi.hoisted(() => ({ - supabaseFromMock: vi.fn(), +const consentMocks = vi.hoisted(() => ({ + markConsentRecordsVerified: vi.fn(), })) // Mock dependencies @@ -19,29 +20,7 @@ vi.mock('@pages/api/newsletter/_email', () => ({ sendWelcomeEmail: vi.fn(), })) -const createSupabaseQueryBuilder = () => { - const builder = { - update: vi.fn(), - eq: vi.fn(), - contains: vi.fn(), - } - - builder.update.mockReturnThis() - builder.eq.mockReturnThis() - builder.contains.mockResolvedValue({ error: null }) - - return builder -} - -let supabaseQueryBuilder = createSupabaseQueryBuilder() - -vi.mock('@pages/api/_utils', () => ({ - supabaseAdmin: { - from: supabaseMocks.supabaseFromMock, - }, -})) - -const supabaseFromMock = supabaseMocks.supabaseFromMock +vi.mock('@pages/api/gdpr/_utils/consentStore', () => consentMocks) const createRequestContext = (inputUrl: string): APIContext => { const url = new URL(inputUrl) @@ -68,10 +47,27 @@ vi.mock('@pages/api/newsletter/index', () => ({ const tokenModule = await import('@pages/api/newsletter/_token') const emailModule = await import('@pages/api/newsletter/_email') const convertKitModule = await import('@pages/api/newsletter/index') +const consentStoreModule = await import('@pages/api/gdpr/_utils/consentStore') const mockConfirmSubscription = tokenModule.confirmSubscription as Mock const mockSendWelcomeEmail = emailModule.sendWelcomeEmail as Mock const mockSubscribeToConvertKit = convertKitModule.subscribeToConvertKit as Mock +const mockMarkConsentRecordsVerified = consentStoreModule.markConsentRecordsVerified as Mock + +const buildSubscription = (overrides: Partial = {}): PendingSubscription => ({ + email: 'test@example.com', + firstName: 'John', + DataSubjectId: 'data-subject-123', + token: 'valid-token-123', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + consentTimestamp: new Date().toISOString(), + userAgent: 'Test Browser', + ipAddress: '192.168.1.1', + verified: true, + source: 'newsletter_form', + ...overrides, +}) describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { beforeEach(() => { @@ -81,9 +77,8 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {}) - supabaseQueryBuilder = createSupabaseQueryBuilder() - supabaseFromMock.mockReturnValue(supabaseQueryBuilder) mockSendWelcomeEmail.mockResolvedValue(undefined) + mockMarkConsentRecordsVerified.mockResolvedValue(1) }) afterEach(() => { @@ -91,18 +86,7 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { }) it('should confirm valid token and activate subscription', async () => { - const mockSubscription = { - email: 'test@example.com', - firstName: 'John', - token: 'valid-token-123', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - consentTimestamp: new Date().toISOString(), - userAgent: 'Test Browser', - ipAddress: '192.168.1.1', - verified: true, - source: 'newsletter_form' as const, - } + const mockSubscription = buildSubscription() mockConfirmSubscription.mockResolvedValue(mockSubscription) @@ -115,9 +99,9 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { expect(data.email).toBe('test@example.com') expect(data.message).toContain('confirmed') - // Verify Supabase consent update call - expect(supabaseFromMock).toHaveBeenCalledWith('consent_records') - expect(supabaseQueryBuilder.update).toHaveBeenCalledWith({ verified: true }) + // Verify consent verification helper call + expect(mockMarkConsentRecordsVerified).toHaveBeenCalledWith('test@example.com', 'data-subject-123') + expect(mockMarkConsentRecordsVerified).toHaveBeenCalledTimes(1) // Verify welcome email was sent (force mock disabled by default) expect(mockSendWelcomeEmail).toHaveBeenCalledWith( @@ -153,16 +137,7 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { }) it('should handle subscription without firstName', async () => { - const mockSubscription = { - email: 'test@example.com', - token: 'valid-token-123', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - consentTimestamp: new Date().toISOString(), - userAgent: 'Test Browser', - verified: true, - source: 'newsletter_form' as const, - } + const mockSubscription = buildSubscription({ firstName: undefined }) mockConfirmSubscription.mockResolvedValue(mockSubscription) @@ -179,38 +154,18 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { }) it('should handle subscription without ipAddress', async () => { - const mockSubscription = { - email: 'test@example.com', - firstName: 'John', - token: 'valid-token-123', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - consentTimestamp: new Date().toISOString(), - userAgent: 'Test Browser', - verified: true, - source: 'newsletter_form' as const, - } + const mockSubscription = buildSubscription({ ipAddress: undefined }) mockConfirmSubscription.mockResolvedValue(mockSubscription) const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) expect(response.status).toBe(200) - expect(supabaseFromMock).toHaveBeenCalledWith('consent_records') + expect(mockMarkConsentRecordsVerified).toHaveBeenCalledWith('test@example.com', 'data-subject-123') }) it('should continue even if welcome email fails', async () => { - const mockSubscription = { - email: 'test@example.com', - firstName: 'John', - token: 'valid-token-123', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - consentTimestamp: new Date().toISOString(), - userAgent: 'Test Browser', - verified: true, - source: 'newsletter_form' as const, - } + const mockSubscription = buildSubscription() mockConfirmSubscription.mockResolvedValue(mockSubscription) mockSendWelcomeEmail.mockRejectedValue(new TestError('Email service down')) @@ -233,4 +188,17 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { expect(body.error).toBeDefined() expect(body.error.message).toContain('Unable to confirm subscription.') }) + + it('should surface errors when consent verification fails', async () => { + const mockSubscription = buildSubscription() + mockConfirmSubscription.mockResolvedValue(mockSubscription) + mockMarkConsentRecordsVerified.mockRejectedValue(new TestError('Consent DB offline')) + + const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error).toBeDefined() + expect(body.error.message).toContain('Unable to confirm subscription.') + }) }) diff --git a/src/pages/api/newsletter/_token.ts b/src/pages/api/newsletter/_token.ts index 4fa978aa3..02978436a 100644 --- a/src/pages/api/newsletter/_token.ts +++ b/src/pages/api/newsletter/_token.ts @@ -3,8 +3,8 @@ * Generates and validates confirmation tokens for GDPR-compliant newsletter signups */ -import { supabaseAdmin } from '@pages/api/_utils' -import { isSupabaseFallbackEnabled } from '@pages/api/_environment/environmentApi' +import { randomUUID } from 'node:crypto' +import { and, db, eq, isNull, newsletterConfirmations } from 'astro:db' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' /** @@ -12,14 +12,14 @@ import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' */ export interface PendingSubscription { email: string - firstName?: string + firstName?: string | undefined DataSubjectId: string token: string createdAt: string // ISO 8601 expiresAt: string // ISO 8601 - 24 hours from creation consentTimestamp: string // ISO 8601 userAgent: string - ipAddress?: string // Optional, for fraud prevention only + ipAddress?: string | undefined // Optional, for fraud prevention only verified: boolean source: 'newsletter_form' | 'contact_form' } @@ -74,33 +74,30 @@ export async function createPendingSubscription(data: { source: data.source, } - // Store in Supabase newsletter_confirmations table try { - const { error } = await supabaseAdmin - .from('newsletter_confirmations') - .insert({ - token, - email: pending.email, - data_subject_id: pending.DataSubjectId, - expires_at: expiresAt.toISOString() - }) - - if (error) { - throw error - } + await db.insert(newsletterConfirmations).values({ + id: randomUUID(), + token, + email: pending.email, + dataSubjectId: pending.DataSubjectId, + firstName: pending.firstName ?? null, + source: pending.source, + userAgent: pending.userAgent, + ipAddress: pending.ipAddress ?? null, + consentTimestamp: new Date(pending.consentTimestamp), + expiresAt, + confirmedAt: null, + createdAt: now, + }) } catch (error) { - if (!isSupabaseFallbackEnabled()) { - console.error('Failed to create pending subscription:', error) - throw new ApiFunctionError({ - message: 'Failed to create subscription confirmation', - cause: error, - code: 'NEWSLETTER_TOKEN_CREATE_FAILED', - status: 500, - route: '/api/newsletter', - operation: 'createPendingSubscription' - }) - } - console.warn('[newsletter] Supabase unavailable, storing pending subscription in memory for e2e tests.') + throw new ApiFunctionError({ + message: 'Failed to create subscription confirmation', + cause: error, + code: 'NEWSLETTER_TOKEN_CREATE_FAILED', + status: 500, + route: '/api/newsletter', + operation: 'createPendingSubscription', + }) } // Also keep in memory for backward compatibility (for now) @@ -119,34 +116,37 @@ export async function createPendingSubscription(data: { export async function validateToken( token: string, ): Promise { - // Try Supabase first - const { data: dbRecord } = await supabaseAdmin - .from('newsletter_confirmations') - .select('*') - .eq('token', token) - .is('confirmed_at', null) // Not yet confirmed - .single() + const [dbRecord] = await db + .select() + .from(newsletterConfirmations) + .where( + and( + eq(newsletterConfirmations.token, token), + isNull(newsletterConfirmations.confirmedAt), + ), + ) + .limit(1) if (dbRecord) { const now = new Date() - const expiresAt = new Date(dbRecord.expires_at) + const expiresAt = new Date(dbRecord.expiresAt) if (now > expiresAt) { - // Token expired return null } - // Convert to PendingSubscription format return { email: dbRecord.email, - DataSubjectId: dbRecord.data_subject_id, + firstName: dbRecord.firstName ?? undefined, + DataSubjectId: dbRecord.dataSubjectId, token: dbRecord.token, - createdAt: dbRecord.created_at, - expiresAt: dbRecord.expires_at, - consentTimestamp: dbRecord.created_at, - userAgent: 'unknown', // Not stored in DB + createdAt: dbRecord.createdAt.toISOString(), + expiresAt: dbRecord.expiresAt.toISOString(), + consentTimestamp: dbRecord.consentTimestamp.toISOString(), + userAgent: dbRecord.userAgent ?? 'unknown', + ipAddress: dbRecord.ipAddress ?? undefined, verified: false, - source: 'newsletter_form', + source: dbRecord.source as PendingSubscription['source'], } } @@ -188,11 +188,10 @@ export async function confirmSubscription( return null } - // Mark as confirmed in Supabase - await supabaseAdmin - .from('newsletter_confirmations') - .update({ confirmed_at: new Date().toISOString() }) - .eq('token', token) + await db + .update(newsletterConfirmations) + .set({ confirmedAt: new Date() }) + .where(eq(newsletterConfirmations.token, token)) // Mark as verified pending.verified = true @@ -225,3 +224,13 @@ function cleanExpiredTokens(): void { export function getPendingCount(): number { return pendingSubscriptions.size } + +export async function deleteNewsletterConfirmationsByEmail(email: string): Promise { + const normalizedEmail = email.trim().toLowerCase() + const deleted = await db + .delete(newsletterConfirmations) + .where(eq(newsletterConfirmations.email, normalizedEmail)) + .returning({ id: newsletterConfirmations.id }) + + return deleted.length +} diff --git a/src/pages/api/newsletter/confirm.ts b/src/pages/api/newsletter/confirm.ts index 12784fccf..2fe339318 100644 --- a/src/pages/api/newsletter/confirm.ts +++ b/src/pages/api/newsletter/confirm.ts @@ -4,10 +4,10 @@ * This is an Astro API route that runs server-side */ import type { APIRoute } from 'astro' -import { supabaseAdmin } from '@pages/api/_utils' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' +import { markConsentRecordsVerified } from '@pages/api/gdpr/_utils/consentStore' // These imports work in Astro API routes because they run server-side import { confirmSubscription } from './_token' @@ -61,18 +61,10 @@ export const GET: APIRoute = async ({ url, request, cookies, clientAddress }) => ) } - // Mark consent as verified in Supabase - // Find the most recent unverified consent record for this email and DataSubjectId - const { error: consentError } = await supabaseAdmin - .from('consent_records') - .update({ verified: true }) - .eq('email', subscription.email) - .eq('data_subject_id', subscription.DataSubjectId) - .eq('verified', false) - .contains('purposes', ['marketing']) - - if (consentError) { - throw new ApiFunctionError(consentError, { + try { + await markConsentRecordsVerified(subscription.email, subscription.DataSubjectId) + } catch (error) { + throw new ApiFunctionError(error, { route: ROUTE, operation: 'verify-consent-record', status: 500, diff --git a/src/pages/api/newsletter/index.ts b/src/pages/api/newsletter/index.ts index ced7de457..fa515227d 100644 --- a/src/pages/api/newsletter/index.ts +++ b/src/pages/api/newsletter/index.ts @@ -267,7 +267,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { ...(firstName && { firstName }), DataSubjectId: subjectId, userAgent, - ...(clientAddress && clientAddress !== 'unknown' && { clientAddress }), + ...(clientAddress && clientAddress !== 'unknown' && { ipAddress: clientAddress }), source: 'newsletter_form', }) diff --git a/test/e2e/helpers/cronHealth.ts b/test/e2e/helpers/cronHealth.ts index 48bc81246..babf87b3a 100644 --- a/test/e2e/helpers/cronHealth.ts +++ b/test/e2e/helpers/cronHealth.ts @@ -51,7 +51,6 @@ const withRetries = async (action: () => Promise, label: string, options?: } const buildSupabaseHealthUrl = (baseUrl: string) => new URL('/rest/v1/?select=1', baseUrl).toString() -const buildUpstashCommandUrl = (baseUrl: string) => new URL('/', baseUrl).toString() const ensureSupabaseReady = async (supabaseUrl: string, serviceRoleKey: string, options?: RetryOptions) => { const healthUrl = buildSupabaseHealthUrl(supabaseUrl) @@ -77,48 +76,15 @@ const ensureSupabaseReady = async (supabaseUrl: string, serviceRoleKey: string, ) } -const ensureUpstashReady = async (upstashUrl: string, upstashToken: string, options?: RetryOptions) => { - const commandUrl = buildUpstashCommandUrl(upstashUrl) - await withRetries( - async () => { - const response = await fetchWithTimeout( - commandUrl, - { - method: 'POST', - headers: { - Authorization: `Bearer ${upstashToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(['PING']), - }, - options?.timeoutMs ?? DEFAULT_OPTIONS.timeoutMs - ) - - if (!response.ok) { - throw new Error(`Upstash responded with status ${response.status}`) - } - }, - 'Upstash REST API', - options - ) -} - export interface CronDependencyConfig extends RetryOptions { supabaseUrl: string supabaseServiceKey: string - upstashUrl: string - upstashToken: string } export async function ensureCronDependenciesHealthy({ supabaseUrl, supabaseServiceKey, - upstashUrl, - upstashToken, ...options }: CronDependencyConfig): Promise { - await Promise.all([ - ensureSupabaseReady(supabaseUrl, supabaseServiceKey, options), - ensureUpstashReady(upstashUrl, upstashToken, options), - ]) + await ensureSupabaseReady(supabaseUrl, supabaseServiceKey, options) } diff --git a/test/e2e/specs/15-cron/cron.spec.ts b/test/e2e/specs/15-cron/cron.spec.ts index f10895149..1040995b6 100644 --- a/test/e2e/specs/15-cron/cron.spec.ts +++ b/test/e2e/specs/15-cron/cron.spec.ts @@ -10,15 +10,11 @@ import { getCronSecret, getSuprabaseApiUrl, getSuprabaseApiServiceRoleKey, - getUpstashApiUrl, - getUpstashApiToken, } from '@pages/api/_environment/environmentApi' const CRON_SECRET = getCronSecret() const SUPABASE_URL = getSuprabaseApiUrl() const SUPABASE_SERVICE_ROLE_KEY = getSuprabaseApiServiceRoleKey() -const UPSTASH_URL = getUpstashApiUrl() -const UPSTASH_TOKEN = getUpstashApiToken() const requiredEnvMissing = () => { if (!mocksEnabled) { @@ -30,9 +26,6 @@ const requiredEnvMissing = () => { if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { return 'Supabase admin credentials are required to seed cron fixtures' } - if (!UPSTASH_URL || !UPSTASH_TOKEN) { - return 'Upstash REST credentials are required for cron ping coverage' - } return null } @@ -44,8 +37,6 @@ const supabaseAdmin: SupabaseClient | null = skipReason auth: { autoRefreshToken: false, persistSession: false }, }) -const upstashCommandEndpoint = UPSTASH_URL ? new URL('/', UPSTASH_URL).toString() : null -const upstashKeepAliveKey = '__cron_keepalive__' const cronAuthHeader = CRON_SECRET ? `Bearer ${CRON_SECRET}` : null const skipUnlessChromiumProject = () => { @@ -57,7 +48,6 @@ const dayInMs = 24 * 60 * 60 * 1000 const createdConfirmationIds = new Set() const createdDsarIds = new Set() -const upstashSeedsToRestore = new Map() const queueCleanup = (bucket: Set, id: string) => { bucket.add(id) @@ -73,58 +63,6 @@ const cleanupRecords = async (table: 'newsletter_confirmations' | 'dsar_requests ids.clear() } -const sendUpstashCommand = async (command: (string | number)[]) => { - if (!upstashCommandEndpoint) { - throw new Error('Missing Upstash endpoint') - } - - const response = await fetch(upstashCommandEndpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${UPSTASH_TOKEN!}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(command), - }) - - if (!response.ok) { - const body = await response.text().catch(() => 'Unable to read response body') - throw new Error(`Failed to run Upstash command: ${response.status} ${body}`) - } - - return response -} - -const readUpstashValue = async (key: string) => { - const response = await sendUpstashCommand(['GET', key]) - try { - const payload = (await response.json()) as { result?: string | null } - if (!Object.prototype.hasOwnProperty.call(payload, 'result')) { - return null - } - const value = payload.result - return typeof value === 'string' ? value : null - } catch { - return null - } -} - -const restoreUpstashSeeds = async () => { - if (!upstashCommandEndpoint || upstashSeedsToRestore.size === 0) { - return - } - - for (const [key, previousValue] of upstashSeedsToRestore.entries()) { - if (previousValue === null) { - await sendUpstashCommand(['DEL', key]) - } else { - await sendUpstashCommand(['SET', key, previousValue]) - } - } - - upstashSeedsToRestore.clear() -} - const insertNewsletterConfirmation = async (options: { expiresAt: Date confirmedAt?: Date | null @@ -201,15 +139,6 @@ const expectMissingById = async (table: 'newsletter_confirmations' | 'dsar_reque expect(data).toBeNull() } -const setUpstashKeepAlive = async (value: string) => { - if (!upstashSeedsToRestore.has(upstashKeepAliveKey)) { - const previousValue = await readUpstashValue(upstashKeepAliveKey) - upstashSeedsToRestore.set(upstashKeepAliveKey, previousValue) - } - - await sendUpstashCommand(['SET', upstashKeepAliveKey, value]) -} - test.describe('Cron API endpoints @ready', () => { test.describe.configure({ mode: 'serial' }) @@ -220,8 +149,6 @@ test.describe('Cron API endpoints @ready', () => { await ensureCronDependenciesHealthy({ supabaseUrl: SUPABASE_URL!, supabaseServiceKey: SUPABASE_SERVICE_ROLE_KEY!, - upstashUrl: UPSTASH_URL!, - upstashToken: UPSTASH_TOKEN!, timeoutMs: 5000, }) }) @@ -230,7 +157,6 @@ test.describe('Cron API endpoints @ready', () => { test.afterEach(async () => { await cleanupRecords('newsletter_confirmations', createdConfirmationIds) await cleanupRecords('dsar_requests', createdDsarIds) - await restoreUpstashSeeds() }) test('@ready cleanup-confirmations removes expired and stale rows', async ({ request }) => { @@ -295,10 +221,8 @@ test.describe('Cron API endpoints @ready', () => { await expectMissingById('dsar_requests', expiredPendingId) }) - test('@ready ping-integrations touches Upstash and Supabase', async ({ request }) => { + test('@ready ping-integrations validates Astro DB availability', async ({ request }) => { skipUnlessChromiumProject() - const sentinel = `keepalive-${randomUUID()}` - await setUpstashKeepAlive(sentinel) const response = await request.get('/api/cron/ping-integrations', { headers: { @@ -308,12 +232,10 @@ test.describe('Cron API endpoints @ready', () => { expect(response.ok()).toBeTruthy() const body = (await response.json()) as { - upstash: { payload: { result?: string | null }; durationMs: number } - supabase: { rowsChecked: number; durationMs: number } + astroDb: { rowsChecked: number; durationMs: number } } - expect(body.upstash.payload?.result).toBe(sentinel) - expect(body.supabase.rowsChecked).toBeGreaterThanOrEqual(0) - expect(body.supabase.durationMs).toBeGreaterThanOrEqual(0) + expect(body.astroDb.rowsChecked).toBeGreaterThanOrEqual(0) + expect(body.astroDb.durationMs).toBeGreaterThanOrEqual(0) }) }) diff --git a/tsconfig.json b/tsconfig.json index 5c54ffff0..19e26bc3d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -95,7 +95,9 @@ "@semantic-ui/astro-lit": ["@types/semantic-ui__astro-lit/index.d.ts"], "unstorage": ["@types/unstorage/index.d.ts"], "unstorage/*": ["@types/unstorage/index.d.ts"], - "unstorage/drivers/*": ["@types/unstorage/index.d.ts"] + "unstorage/drivers/*": ["@types/unstorage/index.d.ts"], + "gsap/utils/VelocityTracker": ["@types/gsap/velocity-tracker.d.ts"], + "gsap/utils/VelocityTracker.js": ["@types/gsap/velocity-tracker.d.ts"] }, /** Strip all comments from TypeScript files when converting into JavaScript */ "removeComments": true, From 682ffe047f75ee361f0a7ff958c099da47172104 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 11 Dec 2025 01:01:03 +0300 Subject: [PATCH 05/22] Update build action workflow to seed migrations to Turso, build scripts, and docs --- .env.example | 34 +++++----------------------------- .github/workflows/build.yml | 24 +++++++++++++----------- .github/workflows/test.yml | 4 ++-- README.md | 15 +++++++++++++++ _TODO.md | 4 ++++ db/dev.db | Bin 77824 -> 77824 bytes db/seed.ts | 8 ++++++++ package.json | 3 ++- 8 files changed, 49 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index 847d0b114..51f9613db 100644 --- a/.env.example +++ b/.env.example @@ -20,35 +20,6 @@ CRON_SECRET="local-cron-secret" RESEND_HTTP_PORT="9011" RESEND_API_KEY="mock-resend-key" -# Upstash-compatible Redis mock for rate limits + cron keepalive keys (local -# and on GitHub Actions, production on Vercel) -UPSTASH_HTTP_PORT="8079" -UPSTASH_REDIS_PORT="6380" -UPSTASH_TOKEN="local-dev-token" - -# Vercel Marketplace Integration for Upstash Redis used with rate limiting on API endpoints -# (local and on GitHub Actions, production on Vercel) -KV_URL="redis://default:local-dev-token@127.0.0.1:6380" -KV_REST_API_URL="http://127.0.0.1:8079" -KV_REST_API_TOKEN="local-dev-token" -KV_REST_API_READ_ONLY_TOKEN="local-dev-token" -REDIS_URL="redis://default:local-dev-token@127.0.0.1:6380" - -# Supabase configuration -# - SUPABASE_URL / SUPABASE_KEY: client + server runtime credentials used in Astro and API routes. -# - SUPABASE_SERVICE_ROLE_KEY: server-side key for tests and cron endpoints (never expose to browser). -# - SUPABASE_HEALTH_TIMEOUT: controls how long we wait for the local Supabase container to become healthy. -# - SUPABASE_DB_PASSWORD / SUPABASE_PROJECT_REF / SUPABASE_ACCESS_TOKEN: only needed when pushing -# migrations to the hosted project (GitHub "build" workflow). Leave them empty locally unless you -# have production access. -SUPABASE_HEALTH_TIMEOUT="240" -SUPABASE_URL="http://127.0.0.1:54321" -SUPABASE_KEY="sb_publishable_example" -SUPABASE_SERVICE_ROLE_KEY="sb_secret_example" -SUPABASE_DB_PASSWORD="example-db-password" -SUPABASE_PROJECT_REF="exampleprojectref" -SUPABASE_ACCESS_TOKEN="sbp_example_personal_access_token" - # Observability / external services SENTRY_AUTH_TOKEN="dev-placeholder-sentry-token" SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0" @@ -60,3 +31,8 @@ VERCEL_ORG_ID="team_C7kQw5vXn0PfH3sJt2Gb9LrY" # Used for social shares on Mastodon WEBMENTION_IO_TOKEN="dev-webmention-token" + +# Astro DB - local file-backed dev database (token unused for file connections) +ASTRO_DB_REMOTE_URL="file:./db/dev.db" +ASTRO_DB_APP_TOKEN="" +# ASTRO_DATABASE_FILE must be exported in the shell (see README) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ca6b660d..d2b5e9ab4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,8 +37,8 @@ jobs: core.setFailed(`Required CI jobs missing or failed: ${missing.join(', ')}`); } - push-supabase-migrations: - name: Push Supabase Production Migrations + push-turso-migrations: + name: Push Turso Production Migrations runs-on: ubuntu-latest needs: verify-ci if: >- @@ -53,15 +53,17 @@ jobs: with: ref: ${{ github.event.workflow_run.head_sha }} - - name: Install Supabase CLI - uses: supabase/setup-cli@v1.6.0 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - version: latest + node-version: '22.x' + cache: 'npm' - - name: Link Supabase project - run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }} --password "${{ secrets.SUPABASE_DB_PASSWORD }}" --workdir suprabase - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + - name: Install dependencies + run: npm ci --legacy-peer-deps - - name: Push migrations to production - run: supabase db push --workdir suprabase + - name: Push Astro DB migrations to Turso + run: npx astro db push --remote + env: + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c0cce345..44a1a4c8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,8 +80,8 @@ jobs: run: npm ci --legacy-peer-deps # Astro build runs sync, check, and integrations including verify links - - name: Verify green build - run: npm run build + - name: Verify green build (local DB snapshot) + run: npm run build:ci - name: Run unit tests (Vitest — coverage with GitHub Actions reporter) id: vitest diff --git a/README.md b/README.md index 74e69a76e..75dc5ee67 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,21 @@ Provisioning the hosted Supabase project uses the same SQL migrations that back The `supabase:db:push` script runs `supabase db push` against the linked project, ensuring every table, index, and policy in `suprabase/migrations` is kept in sync with production. +## Astro DB Workflows + +Astro DB schemas now live under `db/config.ts`. Applying those migrations requires `npx astro db push`; the regular `astro build` command never pushes schema changes for you. Keep local and remote pushes separate to avoid promoting unfinished changes: + +- **Local development / CI** – Point Astro DB at the file-based database via `ASTRO_DB_REMOTE_URL="file:./db/dev.db"` and run `npx astro db push`. For convenience the `build:ci` script exports `ASTRO_DATABASE_FILE=./db/dev.db` before invoking `astro build`, so `npm run build:ci` is the go-to command anywhere you need a build that reads the local snapshot (including GitHub Actions). +- **Production Turso push** – Export your Turso connection details (`ASTRO_DB_REMOTE_URL` + `ASTRO_DB_APP_TOKEN`) and run `npx astro db push --remote`. The merge-to-main workflow should be the only automation that executes this command. +- **Vercel deployments** – Set the build command to `npm run build -- --remote` so the build pipeline talks to Turso in read-only mode while still leaving schema promotion to the dedicated workflow. + +> `ASTRO_DATABASE_FILE` is evaluated before Astro loads `.env.*`, so put it directly in the shell invocation (e.g., `ASTRO_DATABASE_FILE=./db/dev.db npm run build`) or reuse the provided `npm run build:ci` helper. Dropping the variable into `.env.development` or `.env.production` will not satisfy the build hook. + +### Build scripts + +- `npm run build` – Runs `astro build --remote`, hitting the production Turso database in read-only mode. Use this for Vercel deployments and any workflows that need to mirror production infrastructure. +- `npm run build:ci` – Exports `ASTRO_DATABASE_FILE=./db/dev.db` before delegating to `npm run build`, forcing the build to read from the local SQLite snapshot. Use this for local testing and the `Test` GitHub workflow so schema changes stay isolated until the Turso push workflow runs. + ## Coding Standards ### Component Architecture diff --git a/_TODO.md b/_TODO.md index 068fa9c90..85d6890b1 100644 --- a/_TODO.md +++ b/_TODO.md @@ -147,6 +147,10 @@ fatal: /home/kevin/Repos/Webstack: '/home/kevin/Repos/Webstack' is outside repos ⚠️ Privacy policy version fallback applied: 2025-12-08 +## Build output errors + +00:19:09 [WARN] [vite] [plugin:astro:assets:esm] context method emitFile() is not supported in serve mode. This plugin is likely not vite-compatible. + ## Tags Can we add a markdown page to explain what each tag is, and use those pages instead of src/content/_tagList.ts to define the tags available? diff --git a/db/dev.db b/db/dev.db index fa1915d79825874396d84c664fbee18114ac24bf..861e1c017e1606a1d40071a736be559c26c56a0e 100644 GIT binary patch delta 1231 zcma)6&ubGw6rS1arn~tyjS^BQ(lkw)T080PB-w0Q0`cHQp%iNdwT<3;uRa-w@mtoWl z*&n+5TKt29aM@p0b9v*56ovlUXZsDvVY^=)G(UOT%wb+~&~Qs6C|xk`^JUm;{^4g} zkEsiL+6=0Y7-L(iiYr1<1);#EU zfM-1G0L47(HE&?eQ>mU|T-hv$baetB;L|BNnbIUFjwO;4aePutU^SN%aZFF@3Efa) zij)wK2Da7{)6o?f3(l(ZGe&v3Fclmx8HRqdq|Hyym8~sSr;I{egQskng^BQ}ZSiKK!RSx;f26GahodQO$JBj%HW z>iUlEB#S*`+)-!e@i8kiMwTx5n(t}oNXv^CQf1xcl01wb#ZE+#k delta 256 zcmZp8z|!!5WrDOI69WT-Fc8B4&qN(#eI^FIvQA!pHU>e~*$n&%{CvELe5ZMy@RV|M z^O$gTaVByFa(v@xWLwW+#HPqPd$XVbFDr8+v*6??wpK=g$q(3i8TmHnu{Sa@@=yN2 z;mcIaJK3GHgpp_R9?on=uE`o)?^wCn#JyD~*YZeAHs+RQs^^?6!>TeljWwFNNsWDT zAh$XrBgf=Q9tTFY$-8*+7+EK4^42mkZ(h!u&d6rT!Y1yk4%U00Rh9$jau8F%dSI~W0XKvVXG{!W+0LTC_=g_=$Rt4- diff --git a/db/seed.ts b/db/seed.ts index 045aaabed..7fb98941b 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -5,6 +5,10 @@ import { consentEvents, rateLimitWindows, db } from 'astro:db' export default async function seed() { const now = new Date() + /* + * Provide a pair of consent records so GDPR endpoints, dashboard pages, and local tests + * always have deterministic data to read without depending on real submissions. + */ await db.insert(consentEvents).values([ { id: randomUUID(), @@ -34,6 +38,10 @@ export default async function seed() { }, ]) + /* + * Pre-populate a rate-limit window so the Upstash-compatible limiter logic immediately + * has a known scope/identifier to work with when exercising API routes locally. + */ await db.insert(rateLimitWindows).values([ { id: randomUUID(), diff --git a/package.json b/package.json index 851b52966..354d3e9f5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "not IE 11" ], "scripts": { - "build": "cross-env NODE_ENV=production npx astro build", + "build": "cross-env NODE_ENV=production npx astro build --remote", + "build:ci": "cross-env ASTRO_DATABASE_FILE=./db/dev.db npm run build", "check": "npx astro check", "clean": "FORCE_COLOR=1 npx rimraf dist && FORCE_COLOR=1 npx rimraf .astro", "dev": "npm run sync && cross-env NODE_ENV=development FORCE_COLOR=1 npx astro dev", From 890cb4a40aaf1e2475c7ff8057848c17fbf0a87f Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 11 Dec 2025 03:56:45 +0300 Subject: [PATCH 06/22] Update E2E test framework and affected suites to use Astro DB, and remove Upstash and Suprabase mock containers --- .github/workflows/test.yml | 261 +--------- README.md | 24 - _TODO.md | 66 +-- db/seed.ts | 2 +- e2e-stress-run.sh | 2 +- package-lock.json | 467 +++++------------- package.json | 19 +- playwright.config.ts | 21 + src/components/scripts/consent/db/supabase.ts | 8 - .../config/environmentalVariableValidation.ts | 46 -- src/pages/api/_environment/environmentApi.ts | 86 +--- src/pages/api/_utils/index.ts | 1 - src/pages/api/_utils/suprabase.ts | 84 ---- src/pages/api/contact/index.ts | 5 +- src/pages/api/gdpr/consent.ts | 54 +- .../api/newsletter/__tests__/_confirm.spec.ts | 6 +- .../api/newsletter/__tests__/_index.spec.ts | 6 +- src/pages/api/newsletter/_email.ts | 22 +- src/pages/api/newsletter/confirm.ts | 7 +- src/pages/api/newsletter/index.ts | 7 +- suprabase/.gitignore | 8 - suprabase/TROUBLESHOOTING_ACTION_WORKFLOW.md | 63 --- suprabase/config.toml | 351 ------------- .../migration.sql | 30 -- .../migration.sql | 22 - .../migration.sql | 22 - test/containers/README.md | 98 +--- test/containers/docker-compose.e2e.yml | 56 --- test/containers/scripts/wait-for-services.sh | 77 --- test/containers/supabase/logs.sh | 67 --- test/containers/supabase/start.sh | 100 ---- .../containers/upstash/local-proxy/Dockerfile | 25 - test/containers/upstash/local-proxy/LICENSE | 21 - test/containers/upstash/local-proxy/go.mod | 16 - test/containers/upstash/local-proxy/go.sum | 29 -- .../upstash/local-proxy/internal/rest.go | 216 -------- test/containers/upstash/local-proxy/main.go | 102 ---- test/containers/upstash/seed.sh | 56 --- .../upstash/seeds/newsletter-queue.json | 10 - test/e2e/config/global-setup.ts | 12 +- test/e2e/config/global-teardown.ts | 8 + test/e2e/config/runtime/database.ts | 36 ++ test/e2e/config/runtime/paths.ts | 8 + test/e2e/config/runtime/wiremock.ts | 59 +++ test/e2e/db/helpers.ts | 187 +++++++ test/e2e/db/index.ts | 1 + test/e2e/db/libsqlClient.ts | 15 + test/e2e/helpers/cronHealth.ts | 71 +-- test/e2e/helpers/index.ts | 2 +- test/e2e/helpers/mockServices.ts | 37 +- test/e2e/helpers/wiremockConfig.ts | 23 + test/e2e/specs/03-forms/contact-form.spec.ts | 75 ++- .../03-forms/newsletter-double-optin.spec.ts | 127 ++--- .../04-components/consentPreferences.spec.ts | 89 +--- test/e2e/specs/08-api/contact-api.spec.ts | 12 +- test/e2e/specs/08-api/newsletter-api.spec.ts | 12 +- test/e2e/specs/08-api/supabase.spec.ts | 279 ----------- test/e2e/specs/15-cron/cron.spec.ts | 167 ++----- 58 files changed, 699 insertions(+), 3084 deletions(-) delete mode 100644 src/components/scripts/consent/db/supabase.ts delete mode 100644 src/pages/api/_utils/suprabase.ts delete mode 100644 suprabase/.gitignore delete mode 100644 suprabase/TROUBLESHOOTING_ACTION_WORKFLOW.md delete mode 100644 suprabase/config.toml delete mode 100644 suprabase/migrations/20240101000000_create_consent_records/migration.sql delete mode 100644 suprabase/migrations/20240101000100_create_newsletter_confirmations/migration.sql delete mode 100644 suprabase/migrations/20240101000200_create_dsar_requests/migration.sql delete mode 100644 test/containers/scripts/wait-for-services.sh delete mode 100755 test/containers/supabase/logs.sh delete mode 100644 test/containers/supabase/start.sh delete mode 100644 test/containers/upstash/local-proxy/Dockerfile delete mode 100644 test/containers/upstash/local-proxy/LICENSE delete mode 100644 test/containers/upstash/local-proxy/go.mod delete mode 100644 test/containers/upstash/local-proxy/go.sum delete mode 100644 test/containers/upstash/local-proxy/internal/rest.go delete mode 100644 test/containers/upstash/local-proxy/main.go delete mode 100644 test/containers/upstash/seed.sh delete mode 100644 test/containers/upstash/seeds/newsletter-queue.json create mode 100644 test/e2e/config/global-teardown.ts create mode 100644 test/e2e/config/runtime/database.ts create mode 100644 test/e2e/config/runtime/paths.ts create mode 100644 test/e2e/config/runtime/wiremock.ts create mode 100644 test/e2e/db/helpers.ts create mode 100644 test/e2e/db/index.ts create mode 100644 test/e2e/db/libsqlClient.ts create mode 100644 test/e2e/helpers/wiremockConfig.ts delete mode 100644 test/e2e/specs/08-api/supabase.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44a1a4c8b..fbb01d761 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,17 +16,6 @@ env: RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} - SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} - KV_URL: ${{ secrets.KV_URL }} - KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} - KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} - KV_REST_API_READ_ONLY_TOKEN: ${{ secrets.KV_REST_API_READ_ONLY_TOKEN }} - REDIS_URL: ${{ secrets.REDIS_URL }} WEBMENTION_IO_TOKEN: ${{ secrets.WEBMENTION_IO_TOKEN }} jobs: @@ -106,7 +95,7 @@ jobs: name: E2E Tests runs-on: ubuntu-latest needs: unit-test - # Temporarily disabled while Supabase migrations are fixed; set this expression back to the previous condition when ready + # Temporarily disabled until the simplified flow is battle-tested if: ${{ false && !startsWith(github.head_ref || github.ref_name, 'hotfix/') }} env: @@ -116,17 +105,6 @@ jobs: RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} - SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} - KV_URL: ${{ secrets.KV_URL }} - KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} - KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} - KV_REST_API_READ_ONLY_TOKEN: ${{ secrets.KV_REST_API_READ_ONLY_TOKEN }} - REDIS_URL: ${{ secrets.REDIS_URL }} WEBMENTION_IO_TOKEN: ${{ secrets.WEBMENTION_IO_TOKEN }} steps: @@ -145,231 +123,6 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.11.1 - - - name: Build Upstash mock image - run: docker buildx build --load -t wb/upstash-redis-local:test test/containers/upstash/local-proxy - - - name: Generate container env file - shell: bash - run: | - set -euo pipefail - mkdir -p test/containers - : "${SUPABASE_SERVICE_ROLE_KEY:?SUPABASE_SERVICE_ROLE_KEY secret is required}" - - cat < test/containers/.env - COMPOSE_PROJECT_NAME=wb-e2e - CONVERTKIT_HTTP_PORT=9010 - RESEND_HTTP_PORT=9011 - UPSTASH_HTTP_PORT=${UPSTASH_HTTP_PORT:-8079} - UPSTASH_REDIS_PORT=${UPSTASH_REDIS_PORT:-6380} - UPSTASH_TOKEN=${UPSTASH_TOKEN:-local-dev-token} - SUPABASE_HEALTH_TIMEOUT=${SUPABASE_HEALTH_TIMEOUT:-240} - SUPABASE_URL=${SUPABASE_URL:-http://127.0.0.1:54321} - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} - EOF_ENV - - - name: Start mock containers - run: npm run containers:up - - - name: Wait for mock services - run: npm run containers:wait - - - name: Pre-pull Supabase storage image - run: | - set -euo pipefail - image="public.ecr.aws/supabase/storage-api:v1.32.1" - for attempt in 1 2 3 4 5 6; do - echo "Attempt ${attempt}: pulling ${image}" - if docker pull "${image}"; then - echo "Pulled ${image} successfully" - exit 0 - fi - sleep_seconds=$((2 ** attempt)) - echo "Pull failed; sleeping ${sleep_seconds}s before retry" - sleep "${sleep_seconds}" - done - echo "Failed to pull ${image} after multiple attempts" >&2 - exit 1 - - - name: Start Supabase stack - run: npm run containers:supabase:start - - - name: Apply Supabase migrations (local container) - run: | - set -euo pipefail - - echo "::group::Supabase directory layout" - ls -al suprabase || true - find suprabase -maxdepth 2 -type f -print | sort || true - echo "::endgroup::" - - echo "::group::Supabase migrations catalog check" - bash <<'BASH' - set -euo pipefail - - find_db_container() { - docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$3 ~ /^supabase\/postgres(:|$)/ {print $1" "$2; exit}' - } - - db_container_line=$(find_db_container) - if [ -z "${db_container_line}" ]; then - db_container_line=$(docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$2 ~ /_db_/ {print $1" "$2; exit}') - fi - - if [ -z "${db_container_line}" ]; then - echo "Supabase database container not found; cannot verify catalog" >&2 - exit 1 - fi - - container_id=$(echo "${db_container_line}" | awk '{print $1}') - catalog_present=$(docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -At -c "select to_regclass('supabase_migrations.schema_migrations');" || true) - - if [ -z "${catalog_present}" ] || [ "${catalog_present}" = "" ] || [ "${catalog_present}" = "NULL" ]; then - echo "supabase_migrations.schema_migrations missing; creating manually" - docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE SCHEMA IF NOT EXISTS supabase_migrations;" - docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text PRIMARY KEY, inserted_at timestamptz DEFAULT now());" - echo "Catalog created; verifying contents" - docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -c "\\d supabase_migrations.schema_migrations" - docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -c "table supabase_migrations.schema_migrations" - else - echo "Catalog present: ${catalog_present}" - fi - BASH - echo "::endgroup::" - - echo "::group::Supabase migrations" - npm run containers:supabase:db-push - echo "::endgroup::" - - echo "::group::Supabase migration status" - FORCE_COLOR=1 npx supabase migration list --local --workdir suprabase - echo "::endgroup::" - - echo "::group::Supabase migration status (JSON)" - FORCE_COLOR=1 npx supabase migration list --local --workdir suprabase --output json || true - echo "::endgroup::" - - echo "::group::Supabase schema snapshot" - schema_dir="artifacts/supabase" - schema_path="${schema_dir}/schema.sql" - mkdir -p "${schema_dir}" - - FORCE_COLOR=1 npx dotenv-cli -e test/containers/.env -- bash -c 'npx supabase db dump --local --schema "public,graphql_public,storage" --workdir suprabase' > "${schema_path}" - head -n 200 "${schema_path}" || true - echo "(full schema saved to ${schema_path})" - echo "::endgroup::" - - echo "::group::Supabase table inventory" - bash <<'BASH' - set -euo pipefail - - find_db_container() { - docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$3 ~ /^supabase\/postgres(:|$)/ {print $1" "$2; exit}' - } - - db_container_line=$(find_db_container) - if [ -z "${db_container_line}" ]; then - db_container_line=$(docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$2 ~ /_db_/ {print $1" "$2; exit}') - fi - - if [ -z "${db_container_line}" ]; then - echo "Supabase database container not found; skipping inventory" >&2 - exit 0 - fi - - container_id=$(echo "${db_container_line}" | awk '{print $1}') - echo "Listing public schema tables via container ${container_id}" - docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -c '\dt public.*' || true - docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -At -c "select table_schema || '.' || table_name from information_schema.tables where table_schema='public' order by 1;" || true - BASH - echo "::endgroup::" - - echo "::group::Supabase schema guard" - bash <<'BASH' - set -euo pipefail - - find_db_container() { - docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$3 ~ /^supabase\/postgres(:|$)/ {print $1" "$2; exit}' - } - - db_container_line=$(find_db_container) - if [ -z "${db_container_line}" ]; then - db_container_line=$(docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$2 ~ /_db_/ {print $1" "$2; exit}') - fi - - if [ -z "${db_container_line}" ]; then - echo "Supabase database container not found; cannot verify migrations" >&2 - exit 1 - fi - - container_id=$(echo "${db_container_line}" | awk '{print $1}') - container_name=$(echo "${db_container_line}" | awk '{print $2}') - echo "Inspecting tables inside ${container_name} (${container_id})" - - required_tables=(newsletter_confirmations consent_records dsar_requests) - missing=0 - - for table in "${required_tables[@]}"; do - if ! docker exec -e PGPASSWORD=postgres "${container_id}" psql -U postgres -d postgres -At -c "select to_regclass('public.${table}');" | grep -q "public.${table}"; then - echo "❌ Missing table public.${table}" - missing=1 - else - echo "✅ Found table public.${table}" - fi - done - - if [ "${missing}" -ne 0 ]; then - echo "Required tables missing after Supabase migrations" >&2 - exit 1 - fi - BASH - echo "::endgroup::" - - echo "::group::Restart Supabase REST container" - bash <<'BASH' - set -euo pipefail - - rest_container_line=$(docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$3 ~ /postgrest/ {print $1" "$2; exit}') - if [ -z "${rest_container_line}" ]; then - echo "Supabase REST container not found; skipping restart" >&2 - exit 0 - fi - - container_id=$(echo "${rest_container_line}" | awk '{print $1}') - container_name=$(echo "${rest_container_line}" | awk '{print $2}') - echo "Restarting ${container_name} (${container_id}) to refresh schema cache" - docker restart "${container_id}" - BASH - echo "::endgroup::" - - echo "::group::Supabase container logs (last 200 lines)" - bash <<'BASH' - set -euo pipefail - - containers=$(docker ps --format '{{.ID}} {{.Names}} {{.Image}}' | awk '$3 ~ /supabase\// {print $1" "$2}') - if [ -z "${containers}" ]; then - echo "No Supabase containers found" >&2 - exit 0 - fi - - while read -r container_id container_name; do - [ -z "${container_id}" ] && continue - echo "--- ${container_name} (tail -n 200) ---" - docker logs --tail 200 "${container_id}" || true - done <<< "${containers}" - BASH - echo "::endgroup::" - - - name: Upload Supabase schema snapshot - if: ${{ always() && hashFiles('artifacts/supabase/schema.sql') != '' }} - uses: actions/upload-artifact@v5 - with: - name: supabase-schema - path: artifacts/supabase/schema.sql - retention-days: 14 - - name: Start Astro dev server run: | npm run dev -- --host 0.0.0.0 > /tmp/astro-dev.log 2>&1 & @@ -392,11 +145,9 @@ jobs: exit 1 - name: Run Playwright E2E tests - run: npx playwright test + run: npm run test:e2e env: CI: '1' - FORCE_COLOR: '1' - E2E_MOCKS: '1' - name: Stop Astro dev server if: always() @@ -414,14 +165,6 @@ jobs: path: playwright-report/ retention-days: 30 - - name: Stop Supabase stack - if: always() - run: npm run containers:supabase:stop || true - - - name: Stop mock containers - if: always() - run: npm run containers:down || true - hotfix-bypass: name: Hotfix Bypass Notice runs-on: ubuntu-latest diff --git a/README.md b/README.md index 75dc5ee67..77d653909 100644 --- a/README.md +++ b/README.md @@ -46,30 +46,6 @@ npm run dev:env The underlying `npm run dev` command remains unchanged for CI and Vercel; `dev:env` is just a convenience for local shells so API routes that depend on `process.env` (cron handlers, email providers, etc.) behave the same way they do in production. -## Supabase Production Initialization - -Provisioning the hosted Supabase project uses the same SQL migrations that back local development. When you need to initialize (or update) the production database: - -1. Authenticate the Supabase CLI (only required once per machine): - - ```bash - npx supabase login - ``` - -1. Link the CLI to the production project (replace the placeholder reference): - - ```bash - npx supabase link --project-ref your-production-project-ref --workdir suprabase - ``` - -1. Apply the latest migrations and RLS policies: - - ```bash - npm run supabase:db:push - ``` - -The `supabase:db:push` script runs `supabase db push` against the linked project, ensuring every table, index, and policy in `suprabase/migrations` is kept in sync with production. - ## Astro DB Workflows Astro DB schemas now live under `db/config.ts`. Applying those migrations requires `npx astro db push`; the regular `astro build` command never pushes schema changes for you. Keep local and remote pushes separate to avoid promoting unfinished changes: diff --git a/_TODO.md b/_TODO.md index 85d6890b1..3101f97bf 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,39 +1,32 @@ # TODO -[Astro DB](https://docs.astro.build/en/guides/astro-db/) - -Turso Setup Guidance - -We need to run `npx astro db push` with production Astro DB credentials on GitHub as part of the merge to main Action workflow. We'll have two sets of env vars then: development for the dev server, and production for the DB push: - -ASTRO_DB_REMOTE_URL="libsql://webstack-corporate-prod-webstackdev.aws-us-east-1.turso.io" \ -ASTRO_DB_APP_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJpYXQiOjE3NjUzODU2NzQsImlkIjoiOTc2OTFlZWMtZGFhMC00YjI0LTg2M2QtY2JkYjU2ZDczMjE0IiwicmlkIjoiNWNmZTdkZjAtZGIzMC00ZDJhLTlmNjItMzUwZjkyNGEzOGE2In0.dCxUeBL-zSFW7ckng3QCqIHX9X2DQsBv7yboP_GdbtBtpTkxvdmPwQkRAxqG2l1Iy7J7aExX7xIBIPsAWVMvBg" \ -npx astro db push - -ASTRO_DB_REMOTE_URL="file:./db/dev.db" \ -ASTRO_DB_APP_TOKEN="" \ -npx astro db push +## Typing client-side API calls and SSR API endpoints -ASTRO_DATABASE_FILE="./db/dev.db" +Use Astro Actions -Let me know if you'd like similar migrations for the other Supabase-backed endpoints or want the fallback flag renamed now that we're on Astro DB. +### Shared Types vs Swagger / Keeping Docs in Sync -No seed data was added for DSAR requests yet, but schema changes require astro db push (local + Turso) before the endpoint can execute against the new table. +**Type-only sharing (current approach)** -Run npx astro db push locally, then ASTRO_DB_REMOTE_URL=... ASTRO_DB_APP_TOKEN=... npx astro db push --remote so the new dsarRequests table exists remotely. +- Pros: zero extra build tooling, server/client stay aligned as long as both import @pages/api/_contracts. +- Cons: no generated docs/SDKs; discipline is required to keep manual docs current. +- How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge). -We still have a handful of "real" Supabase/Upstash touchpoints under src: +**Code-first OpenAPI (Zod or TS schemas → OpenAPI)** -`suprabase.ts` plus its re-export in index.ts still create the Supabase admin client via `getSuprabaseApiUrl()` / `getSuprabaseApiServiceRoleKey()`. +- Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they're always in sync. +- Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile. -Anything that imports supabaseAdmin (below) rides on that helper, so those env getters/validators (`environmentApi.ts`, `environmentalVariableValidation.ts`)` also remain in play. +**Spec-first OpenAPI + Swagger Codegen** -Client-side module `supabase.ts` still instantiates a public Supabase client (it isn't imported anywhere else now, but the implementation is present). +- Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs. +- Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation. -Those env helpers and the validation entries (`KV_REST_API_URL`, `KV_REST_API_TOKEN`, etc.) are therefore still part of the implementation. +**Affected components:** -Outside these files, the remaining "supabase/upstash" mentions are in docs, tests, or tooling scripts only. If we plan to finish the Astro DB migration, those are the concrete spots left to refactor (and we can drop the unused client helper afterwards). +- CallToAction/Newsletter +- ContactForm ## Mobile Social Shares UI @@ -86,33 +79,6 @@ accent text-white, other default Tailwind colors -## Typing client-side API calls and SSR API endpoints - -Use Astro Actions - -Shared Types vs Swagger / Keeping Docs in Sync - -1. Type-only sharing (current approach) - -- Pros: zero extra build tooling, server/client stay aligned as long as both import @pages/api/_contracts. -- Cons: no generated docs/SDKs; discipline is required to keep manual docs current. -- How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge). - -1. Code-first OpenAPI (Zod or TS schemas → OpenAPI) - -- Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they're always in sync. -- Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile. - -1. Spec-first OpenAPI + Swagger Codegen - -- Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs. -- Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation. - -Affected components: - -- CallToAction/Newsletter -- ContactForm - ## Files with Skipped Tests Blocked Categories (44 tests): diff --git a/db/seed.ts b/db/seed.ts index 7fb98941b..c3c3df835 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -39,7 +39,7 @@ export default async function seed() { ]) /* - * Pre-populate a rate-limit window so the Upstash-compatible limiter logic immediately + * Pre-populate a rate-limit window so the Astro DB-backed limiter logic immediately * has a known scope/identifier to work with when exercising API routes locally. */ await db.insert(rateLimitWindows).values([ diff --git a/e2e-stress-run.sh b/e2e-stress-run.sh index f652f8763..d7f980626 100755 --- a/e2e-stress-run.sh +++ b/e2e-stress-run.sh @@ -19,7 +19,7 @@ if [[ ! -f "$SUCCESS_FILE" ]]; then fi while true; do - CI=1 FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test + CI=1 FORCE_COLOR=1 npx playwright test status=$? if [[ $status -eq 0 ]]; then diff --git a/package-lock.json b/package-lock.json index 929d5be01..5c1aa411c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,14 +24,11 @@ "@sentry/astro": "^10.29.0", "@sentry/browser": "^10.29.0", "@shikijs/transformers": "^3.19.0", - "@supabase/supabase-js": "^2.87.1", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.17", "@types/hast": "^3.0.4", "@types/pubsub-js": "^1.8.6", - "@upstash/ratelimit": "^2.0.7", - "@upstash/redis": "^1.35.7", "@vite-pwa/astro": "^1.2.0", "@webcomponents/template-shadowroot": "^0.2.1", "astro": "5.16.5", @@ -128,7 +125,6 @@ "stylelint-config-standard": "^39.0.1", "stylelint-declaration-block-no-ignored-properties": "2.8.0", "stylelint-order": "7.0.0", - "supabase": "^2.65.8", "temp-dir": "3.0.0", "tslib": "2.8.1", "typescript": "5.9.3", @@ -263,7 +259,8 @@ "version": "2.13.0", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@astrojs/db": { "version": "0.18.3", @@ -465,6 +462,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -637,7 +635,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -653,7 +651,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -666,7 +664,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -680,7 +678,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -694,7 +692,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -709,7 +707,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -719,7 +717,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -731,7 +729,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -744,7 +742,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -758,7 +756,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -874,7 +872,7 @@ "version": "3.932.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.932.0.tgz", "integrity": "sha512-AS8gypYQCbNojwgjvZGkJocC2CoEICDx9ZJ15ILsv+MlcCVLtUJSRSx3VzJOUY2EEIaGLRrPNlIqyn/9/fySvA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1025,8 +1023,9 @@ "version": "3.933.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.933.0.tgz", "integrity": "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", @@ -1044,7 +1043,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.930.0.tgz", "integrity": "sha512-x30jmm3TLu7b/b+67nMyoV0NlbnCVT5DI57yDrhXAPCtdgM1KtdLWt45UcHpKOm1JsaIkmYRh2WYu7Anx4MG0g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1060,7 +1059,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.930.0.tgz", "integrity": "sha512-vh4JBWzMCBW8wREvAwoSqB2geKsZwSHTa0nSt0OMOLp2PdTYIZDi0ZiVMmpfnjcx9XbS6aSluLv9sKx4RrG46A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1075,7 +1074,7 @@ "version": "3.933.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.933.0.tgz", "integrity": "sha512-qgrMlkVKzTCAdNw2A05DC2sPBo0KRQ7wk+lbYSRJnWVzcrceJhnmhoZVV5PFv7JtchK7sHVcfm9lcpiyd+XaCA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1118,7 +1117,7 @@ "version": "3.932.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.932.0.tgz", "integrity": "sha512-9BGTbJyA/4PTdwQWE9hAFIJGpsYkyEW20WON3i15aDqo5oRZwZmqaVageOD57YYqG8JDJjvcwKyDdR4cc38dvg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "3.932.0", @@ -1137,7 +1136,7 @@ "version": "3.933.0", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.933.0.tgz", "integrity": "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -1187,7 +1186,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.930.0.tgz", "integrity": "sha512-KL2JZqH6aYeQssu1g1KuWsReupdfOoxD6f1as2VC+rdwYFUu4LfzMsFfXnBvvQWWqQ7rZHWOw1T+o5gJmg7Dzw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1241,7 +1240,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.930.0.tgz", "integrity": "sha512-we/vaAgwlEFW7IeftmCLlLMw+6hFs3DzZPJw7lVHbj/5HJ0bz9gndxEsS2lQoeJ1zhiiLqAqvXxmM43s0MBg0A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -1268,7 +1267,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.930.0.tgz", "integrity": "sha512-M2oEKBzzNAYr136RRc6uqw3aWlwCxqTP1Lawps9E1d2abRPvl1p1ztQmmXp1Ak4rv8eByIZ+yQyKQ3zPdRG5dw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1285,7 +1284,7 @@ "version": "3.893.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1298,7 +1297,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.930.0.tgz", "integrity": "sha512-q6lCRm6UAe+e1LguM5E4EqM9brQlDem4XDcQ87NzEvlTW6GzmNCO0w1jS0XgCFXQHjDxjdlNFX+5sRbHijwklg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.930.0", @@ -1311,7 +1310,7 @@ "version": "3.932.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.932.0.tgz", "integrity": "sha512-/kC6cscHrZL74TrZtgiIL5jJNbVsw9duGGPurmaVgoCbP7NnxyaSWEurbNV3VPNPhNE3bV3g4Ci+odq+AlsYQg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-user-agent": "3.932.0", @@ -1336,7 +1335,7 @@ "version": "3.930.0", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -1351,7 +1350,7 @@ "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -1370,7 +1369,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.0.tgz", "integrity": "sha512-D1jAmAZQYMoPiacfgNf7AWhg3DFN3Wq/vQv3WINt9znwjzHp2x+WzdJFxxj7xZL7V1U79As6G8f7PorMYWBKsQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -1416,6 +1415,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2917,6 +2917,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3038,6 +3039,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3080,6 +3082,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4752,6 +4755,7 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", "license": "MIT", + "peer": true, "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", @@ -5186,6 +5190,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -5207,6 +5212,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -5219,6 +5225,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -5234,6 +5241,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -5621,6 +5629,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5637,6 +5646,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -5654,6 +5664,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -6766,7 +6777,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6780,7 +6791,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -6798,7 +6809,7 @@ "version": "3.18.4", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.4.tgz", "integrity": "sha512-o5tMqPZILBvvROfC8vC+dSVnWJl9a0u9ax1i1+Bq8515eYjUJqqk5XjjEsDLoeL5dSqGSh6WGdVx1eJ1E/Nwhw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.2.6", @@ -6820,7 +6831,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -6837,7 +6848,7 @@ "version": "5.3.6", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.5", @@ -6854,7 +6865,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6870,7 +6881,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6884,7 +6895,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6897,7 +6908,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.5", @@ -6912,7 +6923,7 @@ "version": "4.3.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.11.tgz", "integrity": "sha512-eJXq9VJzEer1W7EQh3HY2PDJdEcEUnv6sKuNt4eVjyeNWcQFS4KmnY+CKkYOIR6tSqarn6bjjCqg1UB+8UJiPQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.18.4", @@ -6932,7 +6943,7 @@ "version": "4.4.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.11.tgz", "integrity": "sha512-EL5OQHvFOKneJVRgzRW4lU7yidSwp/vRJOe542bHgExN3KNThr1rlg0iE4k4SnA+ohC+qlUxoK+smKeAYPzfAQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -6953,7 +6964,7 @@ "version": "4.2.6", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.5", @@ -6968,7 +6979,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -6982,7 +6993,7 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.5", @@ -6998,7 +7009,7 @@ "version": "4.4.5", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.5", @@ -7015,7 +7026,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7029,7 +7040,7 @@ "version": "5.3.5", "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7043,7 +7054,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7058,7 +7069,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7072,7 +7083,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0" @@ -7085,7 +7096,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7099,7 +7110,7 @@ "version": "5.3.5", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", @@ -7119,7 +7130,7 @@ "version": "4.9.7", "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.7.tgz", "integrity": "sha512-pskaE4kg0P9xNQWihfqlTMyxyFR3CH6Sr6keHYghgyqqDXzjl2QJg5lAzuVe/LzZiOzcbcVtxKYi1/fZPt/3DA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.18.4", @@ -7138,7 +7149,7 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7151,7 +7162,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/querystring-parser": "^4.2.5", @@ -7166,7 +7177,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7181,7 +7192,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7194,7 +7205,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7207,7 +7218,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", @@ -7221,7 +7232,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7234,7 +7245,7 @@ "version": "4.3.10", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.10.tgz", "integrity": "sha512-3iA3JVO1VLrP21FsZZpMCeF93aqP3uIOMvymAT3qHIJz2YlgDeRvNUspFwCNqd/j3qqILQJGtsVQnJZICh/9YA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.5", @@ -7250,7 +7261,7 @@ "version": "4.2.13", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.13.tgz", "integrity": "sha512-PTc6IpnpSGASuzZAgyUtaVfOFpU0jBD2mcGwrgDuHf7PlFgt5TIPxCYBDbFQs06jxgeV3kd/d/sok1pzV0nJRg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.3", @@ -7269,7 +7280,7 @@ "version": "3.2.5", "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.5", @@ -7284,7 +7295,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7297,7 +7308,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.9.0", @@ -7311,7 +7322,7 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.5", @@ -7326,7 +7337,7 @@ "version": "4.5.6", "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", @@ -7346,7 +7357,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7359,7 +7370,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7373,7 +7384,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7395,86 +7406,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@supabase/auth-js": { - "version": "2.87.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.87.1.tgz", - "integrity": "sha512-6RDeOf5TVoaXFtEstN188ykp3pXLZaU9qoAWfx8dc50FFAAqt+kcFJ96V0IvSmcpb4mDAWcpTJ7BegmVDn/WIw==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.87.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.87.1.tgz", - "integrity": "sha512-rWmYo4gRD0XAjMhYDlz7IH67bp4TIQ1UE4VqwIQtl1gGPwtLDq6wcRnu7jLKlXx0Gtrknw/eoiHYG9//XrCTzQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.87.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.87.1.tgz", - "integrity": "sha512-Yzu5eL3iGmZW0C/8x+vEojAOou63FI9oVw8HI8YOq63+5yM8g8aGh7Y1E2vbXFb7+gHGsPqLnaC6dPhrYt7qBA==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.87.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.87.1.tgz", - "integrity": "sha512-XvLtEznxmYZXA7LYuy5zbSXpSYjDLJq2wQeRh3MzON2OR4U8Kq+RtPz2E2Wi8HEzvBfsc+nNu1TG8LQ9+3DRkA==", - "license": "MIT", - "dependencies": { - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "tslib": "2.8.1", - "ws": "^8.18.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.87.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.87.1.tgz", - "integrity": "sha512-0Uc8tNV4yzkNNmp1inpXru0RB4a7ECq05G2S6BDvSpMxTxJrDVJ4vVDwyhqB8ZZ+O9+8prHaQYoByQeuDnwpFQ==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.87.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.87.1.tgz", - "integrity": "sha512-tVgqZqnHZVum584KuUKSQZgcy6ZkhVd6gG8QWg2QfIXH9HmXdamauxdVsLXwaNPJxEdOyfAfwIyi5XUsiVYWtg==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.87.1", - "@supabase/functions-js": "2.87.1", - "@supabase/postgrest-js": "2.87.1", - "@supabase/realtime-js": "2.87.1", - "@supabase/storage-js": "2.87.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -7855,6 +7786,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -8225,6 +8157,7 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8240,12 +8173,6 @@ "@types/pg": "*" } }, - "node_modules/@types/phoenix": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", - "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", - "license": "MIT" - }, "node_modules/@types/pubsub-js": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@types/pubsub-js/-/pubsub-js-1.8.6.tgz", @@ -8424,6 +8351,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -8910,39 +8838,6 @@ "win32" ] }, - "node_modules/@upstash/core-analytics": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", - "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", - "license": "MIT", - "dependencies": { - "@upstash/redis": "^1.28.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@upstash/ratelimit": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.7.tgz", - "integrity": "sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw==", - "license": "MIT", - "dependencies": { - "@upstash/core-analytics": "^0.0.10" - }, - "peerDependencies": { - "@upstash/redis": "^1.34.3" - } - }, - "node_modules/@upstash/redis": { - "version": "1.35.7", - "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.7.tgz", - "integrity": "sha512-bdCdKhke+kYUjcLLuGWSeQw7OLuWIx3eyKksyToLBAlGIMX9qiII0ptp8E0y7VFE1yuBxBd/3kSzJ8774Q4g+A==", - "license": "MIT", - "dependencies": { - "uncrypto": "^0.1.3" - } - }, "node_modules/@vercel/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz", @@ -9465,7 +9360,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@webcomponents/template-shadowroot/-/template-shadowroot-0.2.1.tgz", "integrity": "sha512-fXL/vIUakyZL62hyvUh+EMwbVoTc0hksublmRz6ai6et8znHkJa6gtqMUZo1oc7dIz46exHSIImml9QTdknMHg==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@webgpu/types": { "version": "0.1.21", @@ -9487,6 +9383,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9875,6 +9772,7 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.5.tgz", "integrity": "sha512-QeuM4xzTR0QuXFDNlGVW0BW7rcquKFIkylaPeM4ufii0/RRiPTYtwxDYVZ3KfiMRuuc+nbLD0214kMKTvz/yvQ==", "license": "MIT", + "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -10338,6 +10236,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10591,37 +10490,6 @@ "node": "*" } }, - "node_modules/bin-links": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", - "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", - "dev": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "proc-log": "^6.0.0", - "read-cmd-shim": "^6.0.0", - "write-file-atomic": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/bin-links/node_modules/write-file-atomic": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", - "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -10659,7 +10527,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/boxen": { @@ -10733,6 +10601,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -11206,16 +11075,6 @@ "node": ">=6" } }, - "node_modules/cmd-shim": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", - "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -12278,7 +12137,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -12671,6 +12531,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12923,6 +12784,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -14907,15 +14769,6 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -15842,6 +15695,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -16290,6 +16144,7 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16546,6 +16401,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -18073,6 +17929,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -18249,16 +18106,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", - "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -19023,8 +18870,8 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -19069,6 +18916,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19215,6 +19063,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -19244,6 +19093,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19258,8 +19108,9 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz", "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", @@ -19305,16 +19156,6 @@ "node": ">=6" } }, - "node_modules/proc-log": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.0.0.tgz", - "integrity": "sha512-KG/XsTDN901PNfPfAMmj6N/Ywg9tM+bHK8pAz+27fS4N4Pcr+4zoYBOcGSBu6ceXYNPxkLpa4ohtfxV1XcLAfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -19509,16 +19350,6 @@ "node": ">=0.10.0" } }, - "node_modules/read-cmd-shim": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", - "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -20519,7 +20350,7 @@ "version": "0.0.15", "resolved": "https://registry.npmjs.org/s.color/-/s.color-0.0.15.tgz", "integrity": "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/safe-array-concat": { @@ -20614,7 +20445,7 @@ "version": "0.7.9", "resolved": "https://registry.npmjs.org/sass-formatter/-/sass-formatter-0.7.9.tgz", "integrity": "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "suf-log": "^2.5.3" @@ -21496,6 +21327,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -21725,6 +21557,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -21765,75 +21598,12 @@ "version": "2.5.3", "resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz", "integrity": "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "s.color": "0.0.15" } }, - "node_modules/supabase": { - "version": "2.65.8", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.65.8.tgz", - "integrity": "sha512-ZEk9Mq1iambcg+GX1JT258eR2LpoNF2a6a6h1VZEPFNNSB54Yg7Z9bd8WlUM/XtB8p8sE37+AfzVXShNzcMIYg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bin-links": "^6.0.0", - "https-proxy-agent": "^7.0.2", - "node-fetch": "^3.3.2", - "tar": "7.5.2" - }, - "bin": { - "supabase": "bin/supabase" - }, - "engines": { - "npm": ">=8" - } - }, - "node_modules/supabase/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/supabase/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/supabase/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -22109,7 +21879,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -22212,6 +21983,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -22297,6 +22069,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22624,8 +22397,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23082,6 +22855,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -23363,6 +23137,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -23515,6 +23290,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -23547,6 +23323,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -24298,6 +24075,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24434,6 +24212,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -24884,6 +24663,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -25046,6 +24826,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 354d3e9f5..f79551c72 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "build:ci": "cross-env ASTRO_DATABASE_FILE=./db/dev.db npm run build", "check": "npx astro check", "clean": "FORCE_COLOR=1 npx rimraf dist && FORCE_COLOR=1 npx rimraf .astro", + "containers:down": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down -v", + "containers:status": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml ps", + "containers:logs": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f", "dev": "npm run sync && cross-env NODE_ENV=development FORCE_COLOR=1 npx astro dev", "dev:env": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev", "format": "npm run format:json && npm run format:code && npm run format:style", @@ -44,21 +47,9 @@ "lint:style": "FORCE_COLOR=1 npx stylelint \"src/**/*.{css,astro}\"", "lint:actions": "FORCE_COLOR=1 npx node-actionlint", "sync": "FORCE_COLOR=1 npx astro sync", - "containers:up": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml up -d", - "containers:down": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down -v", - "containers:logs": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f", - "containers:status": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml ps", - "containers:wait": "bash test/containers/scripts/wait-for-services.sh", - "containers:supabase:start": "bash test/containers/supabase/start.sh", - "containers:supabase:stop": "FORCE_COLOR=1 npx supabase stop --workdir suprabase", - "containers:supabase:status": "FORCE_COLOR=1 npx supabase status --workdir suprabase", - "containers:supabase:logs": "FORCE_COLOR=1 bash test/containers/supabase/logs.sh", - "containers:supabase:db-push": "FORCE_COLOR=1 dotenv -e test/containers/.env -- bash -c 'npx supabase migration up --local --workdir suprabase --debug'", - "supabase:db:push": "FORCE_COLOR=1 npx supabase db push --workdir suprabase", "test": "npm run test:unit && npm run test:e2e", "test:coverage": "FORCE_COLOR=1 npx vitest run --coverage", "test:e2e": "FORCE_COLOR=1 npx playwright test", - "test:e2e:full": "dotenv -e .env.development -- cross-env FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test", "test:unit": "FORCE_COLOR=1 npx vitest run", "upgrade": "npx @astrojs/upgrade", "prepare": "node .husky/prepare.js" @@ -79,14 +70,11 @@ "@sentry/astro": "^10.29.0", "@sentry/browser": "^10.29.0", "@shikijs/transformers": "^3.19.0", - "@supabase/supabase-js": "^2.87.1", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.17", "@types/hast": "^3.0.4", "@types/pubsub-js": "^1.8.6", - "@upstash/ratelimit": "^2.0.7", - "@upstash/redis": "^1.35.7", "@vite-pwa/astro": "^1.2.0", "@webcomponents/template-shadowroot": "^0.2.1", "astro": "5.16.5", @@ -183,7 +171,6 @@ "stylelint-config-standard": "^39.0.1", "stylelint-declaration-block-no-ignored-properties": "2.8.0", "stylelint-order": "7.0.0", - "supabase": "^2.65.8", "temp-dir": "3.0.0", "tslib": "2.8.1", "typescript": "5.9.3", diff --git a/playwright.config.ts b/playwright.config.ts index 78ae68464..c59596d4b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test' +import { testDatabaseFile } from './test/e2e/config/runtime/paths' /** * Read environment variables from file. @@ -9,6 +10,9 @@ import { isCI } from 'src/lib/config/environmentServer' if ( !isCI() ) dotenv.config({ path: '.env.development' }) +process.env['ASTRO_DATABASE_FILE'] = process.env['ASTRO_DATABASE_FILE'] ?? testDatabaseFile +process.env['PLAYWRIGHT'] = process.env['PLAYWRIGHT'] ?? 'true' + /** * See https://playwright.dev/docs/test-configuration. */ @@ -16,6 +20,21 @@ if ( !isCI() ) dotenv.config({ path: '.env.development' }) /** Debug mode - set CI=1 or CI=true to run only chromium with no HTML report */ const isCIMode = Boolean(process.env['CI'] && (process.env['CI'] === 'true') || process.env['CI'] === '1') +const buildWebServerEnv = () => { + const env: Record = {} + + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') { + env[key] = value + } + } + + env['PLAYWRIGHT'] = 'true' + env['ASTRO_DATABASE_FILE'] = testDatabaseFile + + return env +} + export default defineConfig({ /* Look for test files in the "tests" directory, relative to this configuration file. */ testDir: './test/e2e/specs', @@ -119,10 +138,12 @@ export default defineConfig({ /** How long to wait for the process to start up and be available in milliseconds. */ timeout: 120 * 1000, reuseExistingServer: !process.env['CI'], + env: buildWebServerEnv(), }, }), globalSetup: './test/e2e/config/global-setup.ts', + globalTeardown: './test/e2e/config/global-teardown.ts', // path to the global teardown files. //globalTeardown: require.resolve('./global-teardown'), diff --git a/src/components/scripts/consent/db/supabase.ts b/src/components/scripts/consent/db/supabase.ts deleted file mode 100644 index c625223b2..000000000 --- a/src/components/scripts/consent/db/supabase.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createClient } from '@supabase/supabase-js' -import { SUPABASE_KEY, SUPABASE_URL} from 'astro:env/client' - -// Public client (client-side, respects RLS) -export const supabasePublic = createClient( - SUPABASE_URL, - SUPABASE_KEY -) diff --git a/src/lib/config/environmentalVariableValidation.ts b/src/lib/config/environmentalVariableValidation.ts index 9e3ef11e6..fd0a66b9f 100644 --- a/src/lib/config/environmentalVariableValidation.ts +++ b/src/lib/config/environmentalVariableValidation.ts @@ -84,34 +84,6 @@ export const environmentalVariablesConfig: AstroUserConfig['env'] = { context: 'server', optional: false, }), - /** - * Site uses Vercel Upstash integration for rate limiting on API endpoints - */ - KV_URL: envField.string({ // not currently used so set to secret - access: 'secret', - context: 'server', - optional: false, - }), - KV_REST_API_URL: envField.string({ - access: 'public', - context: 'server', - optional: false, - }), - KV_REST_API_TOKEN: envField.string({ - access: 'public', - context: 'server', - optional: false, - }), - KV_REST_API_READ_ONLY_TOKEN: envField.string({ // not currently used so set to secret - access: 'secret', - context: 'server', - optional: false, - }), - REDIS_URL: envField.string({ // not currently used - access: 'secret', - context: 'server', - optional: false, - }), /** * Site uses Resend for sending site emails */ @@ -136,24 +108,6 @@ export const environmentalVariablesConfig: AstroUserConfig['env'] = { context: 'client', optional: false, // Optional - Sentry only enabled if provided }), - /** - * Site uses Suprabase for managing GDPR consent records - */ - SUPABASE_KEY: envField.string({ - access: 'public', - context: 'client', - optional: false, - }), - SUPABASE_URL: envField.string({ - access: 'public', - context: 'client', - optional: false, - }), - SUPABASE_SERVICE_ROLE_KEY: envField.string({ - access: 'public', - context: 'server', - optional: false, - }), /** * Site uses ConvertKit for managing newsletter subscriptions */ diff --git a/src/pages/api/_environment/environmentApi.ts b/src/pages/api/_environment/environmentApi.ts index 8a3edfd93..46d2e21ac 100644 --- a/src/pages/api/_environment/environmentApi.ts +++ b/src/pages/api/_environment/environmentApi.ts @@ -6,7 +6,7 @@ * functions with process.env. */ import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { isE2eTest, isTest, isUnitTest } from '@lib/config/environmentServer' +import { isE2eTest, isUnitTest } from '@lib/config/environmentServer' export { isCI, isE2eTest, @@ -133,9 +133,9 @@ export function getResendApiKey(): string { * Returns the base URL for the local Resend mock when running e2e tests. * Falls back to localhost + RESEND_HTTP_PORT when RESEND_MOCK_URL is not provided. */ -export function getResendMockBaseUrl(options?: { force?: boolean }): string | null { +export function getResendMockBaseUrl(): string | null { const explicit = process.env['RESEND_MOCK_URL'] - const mocksEnabled = Boolean(options?.force) || process.env['E2E_MOCKS'] === '1' || Boolean(explicit) || isE2eTest() + const mocksEnabled = Boolean(explicit) || isE2eTest() if (!mocksEnabled) { return null @@ -144,7 +144,7 @@ export function getResendMockBaseUrl(options?: { force?: boolean }): string | nu if (explicit) { return explicit.replace(/\/$/, '') } - const host = process.env['E2E_MOCKS_HOST'] ?? '127.0.0.1' + const host = process.env['WIREMOCK_HOST'] ?? '127.0.0.1' const port = process.env['RESEND_HTTP_PORT'] ?? '9011' return `http://${host}:${port}` } @@ -164,81 +164,3 @@ export function getSentryDsn(): string { } return key } - -/** - * Gets the Suprabase REST API URL token. This value is set in Vercel env vars and - * made available to serverless functions by default. - * - * @throws {ApiFunctionError} If SUPABASE_URL is not set - */ -export function getSuprabaseApiUrl(): string { - const url = process.env['SUPABASE_URL'] - if (!url) { - throw new ApiFunctionError( - 'SUPABASE_URL environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return url -} - -/** - * Gets the Suprabase REST API URL token. This value is set in Vercel env vars and - * made available to serverless functions by default. - * - * @throws {ApiFunctionError} If SUPABASE_SERVICE_ROLE_KEY is not set - */ -export function getSuprabaseApiServiceRoleKey(): string { - const token = process.env['SUPABASE_SERVICE_ROLE_KEY'] - if (!token) { - throw new ApiFunctionError( - 'SUPABASE_SERVICE_ROLE_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return token -} - -/** - * Gets the Upstash REST API URL. This value is set in Vercel env vars and - * made available to serverless functions by default. - * - * @throws {ApiFunctionError} If KV_REST_API_URL is not set - */ -export function getUpstashApiUrl(): string { - const url = process.env['KV_REST_API_URL'] - if (!url) { - throw new ApiFunctionError( - 'KV_REST_API_URL environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return url -} - -/** - * Gets the Upstash REST API secret token. This value is set in Vercel env vars and - * made available to serverless functions by default. - * - * @throws {ApiFunctionError} If KV_REST_API_TOKEN is not set - */ -export function getUpstashApiToken(): string { - const token = process.env['KV_REST_API_TOKEN'] - if (!token) { - throw new ApiFunctionError( - 'KV_REST_API_TOKEN environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return token -} - -/** - * Determines whether Supabase operations may fall back to mocked responses. - * Enabled automatically for end-to-end runs that set E2E_MOCKS=1, but can also be - * toggled explicitly with E2E_SUPABASE_FALLBACK=1 when running smoke tests without Supabase. - */ -export function isSupabaseFallbackEnabled(): boolean { - return ( - process.env['E2E_SUPABASE_FALLBACK'] === '1' || - process.env['E2E_MOCKS'] === '1' || - isDev() || - isTest() - ) -} diff --git a/src/pages/api/_utils/index.ts b/src/pages/api/_utils/index.ts index d6b5d5299..886d74da1 100644 --- a/src/pages/api/_utils/index.ts +++ b/src/pages/api/_utils/index.ts @@ -1,4 +1,3 @@ -export { supabaseAdmin } from './suprabase' export { rateLimiters, checkRateLimit, diff --git a/src/pages/api/_utils/suprabase.ts b/src/pages/api/_utils/suprabase.ts deleted file mode 100644 index 5d71e8f06..000000000 --- a/src/pages/api/_utils/suprabase.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createClient, type SupabaseClient } from '@supabase/supabase-js' -import { captureException } from '@sentry/astro' -import { ensureApiSentry } from '@pages/api/_sentry' -import { getSuprabaseApiServiceRoleKey, getSuprabaseApiUrl, isProd } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError, normalizeUnknownApiError } from '@pages/api/_errors/ApiFunctionError' - -let cachedSupabaseAdmin: SupabaseClient | null = null -let supabaseInitError: ApiFunctionError | null = null -let hasLoggedInitError = false - -const SUPABASE_INIT_FALLBACK_MESSAGE = 'Failed to initialize Supabase admin client.' -const SUPABASE_INIT_OPERATION = 'supabaseAdmin.initialize' -const SUPABASE_INIT_ERROR_CODE = 'SUPABASE_INIT_FAILED' - -const logSupabaseInitError = (error: Error) => { - if (hasLoggedInitError) { - return - } - - hasLoggedInitError = true - - if (isProd()) { - ensureApiSentry() - captureException(error) - } else { - console.error('[supabaseAdmin] Failed to initialize Supabase admin client:', error) - } -} - -const getSupabaseAdminClient = (): SupabaseClient => { - if (cachedSupabaseAdmin) { - return cachedSupabaseAdmin - } - - if (supabaseInitError) { - throw supabaseInitError - } - - try { - cachedSupabaseAdmin = createClient( - getSuprabaseApiUrl(), - getSuprabaseApiServiceRoleKey(), - { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }, - ) - - return cachedSupabaseAdmin - } catch (error) { - const normalizedError = normalizeUnknownApiError(error, SUPABASE_INIT_FALLBACK_MESSAGE) - const apiError = ApiFunctionError.from(normalizedError, { - status: 500, - code: SUPABASE_INIT_ERROR_CODE, - operation: SUPABASE_INIT_OPERATION, - details: { - message: normalizedError.message, - }, - }) - - supabaseInitError = apiError - logSupabaseInitError(apiError) - throw apiError - } -} - -export const supabaseAdmin = new Proxy({} as SupabaseClient, { - get(_target, property, receiver) { - const client = getSupabaseAdminClient() - const value = Reflect.get(client, property, receiver) - if (typeof value === 'function') { - return value.bind(client) - } - return value - }, -}) - -export const __resetSupabaseAdminForTests = () => { - cachedSupabaseAdmin = null - supabaseInitError = null - hasLoggedInitError = false -} diff --git a/src/pages/api/contact/index.ts b/src/pages/api/contact/index.ts index ca1e7ec88..0c31c855e 100644 --- a/src/pages/api/contact/index.ts +++ b/src/pages/api/contact/index.ts @@ -43,8 +43,6 @@ interface EmailData { html: string } -const E2E_MOCKS_HEADER = 'x-e2e-mocks' - /** * Validate contact form input */ @@ -285,8 +283,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { 'unknown' try { - const forceMockResend = request.headers.get(E2E_MOCKS_HEADER) === '1' - const resendMockBaseUrl = getResendMockBaseUrl({ force: forceMockResend }) + const resendMockBaseUrl = getResendMockBaseUrl() const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) if (!checkContactRateLimit(rateLimitIdentifier)) { diff --git a/src/pages/api/gdpr/consent.ts b/src/pages/api/gdpr/consent.ts index b8736e769..9267d06d0 100644 --- a/src/pages/api/gdpr/consent.ts +++ b/src/pages/api/gdpr/consent.ts @@ -1,6 +1,5 @@ -import { randomUUID } from 'node:crypto' import type { APIRoute } from 'astro' -import { getPrivacyPolicyVersion, isSupabaseFallbackEnabled } from '@pages/api/_environment/environmentApi' +import { getPrivacyPolicyVersion } from '@pages/api/_environment/environmentApi' import { rateLimiters, checkRateLimit } from '@pages/api/_utils' import { validate as uuidValidate } from 'uuid' import type { ConsentRequest, ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' @@ -97,35 +96,6 @@ const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] return mapped } -const buildMockConsentRecord = (body: ConsentRequest): ConsentResponse['record'] => { - const normalizedEmail = normalizeNullableString(body.email ?? null) - const normalizedIpAddress = normalizeNullableString(body.ipAddress ?? null) - const normalizedConsentText = normalizeNullableString(body.consentText ?? null) - - const mockRecord: ConsentResponse['record'] = { - id: randomUUID(), - DataSubjectId: body.DataSubjectId, - purposes: sanitizePurposes(body.purposes), - timestamp: new Date().toISOString(), - source: sanitizeSource(body.source), - userAgent: normalizeUserAgent(body.userAgent), - privacyPolicyVersion: getPrivacyPolicyVersion(), - verified: body.verified ?? false, - } - - if (normalizedEmail) { - mockRecord.email = normalizedEmail - } - if (normalizedIpAddress) { - mockRecord.ipAddress = normalizedIpAddress - } - if (normalizedConsentText) { - mockRecord.consentText = normalizedConsentText - } - - return mockRecord -} - const buildErrorResponse = ( error: unknown, context: ReturnType['context'], @@ -204,19 +174,15 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { }) record = mapConsentRecord(dbRecord) } catch (error) { - if (!isSupabaseFallbackEnabled()) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'insert-consent', - status: 500, - details: { - dataSubjectId: body.DataSubjectId, - purposes: body.purposes, - }, - }) - } - console.warn('[gdpr/consent] Astro DB unavailable, returning mocked consent record for e2e tests.') - record = buildMockConsentRecord(body) + throw new ApiFunctionError(error, { + route: ROUTE, + operation: 'insert-consent', + status: 500, + details: { + dataSubjectId: body.DataSubjectId, + purposes: body.purposes, + }, + }) } return jsonResponse( diff --git a/src/pages/api/newsletter/__tests__/_confirm.spec.ts b/src/pages/api/newsletter/__tests__/_confirm.spec.ts index 6f384cb7e..289804e12 100644 --- a/src/pages/api/newsletter/__tests__/_confirm.spec.ts +++ b/src/pages/api/newsletter/__tests__/_confirm.spec.ts @@ -106,8 +106,7 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { // Verify welcome email was sent (force mock disabled by default) expect(mockSendWelcomeEmail).toHaveBeenCalledWith( 'test@example.com', - 'John', - expect.objectContaining({ forceMockResend: false }), + 'John' ) expect(mockSubscribeToConvertKit).toHaveBeenCalledWith( @@ -148,8 +147,7 @@ describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { expect(data.success).toBe(true) expect(mockSendWelcomeEmail).toHaveBeenCalledWith( 'test@example.com', - undefined, - expect.objectContaining({ forceMockResend: false }), + undefined ) }) diff --git a/src/pages/api/newsletter/__tests__/_index.spec.ts b/src/pages/api/newsletter/__tests__/_index.spec.ts index b47691b2e..577765fac 100644 --- a/src/pages/api/newsletter/__tests__/_index.spec.ts +++ b/src/pages/api/newsletter/__tests__/_index.spec.ts @@ -107,8 +107,7 @@ describe('Newsletter API - POST /api/newsletter', () => { expect(mockSendConfirmationEmail).toHaveBeenCalledWith( 'test@example.com', 'test-token-123', - 'John', - expect.objectContaining({ forceMockResend: false }), + 'John' ) }) @@ -227,8 +226,7 @@ describe('Newsletter API - POST /api/newsletter', () => { expect(mockSendConfirmationEmail).toHaveBeenCalledWith( 'test@example.com', 'test-token-123', - undefined, - expect.objectContaining({ forceMockResend: false }), + undefined ) }) diff --git a/src/pages/api/newsletter/_email.ts b/src/pages/api/newsletter/_email.ts index 2115bf97d..19a83cc5b 100644 --- a/src/pages/api/newsletter/_email.ts +++ b/src/pages/api/newsletter/_email.ts @@ -23,10 +23,6 @@ function getMockAuthorizationHeader(): string { } } -function resolveResendMockBaseUrl(force?: boolean) { - return force === undefined ? getResendMockBaseUrl() : getResendMockBaseUrl({ force }) -} - /** * Generate the HTML content for the confirmation email */ @@ -202,17 +198,12 @@ Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe * @returns Promise that resolves when email is sent * @throws {Error} If Resend API key is not configured or email fails to send */ -interface SendConfirmationEmailOptions { - forceMockResend?: boolean -} - export async function sendConfirmationEmail( email: string, token: string, - firstName?: string, - options?: SendConfirmationEmailOptions + firstName?: string ): Promise { - const resendMockBaseUrl = resolveResendMockBaseUrl(options?.forceMockResend) + const resendMockBaseUrl = getResendMockBaseUrl() const siteUrl = getSiteUrl() const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` const expiresIn = '24 hours' @@ -301,16 +292,11 @@ export async function sendConfirmationEmail( * @param email - Subscriber's email address * @param firstName - Optional subscriber first name for personalization */ -interface SendWelcomeEmailOptions { - forceMockResend?: boolean -} - export async function sendWelcomeEmail( email: string, - firstName?: string, - options?: SendWelcomeEmailOptions + firstName?: string ): Promise { - const resendMockBaseUrl = resolveResendMockBaseUrl(options?.forceMockResend) + const resendMockBaseUrl = getResendMockBaseUrl() if (!resendMockBaseUrl && (isDev() || isTest())) { console.log('[DEV/TEST MODE] Newsletter welcome email would be sent:', { email }) diff --git a/src/pages/api/newsletter/confirm.ts b/src/pages/api/newsletter/confirm.ts index 2fe339318..4ae5d3c0f 100644 --- a/src/pages/api/newsletter/confirm.ts +++ b/src/pages/api/newsletter/confirm.ts @@ -13,8 +13,6 @@ import { markConsentRecordsVerified } from '@pages/api/gdpr/_utils/consentStore' import { confirmSubscription } from './_token' import { sendWelcomeEmail } from './_email' -const E2E_MOCKS_HEADER = 'x-e2e-mocks' - export const prerender = false // Force SSR for this endpoint const ROUTE = '/api/newsletter/confirm' @@ -36,7 +34,6 @@ export const GET: APIRoute = async ({ url, request, cookies, clientAddress }) => const token = url.searchParams.get('token') apiContext.extra = { ...(apiContext.extra || {}), token } - const forceMockResend = request.headers.get(E2E_MOCKS_HEADER) === '1' try { if (!token) { @@ -77,9 +74,7 @@ export const GET: APIRoute = async ({ url, request, cookies, clientAddress }) => // Send welcome email (non-blocking, don't fail if it errors) try { - await sendWelcomeEmail(subscription.email, subscription.firstName, { - forceMockResend, - }) + await sendWelcomeEmail(subscription.email, subscription.firstName) } catch (emailError) { handleApiFunctionError(emailError, { ...apiContext, diff --git a/src/pages/api/newsletter/index.ts b/src/pages/api/newsletter/index.ts index fa515227d..830172fd0 100644 --- a/src/pages/api/newsletter/index.ts +++ b/src/pages/api/newsletter/index.ts @@ -47,8 +47,6 @@ interface ConvertKitErrorResponse { errors: string[] } -const E2E_MOCKS_HEADER = 'x-e2e-mocks' - /** * Validate email address format and length */ @@ -191,7 +189,6 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { const userAgent = request.headers.get('user-agent') || 'unknown' apiContext.extra = { ...(apiContext.extra || {}), userAgent } - const forceMockResend = request.headers.get(E2E_MOCKS_HEADER) === '1' try { const rateLimitIdentifier = createRateLimitIdentifier('newsletter:consent', fingerprint) @@ -271,9 +268,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { source: 'newsletter_form', }) - await sendConfirmationEmail(validatedEmail, token, firstName, { - forceMockResend, - }) + await sendConfirmationEmail(validatedEmail, token, firstName) return new Response( JSON.stringify({ diff --git a/suprabase/.gitignore b/suprabase/.gitignore deleted file mode 100644 index ad9264f0b..000000000 --- a/suprabase/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Supabase -.branches -.temp - -# dotenvx -.env.keys -.env.local -.env.*.local diff --git a/suprabase/TROUBLESHOOTING_ACTION_WORKFLOW.md b/suprabase/TROUBLESHOOTING_ACTION_WORKFLOW.md deleted file mode 100644 index 4e2bf0c9d..000000000 --- a/suprabase/TROUBLESHOOTING_ACTION_WORKFLOW.md +++ /dev/null @@ -1,63 +0,0 @@ -# Supabase GitHub Actions Troubleshooting - -_Last updated: 2025-12-05_ - -## Current Failure Signature - -- **Workflow:** `.github/workflows/test.yml` (E2E job) consistently fails before Playwright because Supabase migrations never create the required tables. -- **Symptoms:** - - `supabase_migrations.schema_migrations` is missing each run, so we create it manually via `psql`. - - `npx supabase migration up --local --workdir suprabase --debug` reports "Local database is up to date" immediately after querying `supabase_migrations.schema_migrations`, and does not emit any SQL. - - Schema dump (`artifacts/supabase/schema.sql`) lacks `consent_records`, `newsletter_confirmations`, and `dsar_requests`; guard script confirms tables missing. - - Table inventory from the Postgres container shows `Did not find any relation named "public.*"`, implying the CLI never runs migrations even though files exist. -- **Impact:** E2E suite cannot start (migrations guard exits 1), blocking cron and gated deployments. - -## Last Four Days of Changes (files: workflows, `suprabase/**`, `package.json`) - -| Date | Commit | Area | Summary | -| ---- | ------ | ---- | ------- | -| 2025-12-05 | `6d2abe6` | `suprabase/`, workflow | Migrated flat SQL files into timestamped folders so Supabase CLI can detect them; still seeing "up to date". | -| 2025-12-05 | `5e5c503` | test workflow | Added manual Docker pull with exponential backoff to dodge AWS ECR rate limits (`storage-api` image throttling). | -| 2025-12-05 | `4fa4135` | test workflow | Added catalog bootstrap step that creates `supabase_migrations.schema_migrations` via `psql` when missing. | -| 2025-12-05 | `2dc0150` | test workflow | Expanded migration logging (dir listings, schema dumps, table inventory) for observability. | -| 2025-12-05 | `9aed63a` | test workflow & `package.json` | Updated Supabase script to run `migration up` instead of `db push`; more verbose logging. | -| 2025-12-04 | `de6e1f9` | test workflow | Guard now talks to the actual Postgres container rather than the meta sidecar. | -| 2025-12-04 | `c263f13` | test workflow | Added schema dump artifacts and JSON migration status logging. | -| 2025-12-04 | `49dc3c8` | test workflow | Persisted Supabase schema log artifacts for later inspection. | -| 2025-12-04 | `7c864a2` | workflows & `package.json` | Swapped Ruby `dotenv` usage for Node CLI inside Actions; adjusted scripts accordingly. | -| 2025-12-04 | `cf9bb82` | workflows, package | Refactored workflows for clarity; added containers script to push migrations locally. | - -_Older commits prior to four days introduced the `build-and-test.yml` predecessors and earlier `supabase db push` wiring; see `git log -- .github/workflows/build-and-test.yml` for context._ - -## Attempted Fixes (Chronological) - -1. **Re-enabled CLI migrations inside Test workflow** (`cf9bb826`, Dec 4): ensured containers step existed, but migrations still skipped. -2. **Added extensive logging** (`bccc1e4c`, `c263f135`, `2dc0150f`): directory listings, schema dumps, JSON status, Postgres table inventory to capture evidence. -3. **Swapped dotenv & CLI invocation** (`7c864a2d`, `9aed63a7`): used Node-based `dotenv-cli` and later `npx supabase migration up` to mimic local behavior. -4. **Manual catalog bootstrap** (`4fa4135c`): created `supabase_migrations.schema_migrations` schema/table before CLI runs to avoid `42P01` errors observed earlier. -5. **Image throttling guard** (`5e5c5037`): pre-pulled `public.ecr.aws/supabase/storage-api:v1.32.1` with retries to prevent `toomanyrequests` failures during `supabase start`. -6. **Migration file restructuring** (`16855f89`, `6d2abe62`): moved migrations into timestamped directories with `migration.sql` to align with Supabase CLI expectations. -7. **Schema guard enforcement** (multiple commits): script ensures required tables exist; still failing with same missing tables. - -## Working Theory - -- The Supabase CLI appears to think the local project has already run every migration. Evidence: `migration up` immediately exits after reading an empty `schema_migrations` table we just created. -- Possible causes: - - Supabase CLI caches migration state in `.branches/_current_branch` or `.temp/profile`; we delete these on each checkout so CLI may consider the project uninitialized and skip? (But logs show it reads zero rows then stops.) - - Because `supabase start` spins up a fresh Postgres container every workflow run, migrations might require `supabase db reset` (which runs `db stop && db start && migration up`) rather than `migration up`. Without a shadow DB or `link`, CLI might decide there are no changes to apply. - - The CLI expects a corresponding `supabase/migrations/meta` directory (created via `supabase migration new`). Our hand-made directories lack `migration.sql` metadata files (like `snapshot.sql`), so CLI ignores them even though the files exist. - -## Suggested Next Steps (when time allows) - -1. **Recreate migrations with CLI tooling**: - - Run `supabase migration new ` locally to generate the folder structure with `migration.sql` + `snapshot.sql`. Copy the SQL into those files, commit, and verify `migration list` now shows pending versions. -2. **Try `supabase db reset --local --workdir suprabase`**: - - This command runs all migrations against a clean database; use it instead of `migration up` to ensure tables get recreated each CI run. -3. **Preserve CLI metadata**: - - Commit the `.branches` and `.temp` contents or generate them before calling `migration up`. The “open supabase/.temp/profile: no such file” message suggests the CLI aborts early when the profile is missing. -4. **Add diagnostic `supabase migration list --format json` before running migrations**: - - Capture whether the CLI sees the new timestamped directories at all. -5. **Consider bypassing CLI**: - - As a fallback, run `psql` against the container using the SQL files directly (e.g., `cat migrations/*/migration.sql | docker exec … psql`). This trades Supabase tooling for deterministic migrations but unblocks the suite while CLI issues are investigated. - -Document whatever new data you collect here so the next debugging session has full context. diff --git a/suprabase/config.toml b/suprabase/config.toml deleted file mode 100644 index fc44df110..000000000 --- a/suprabase/config.toml +++ /dev/null @@ -1,351 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "astro.webstackbuilders.com" - -[api] -enabled = true -# Port to use for the API URL. -port = 54321 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = false -# Paths to self-signed certificate pair. -# cert_path = "../certs/my-cert.pem" -# key_path = "../certs/my-key.pem" - -[db] -# Port to use for the local database URL. -port = 54322 -# Port used by db diff command to initialize the shadow database. -shadow_port = 54320 -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 17 -replica_region = "us-east-1" - -[db.pooler] -enabled = false -# Port to use for the local connection pooler. -port = 54329 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -# [db.vault] -# secret_key = "env(SECRET_VALUE)" - -[db.migrations] -# If disabled, migrations will be skipped during a db push or reset. -enabled = true -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" -schema_paths = [] - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[db.network_restrictions] -# Enable management of network restrictions. -enabled = false -# List of IPv4 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv4 connections. Set empty array to block all IPs. -allowed_cidrs = ["0.0.0.0/0"] -# List of IPv6 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv6 connections. Set empty array to block all IPs. -allowed_cidrs_v6 = ["::/0"] - -[realtime] -enabled = true -replica_region = "us-east-1" -# Bind realtime via either IPv4 or IPv6. (default: IPv4) -# ip_version = "IPv6" -# The maximum length in bytes of HTTP request headers. (default: 4096) -# max_header_length = 4096 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 54323 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] -enabled = true -# Port to use for the email testing server web interface. -port = 54324 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -# Image transformation API is available to Supabase Pro plan. -# [storage.image_transformation] -# enabled = true - -# Uncomment to configure local storage buckets -# [storage.buckets.images] -# public = false -# file_size_limit = "50MiB" -# allowed_mime_types = ["image/png", "image/jpeg"] -# objects_path = "./images" - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# Path to JWT signing key. DO NOT commit your signing keys file to git. -# signing_keys_path = "./signing_keys.json" -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = false -# Allow/disallow testing manual linking of accounts -enable_manual_linking = false -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -[auth.rate_limit] -# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 -# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. -sms_sent = 30 -# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. -anonymous_users = 30 -# Number of sessions that can be refreshed in a 5 minute interval per IP address. -token_refresh = 150 -# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). -sign_in_sign_ups = 30 -# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. -token_verifications = 30 -# Number of Web3 logins that can be made in a 5 minute interval per IP address. -web3 = 30 - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -# [auth.captcha] -# enabled = true -# provider = "hcaptcha" -# secret = "" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = false -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -# [auth.email.smtp] -# enabled = true -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -# Uncomment to customize email template -# [auth.email.template.invite] -# subject = "You have been invited" -# content_path = "./supabase/templates/invite.html" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = false -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ .Code }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -# [auth.sms.test_otp] -# 4152127777 = "123456" - -# Configure logged in session timeouts. -# [auth.sessions] -# Force log out after the specified duration. -# timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -# inactivity_timeout = "8h" - -# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. -# [auth.hook.before_user_created] -# enabled = true -# uri = "pg-functions://postgres/auth/before-user-created-hook" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -# [auth.hook.custom_access_token] -# enabled = true -# uri = "pg-functions:////" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = false -account_sid = "" -message_service_sid = "" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -# Control how many MFA factors can be enrolled at once per user. -max_enrolled_factors = 10 - -# Control MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = false -verify_enabled = false - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = false -verify_enabled = false -otp_length = 6 -template = "Your code is {{ .Code }}" -max_frequency = "5s" - -# Configure MFA via WebAuthn -# [auth.mfa.web_authn] -# enroll_enabled = true -# verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.apple] -enabled = false -client_id = "" -# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth redirectUrl. -redirect_uri = "" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = false -# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. -email_optional = false - -# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. -# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. -[auth.web3.solana] -enabled = false - -# Use Firebase Auth as a third-party provider alongside Supabase Auth. -[auth.third_party.firebase] -enabled = false -# project_id = "my-firebase-project" - -# Use Auth0 as a third-party provider alongside Supabase Auth. -[auth.third_party.auth0] -enabled = false -# tenant = "my-auth0-tenant" -# tenant_region = "us" - -# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. -[auth.third_party.aws_cognito] -enabled = false -# user_pool_id = "my-user-pool-id" -# user_pool_region = "us-east-1" - -# Use Clerk as a third-party provider alongside Supabase Auth. -[auth.third_party.clerk] -enabled = false -# Obtain from https://clerk.com/setup/supabase -# domain = "example.clerk.accounts.dev" - -# OAuth server configuration -[auth.oauth_server] -# Enable OAuth server functionality -enabled = false -# Path for OAuth consent flow UI -authorization_url_path = "/oauth/consent" -# Allow dynamic client registration -allow_dynamic_registration = false - -[edge_runtime] -enabled = true -# Supported request policies: `oneshot`, `per_worker`. -# `per_worker` (default) — enables hot reload during local development. -# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). -policy = "per_worker" -# Port to attach the Chrome inspector for debugging edge functions. -inspector_port = 8083 -# The Deno major version to use. -deno_version = 2 - -# [edge_runtime.secrets] -# secret_key = "env(SECRET_VALUE)" - -[analytics] -enabled = true -port = 54327 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "" -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "env(S3_HOST)" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "env(S3_REGION)" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "env(S3_ACCESS_KEY)" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/suprabase/migrations/20240101000000_create_consent_records/migration.sql b/suprabase/migrations/20240101000000_create_consent_records/migration.sql deleted file mode 100644 index f29c4ecd5..000000000 --- a/suprabase/migrations/20240101000000_create_consent_records/migration.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE consent_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - data_subject_id UUID NOT NULL, - email TEXT, - purposes TEXT[] NOT NULL, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - source TEXT NOT NULL, - user_agent TEXT NOT NULL, - ip_address INET, - privacy_policy_version TEXT NOT NULL, - consent_text TEXT, - verified BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Indexes -CREATE INDEX idx_consent_data_subject_id ON consent_records(data_subject_id); -CREATE INDEX idx_consent_email ON consent_records(email) WHERE email IS NOT NULL; -CREATE INDEX idx_consent_timestamp ON consent_records(timestamp DESC); - --- RLS -ALTER TABLE consent_records ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role_all_access" -ON consent_records -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); diff --git a/suprabase/migrations/20240101000100_create_newsletter_confirmations/migration.sql b/suprabase/migrations/20240101000100_create_newsletter_confirmations/migration.sql deleted file mode 100644 index 7f53afd67..000000000 --- a/suprabase/migrations/20240101000100_create_newsletter_confirmations/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE newsletter_confirmations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token TEXT UNIQUE NOT NULL, - email TEXT NOT NULL, - data_subject_id UUID NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - confirmed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_newsletter_token ON newsletter_confirmations(token); -CREATE INDEX idx_newsletter_expiry ON newsletter_confirmations(expires_at) - WHERE confirmed_at IS NULL; - -ALTER TABLE newsletter_confirmations ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role_all_access" -ON newsletter_confirmations -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); diff --git a/suprabase/migrations/20240101000200_create_dsar_requests/migration.sql b/suprabase/migrations/20240101000200_create_dsar_requests/migration.sql deleted file mode 100644 index 985513084..000000000 --- a/suprabase/migrations/20240101000200_create_dsar_requests/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE dsar_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token TEXT UNIQUE NOT NULL, - email TEXT NOT NULL, - request_type TEXT NOT NULL CHECK (request_type IN ('ACCESS', 'DELETE')), - expires_at TIMESTAMPTZ NOT NULL, - fulfilled_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_dsar_token ON dsar_requests(token); -CREATE INDEX idx_dsar_expiry ON dsar_requests(expires_at) - WHERE fulfilled_at IS NULL; - -ALTER TABLE dsar_requests ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role_all_access" -ON dsar_requests -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); diff --git a/test/containers/README.md b/test/containers/README.md index c60863cde..822e15daa 100644 --- a/test/containers/README.md +++ b/test/containers/README.md @@ -1,118 +1,60 @@ # Test Containers -End-to-end tests now rely on a deterministic set of local services. Everything needed to spin them up lives in this folder so CI and local developers share the same topology. +Astro DB now handles test data, so the only external services we need during e2e runs are the WireMock clones of ConvertKit and Resend. This folder keeps their mappings, payload fixtures, and a tiny docker-compose stack so local debugging matches CI. ## Services - ConvertKit mock (`wiremock/wiremock`) responds to newsletter opt-in calls. - Resend mock (`wiremock/wiremock`) queues transactional emails without leaving the network. -- Upstash Redis local clone (`wb/upstash-redis-local`) is built from `test/containers/upstash/local-proxy/` and exposes REST + RESP interfaces backed by a dedicated `redis:7.4-alpine` container with deterministic seed data. -- Supabase (started via CLI) mirrors production database policies using the existing `suprabase/` project. ## Directory layout ```text test/containers/ -├── .env.example # Ports + tokens for docker-compose ├── convertkit/ # WireMock mappings and payload fixtures ├── resend/ # WireMock mappings for Resend endpoints -├── upstash/ # Redis seed script + declarative seed files -├── supabase/ # Helper scripts for running Supabase via CLI -├── scripts/ # Shared shell helpers (health checks, waits, etc.) -└── docker-compose.e2e.yml # Compose stack for mocks +└── docker-compose.e2e.yml # Compose stack for both mocks ``` ## Prerequisites -1. Copy the example environment file and adjust ports/tokens if necessary: +1. Ensure Docker Desktop / Engine is running. +2. Create `test/containers/.env` with any custom port overrides. The defaults are: ```bash - cp test/containers/.env.example test/containers/.env + WIREMOCK_HOST=127.0.0.1 + CONVERTKIT_HTTP_PORT=9010 + RESEND_HTTP_PORT=9011 ``` -2. Ensure Docker Desktop / Engine is running. -3. Install the Supabase CLI (`npm install --save-dev supabase` already supplies it locally). + `WIREMOCK_HOST` is consumed by both the docker-compose stack and the Astro API helpers, so override it if Docker binds the mocks to a different interface (for example, when running inside Lima or Colima). ## Usage -### 1. Start mock containers +### Automatic (Playwright) -```bash -source test/containers/.env -npm run containers:up -``` - -This command launches WireMock + Upstash services, applies Redis seeds, and exposes the ports declared in `.env`. - -### 2. Wait for health checks (optional outside CI) - -```bash -npm run containers:wait -``` - -The helper script (invoked through the npm alias) polls each HTTP endpoint until it responds or exits after the configured timeout. - -### 3. Start Supabase (runs outside docker-compose) - -```bash -npm run containers:supabase:start -``` - -The script reuses the existing `suprabase/` project directory, runs `npx supabase start`, and waits for the REST gateway to accept authenticated requests. Update `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` in `.env` if they differ from your local defaults. - -### 4. Run the dev server + Playwright - -With mocks running you can start the Astro dev server (in another terminal): - -```bash -npm run dev -``` - -Then execute the Playwright suite with the mock flag enabled: - -```bash -CI=1 FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test -``` +`npm run test:e2e` now invokes Playwright's global setup, which: -### 5. Tear everything down +1. Resets the Astro DB test database (`.cache/astro-db/e2e.db`). +2. Runs `docker compose` against `docker-compose.e2e.yml` to boot the ConvertKit and Resend mocks. +3. Registers a teardown hook so the containers stop once the suite completes. -```bash -npm run containers:down -npm run containers:supabase:stop -``` +No additional feature flags or legacy secrets are required once the compose stack is running. -### 6. Inspect Supabase services (optional) +### Manual debugging -Check status or stream API logs without leaving npm: +If you want to inspect WireMock responses outside the test harness, run the compose file directly: ```bash -npm run containers:supabase:status -npm run containers:supabase:logs +docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml up -d convertkit-mock resend-mock +# ... exercise the API ... +docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down ``` ## Extending WireMock mappings Add new JSON files under the appropriate `mappings/` folder and pair them with payloads inside `__files/`. WireMock's response templating is enabled, so you can echo pieces of the incoming request using helpers like `{{jsonPath request.body '$.field'}}`. -## Upstash seeding - -Declarative seed files live in `upstash/seeds/*.json`. Supported `type` values: - -- `queue` - pushes each `items[]` entry onto the named list. -- `hash` - sets fields under the named hash. -- `string` - sets a single string value. - -The seeding container installs `jq` automatically and pipes commands through `redis-cli` once the Redis instance is healthy. - ## CI integration -GitHub Actions can execute the same stack with: - -```bash -docker buildx build --load -t wb/upstash-redis-local:test test/containers/upstash/local-proxy -npm run containers:up -npm run containers:wait -npm run containers:supabase:start -``` - -After tests conclude run `npm run containers:supabase:stop || true` and `npm run containers:down || true` (ideally inside an `if: always()` block) to guarantee cleanup. +CI pipelines only need Docker available plus whatever secrets Resend/ConvertKit require. Running `npm run test:e2e` (or `npx playwright test`) automatically provisions the mocks and seeds Astro DB, so there are no bespoke sidecar steps to maintain. diff --git a/test/containers/docker-compose.e2e.yml b/test/containers/docker-compose.e2e.yml index ec3fa840c..52086b233 100644 --- a/test/containers/docker-compose.e2e.yml +++ b/test/containers/docker-compose.e2e.yml @@ -47,62 +47,6 @@ services: networks: - e2e-mocks - upstash-redis-db: - image: redis:7.4-alpine - container_name: ${COMPOSE_PROJECT_NAME:-wb}-upstash-redis-db - restart: unless-stopped - command: ["redis-server", "--save", "", "--appendonly", "no"] - ports: - - "${UPSTASH_REDIS_PORT:-6380}:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 6 - networks: - - e2e-mocks - - upstash-redis: - image: wb/upstash-redis-local:test - container_name: ${COMPOSE_PROJECT_NAME:-wb}-upstash-redis - restart: unless-stopped - command: - - "--token" - - "${UPSTASH_TOKEN:-local-dev-token}" - - "--addr" - - ":${UPSTASH_HTTP_PORT:-8079}" - - "--redis" - - "upstash-redis-db:6379" - ports: - - "${UPSTASH_HTTP_PORT:-8079}:${UPSTASH_HTTP_PORT:-8079}" - healthcheck: - test: - - CMD-SHELL - - >- - curl -fsS -H "Authorization: Bearer ${UPSTASH_TOKEN:-local-dev-token}" -H "Content-Type: application/json" --data '["PING"]' http://127.0.0.1:${UPSTASH_HTTP_PORT:-8079}/ >/dev/null - interval: 10s - timeout: 5s - retries: 6 - networks: - - e2e-mocks - - upstash-seed: - image: redis:7.4-alpine - container_name: ${COMPOSE_PROJECT_NAME:-wb}-upstash-seed - depends_on: - upstash-redis-db: - condition: service_healthy - environment: - REDIS_HOST: upstash-redis-db - REDIS_PORT: "6379" - REDIS_TOKEN: ${UPSTASH_TOKEN:-local-dev-token} - volumes: - - ./upstash/seed.sh:/scripts/seed.sh:ro - - ./upstash/seeds:/seeds:ro - entrypoint: ["/bin/sh", "/scripts/seed.sh"] - networks: - - e2e-mocks - networks: e2e-mocks: driver: bridge diff --git a/test/containers/scripts/wait-for-services.sh b/test/containers/scripts/wait-for-services.sh deleted file mode 100644 index 1d925e3f9..000000000 --- a/test/containers/scripts/wait-for-services.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT=$(cd "$(dirname "$0")/../../" && pwd) -ENV_FILE="${REPO_ROOT}/test/containers/.env" -TIMEOUT=${WAIT_FOR_TIMEOUT:-120} - -if [ -f "$ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$ENV_FILE" - set +a -fi - -CONVERTKIT_PORT=${CONVERTKIT_HTTP_PORT:-9010} -RESEND_PORT=${RESEND_HTTP_PORT:-9011} -UPSTASH_PORT=${UPSTASH_HTTP_PORT:-8079} -UPSTASH_TOKEN=${UPSTASH_TOKEN:-local-dev-token} - -wait_for() { - local name=$1 - local url=$2 - local start - start=$(date +%s) - - while true; do - if curl -fsS "$url" >/dev/null 2>&1; then - echo "[wait] $name ready at $url" - break - fi - - local now elapsed - now=$(date +%s) - elapsed=$((now - start)) - - if [ "$elapsed" -ge "$TIMEOUT" ]; then - echo "[wait] Timed out waiting for $name" >&2 - exit 1 - fi - - sleep 3 - done -} - -wait_for "ConvertKit" "http://127.0.0.1:${CONVERTKIT_PORT}/__admin/mappings" -wait_for "Resend" "http://127.0.0.1:${RESEND_PORT}/__admin/mappings" - -wait_for_upstash() { - local name=$1 - local url=$2 - local start - start=$(date +%s) - - while true; do - if curl -fsS \ - -H "Authorization: Bearer ${UPSTASH_TOKEN}" \ - -H "Content-Type: application/json" \ - --data '["PING"]' \ - "$url" >/dev/null 2>&1; then - echo "[wait] $name ready at $url" - break - fi - - local now elapsed - now=$(date +%s) - elapsed=$((now - start)) - - if [ "$elapsed" -ge "$TIMEOUT" ]; then - echo "[wait] Timed out waiting for $name" >&2 - exit 1 - fi - - sleep 3 - done -} - -wait_for_upstash "Upstash REST" "http://127.0.0.1:${UPSTASH_PORT}/" diff --git a/test/containers/supabase/logs.sh b/test/containers/supabase/logs.sh deleted file mode 100755 index 40256aa66..000000000 --- a/test/containers/supabase/logs.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT=$(cd "$(dirname "$0")/../../../" && pwd) -SUPABASE_DIR="${REPO_ROOT}/suprabase" -CONFIG_FILE="${SUPABASE_DIR}/config.toml" - -if [ ! -f "$CONFIG_FILE" ]; then - echo "Supabase config file not found at $CONFIG_FILE" >&2 - exit 1 -fi - -PROJECT_ID=$(grep -m1 '^project_id' "$CONFIG_FILE" | awk -F '"' '{print $2}') -if [ -z "$PROJECT_ID" ]; then - echo "Unable to determine Supabase project_id from $CONFIG_FILE" >&2 - exit 1 -fi - -RAW_COMPOSE_PROJECT_NAME="$PROJECT_ID" -SANITIZED_COMPOSE_PROJECT_NAME=$(echo "$PROJECT_ID" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g') - -if ! command -v docker >/dev/null 2>&1; then - echo "Docker is required to tail Supabase logs" >&2 - exit 1 -fi - -collect_containers() { - local project_name="$1" - docker ps --filter "label=com.docker.compose.project=${project_name}" --format '{{.ID}} {{.Names}}' -} - -mapfile -t CONTAINER_INFO < <(collect_containers "$RAW_COMPOSE_PROJECT_NAME") - -if [ ${#CONTAINER_INFO[@]} -eq 0 ] && [ "$RAW_COMPOSE_PROJECT_NAME" != "$SANITIZED_COMPOSE_PROJECT_NAME" ]; then - mapfile -t CONTAINER_INFO < <(collect_containers "$SANITIZED_COMPOSE_PROJECT_NAME") -fi - -if [ ${#CONTAINER_INFO[@]} -eq 0 ]; then - echo "No running Supabase containers found for project '${PROJECT_ID}'. Is Supabase running?" >&2 - exit 1 -fi - -echo "Tailing logs for Supabase project '${PROJECT_ID}' (${#CONTAINER_INFO[@]} containers)..." - -TAIL_PIDS=() - -cleanup() { - for pid in "${TAIL_PIDS[@]}"; do - if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then - kill "$pid" >/dev/null 2>&1 || true - fi - done -} - -trap cleanup EXIT INT TERM - -for entry in "${CONTAINER_INFO[@]}"; do - container_id="${entry%% *}" - container_name="${entry#* }" - - ( - docker logs -f "$container_id" 2>&1 | sed -e "s/^/[${container_name}] /" - ) & - TAIL_PIDS+=($!) -done - -wait diff --git a/test/containers/supabase/start.sh b/test/containers/supabase/start.sh deleted file mode 100644 index 134e2ac97..000000000 --- a/test/containers/supabase/start.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT=$(cd "$(dirname "$0")/../../../" && pwd) -SUPABASE_DIR="${REPO_ROOT}/suprabase" -ENV_FILE="${REPO_ROOT}/test/containers/.env" -ROOT_ENV_FILE="${REPO_ROOT}/.env" -DEV_ENV_FILE="${REPO_ROOT}/.env.development" -DEFAULT_SUPABASE_URL="http://127.0.0.1:54321" -DEFAULT_HEALTH_TIMEOUT=240 -declare -a SUPABASE_PORTS=(54320 54321 54322 54323 54324 54325 54326 54327 54328 54329) - -free_port() { - local port="$1" - local pid - - pid=$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) - if [ -n "$pid" ]; then - local proc - proc=$(ps -p "$pid" -o comm= 2>/dev/null || true) - if [ "$proc" = "rootlessport" ]; then - echo "Releasing stale rootlessport listener on :$port (pid $pid)" - kill "$pid" >/dev/null 2>&1 || true - sleep 1 - else - echo "Port $port is in use by $proc (pid $pid). Supabase may fail to start." >&2 - fi - fi -} - -if [ -f "$ROOT_ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$ROOT_ENV_FILE" - set +a -fi - -if [ -f "$DEV_ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$DEV_ENV_FILE" - set +a -fi - -if [ -f "$ENV_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$ENV_FILE" - set +a -fi - -if [ ! -d "$SUPABASE_DIR" ]; then - echo "Supabase project directory not found. Expected '$SUPABASE_DIR'." >&2 - exit 1 -fi - -SUPABASE_ENV_URL="${SUPABASE_URL:-$DEFAULT_SUPABASE_URL}" -SUPABASE_SERVICE_ROLE_KEY="${SUPABASE_SERVICE_ROLE_KEY:-}" -HEALTH_TIMEOUT="${SUPABASE_HEALTH_TIMEOUT:-$DEFAULT_HEALTH_TIMEOUT}" -REALTIME_REPLICA_REGION="${SUPABASE_REALTIME_REPLICA_REGION:-us-east-1}" - -if [ -z "$SUPABASE_SERVICE_ROLE_KEY" ]; then - echo "SUPABASE_SERVICE_ROLE_KEY is required for health checks. Update test/containers/.env" >&2 - exit 1 -fi - -npx supabase stop --workdir "$SUPABASE_DIR" >/dev/null 2>&1 || true - -for port in "${SUPABASE_PORTS[@]}"; do - free_port "$port" -done - -export REPLICA_REGION="$REALTIME_REPLICA_REGION" -export SUPABASE_REALTIME_REPLICA_REGION="$REALTIME_REPLICA_REGION" - -npx supabase start --workdir "$SUPABASE_DIR" --ignore-health-check - -START_TIME=$(date +%s) -HEALTH_URL="${SUPABASE_ENV_URL%/}/rest/v1/?select=1" - -while true; do - if curl -fsS \ - -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \ - -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" \ - "$HEALTH_URL" >/dev/null; then - echo "Supabase REST API is ready at $SUPABASE_ENV_URL" - break - fi - - NOW=$(date +%s) - ELAPSED=$((NOW - START_TIME)) - if [ "$ELAPSED" -ge "$HEALTH_TIMEOUT" ]; then - echo "Timed out waiting for Supabase after ${HEALTH_TIMEOUT}s" >&2 - exit 1 - fi - - sleep 5 - echo "Waiting for Supabase... (${ELAPSED}s)" - -done diff --git a/test/containers/upstash/local-proxy/Dockerfile b/test/containers/upstash/local-proxy/Dockerfile deleted file mode 100644 index e93d4681f..000000000 --- a/test/containers/upstash/local-proxy/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# syntax=docker/dockerfile:1.7 -ARG GO_VERSION=1.21 - -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-bookworm AS build -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY internal ./internal -COPY main.go . -ARG TARGETOS -ARG TARGETARCH -ARG TARGETVARIANT -RUN --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=cache,target=/go/pkg/mod \ - CGO_ENABLED=0 \ - GOOS=${TARGETOS:-linux} \ - GOARCH=${TARGETARCH:-amd64} \ - $(if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then echo GOARM=${TARGETVARIANT#v}; fi) \ - go build -ldflags="-s -w" -o /upstash-redis-local main.go - -FROM --platform=$TARGETPLATFORM gcr.io/distroless/base-debian12:nonroot -COPY LICENSE /licenses/UPSTASH_REDIS_LOCAL_LICENSE -COPY --from=build /upstash-redis-local /usr/local/bin/upstash-redis-local -USER nonroot -ENTRYPOINT ["upstash-redis-local"] diff --git a/test/containers/upstash/local-proxy/LICENSE b/test/containers/upstash/local-proxy/LICENSE deleted file mode 100644 index 056d6ce2d..000000000 --- a/test/containers/upstash/local-proxy/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Hemanth Krishna - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/test/containers/upstash/local-proxy/go.mod b/test/containers/upstash/local-proxy/go.mod deleted file mode 100644 index 5549e6e01..000000000 --- a/test/containers/upstash/local-proxy/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module upstash-redis-local - -go 1.21 - -require ( - github.com/gomodule/redigo v1.8.9 - github.com/valyala/fasthttp v1.51.0 - go.uber.org/zap v1.26.0 -) - -require ( - github.com/andybalholm/brotli v1.0.6 // indirect - github.com/klauspost/compress v1.17.3 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - go.uber.org/multierr v1.11.0 // indirect -) diff --git a/test/containers/upstash/local-proxy/go.sum b/test/containers/upstash/local-proxy/go.sum deleted file mode 100644 index 215845556..000000000 --- a/test/containers/upstash/local-proxy/go.sum +++ /dev/null @@ -1,29 +0,0 @@ -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= -github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= -github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA= -github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/containers/upstash/local-proxy/internal/rest.go b/test/containers/upstash/local-proxy/internal/rest.go deleted file mode 100644 index 9f4a2d02c..000000000 --- a/test/containers/upstash/local-proxy/internal/rest.go +++ /dev/null @@ -1,216 +0,0 @@ -package internal - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "log" - "strings" - "sync" - - "github.com/gomodule/redigo/redis" - "github.com/valyala/fasthttp" - "go.uber.org/zap" -) - -// Server Configurations -type Server struct { - Address string - APIToken string - RedisConn redis.Conn - credentials map[string]credentials - mutex sync.Mutex - Logger *zap.Logger -} - -type credentials struct { - Username string - Password string -} - -type errorResult struct { - Error string `json:"error"` -} - -type successResult struct { - Result interface{} `json:"result"` -} - -// Serve exposed function to start the server -func (s *Server) Serve() { - - s.Logger.Info("upstash-redis-local booting on ", zap.String("addr", s.Address)) - if err := fasthttp.ListenAndServe(s.Address, s.requestHandler); err != nil { - log.Fatalf("Error in serving: %v", err) - } -} - -// Handle requests for each query -func (s *Server) requestHandler(ctx *fasthttp.RequestCtx) { - s.Logger.Info("Incoming Request with data:", zap.String("body:", string(ctx.PostBody()))) - if !ctx.IsGet() && !ctx.IsPost() && !ctx.IsHead() && !ctx.IsPut() { - s.Logger.Warn("Invalid Method Request") - s.respond(ctx, nil, fasthttp.StatusMethodNotAllowed) - return - } - _, err := s.authenticate(ctx) - if err != nil { - s.Logger.Warn("Unauthorized Request") - s.respond(ctx, errorResult{Error: "Unauthorized"}, fasthttp.StatusUnauthorized) - return - } - - switch endpoint := string(ctx.Path()); strings.TrimPrefix(endpoint, "/") { - case "": - s.handleSingleExecute(ctx) - return - case "/pipeline": - s.handlePipelineExecute(ctx) - return - default: - segments := strings.Split(endpoint, "/")[1:] - - if len(ctx.PostBody()) > 0 { - segments = append(segments, string(ctx.PostBody())) - } - - if ctx.QueryArgs().String() != "" { - qparts := strings.Split(ctx.QueryArgs().String(), "&") - for _, qpart := range qparts { - kv := strings.SplitN(qpart, "=", 2) - if kv[0] == "_token" { - continue - } - segments = append(segments, kv...) - } - } - - args := make([]interface{}, len(segments)-1) - for i, data := range segments[1:] { - args[i] = data - } - res, code := s.executeCommand(segments[0], args...) - s.respond(ctx, res, code) - return - - } -} - -func (s *Server) handleSingleExecute(ctx *fasthttp.RequestCtx) { - - var args []interface{} - - if err := json.Unmarshal(ctx.PostBody(), &args); err != nil { - s.respond(ctx, errorResult{Error: "ERR failed to parse command"}, fasthttp.StatusBadRequest) - return - } - if len(args) == 0 { - s.respond(ctx, errorResult{Error: "ERR empty command"}, fasthttp.StatusBadRequest) - return - } - result, code := s.executeCommand(fmt.Sprint(args[0]), args[1:]...) - s.respond(ctx, result, code) -} - -func (s *Server) handlePipelineExecute(ctx *fasthttp.RequestCtx) { - var pipelineRequests [][]interface{} - - if err := json.Unmarshal(ctx.PostBody(), &pipelineRequests); err != nil { - s.respond(ctx, errorResult{Error: "ERR failed to parse pipeline request"}, fasthttp.StatusBadRequest) - return - } - if len(pipelineRequests) == 0 { - s.respond(ctx, errorResult{Error: "ERR empty pipeline request"}, fasthttp.StatusBadRequest) - return - } - - var results []interface{} - for _, request := range pipelineRequests { - if len(request) == 0 { - results = append(results, errorResult{Error: "ERR empty pipeline command"}) - continue - } - result, _ := s.executeCommand(fmt.Sprint(request[0]), request[1:]...) - results = append(results, result) - } - s.respond(ctx, results, fasthttp.StatusOK) -} - -func (s *Server) executeCommand(commandName string, args ...interface{}) (interface{}, int) { - if strings.ToLower(commandName) == "acl" && len(args) > 0 && strings.ToLower(fmt.Sprint(args[0])) == "resttoken" { - return s.aclRestToken(commandName, args...) - } - res, err := s.RedisConn.Do(commandName, args...) - if err != nil { - return errorResult{Error: err.Error()}, fasthttp.StatusBadRequest - } - return successResult{Result: res}, fasthttp.StatusOK -} - -func (s *Server) parseToken(ctx *fasthttp.RequestCtx) string { - token := string(ctx.Request.Header.Peek("Authorization")) - if token != "" { - return strings.TrimPrefix(token, "Bearer ") - } - return "" -} - -func (s *Server) aclRestToken(_ string, args ...interface{}) (interface{}, int) { - if len(args) != 3 { - return errorResult{Error: "ERR invalid syntax. Usage: ACL RESTTOKEN username password"}, fasthttp.StatusBadRequest - } - user, pwd := fmt.Sprint(args[1]), fmt.Sprint(args[2]) - credential, code := s.executeCommand("AUTH", user, pwd) - if code != fasthttp.StatusOK { - return credential, code - } - var buf [48]byte - if _, err := rand.Read(buf[:]); err != nil { - return errorResult{Error: err.Error()}, fasthttp.StatusInternalServerError - } - token := base64.URLEncoding.EncodeToString(buf[:]) - - s.mutex.Lock() - if s.credentials == nil { - s.credentials = make(map[string]credentials) - } - s.credentials[token] = credentials{user, pwd} - s.mutex.Unlock() - - return successResult{Result: token}, fasthttp.StatusOK -} -func (s *Server) authenticate(ctx *fasthttp.RequestCtx) (*credentials, error) { - token := s.parseToken(ctx) - if token == "" { - return nil, errors.New("invalid token") - } - if token == s.APIToken { - return &credentials{}, nil - } - s.mutex.Lock() - credential, found := s.credentials[token] - s.mutex.Unlock() - if !found { - return nil, errors.New("invalid token") - } - return &credential, nil -} - -func (s *Server) respond(ctx *fasthttp.RequestCtx, data interface{}, status int) { - ctx.SetContentType("application/json; charset=utf-8") - ctx.SetStatusCode(status) - if data != nil { - b, err := json.Marshal(data) - if err != nil { - s.Logger.Error("something went wrong due to: ", zap.Error(err)) - s.respond(ctx, errorResult{Error: fmt.Sprintf("something went wrong: %v", err)}, fasthttp.StatusInternalServerError) - } - _, err = ctx.Write(b) - if err != nil { - s.Logger.Error("something went wrong due to: ", zap.Error(err)) - } - s.Logger.Info("Response Sent with status code", zap.Int("code", status)) - } -} diff --git a/test/containers/upstash/local-proxy/main.go b/test/containers/upstash/local-proxy/main.go deleted file mode 100644 index e3b44f4dc..000000000 --- a/test/containers/upstash/local-proxy/main.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "errors" - "flag" - "fmt" - "log" - "time" - "upstash-redis-local/internal" - - "github.com/gomodule/redigo/redis" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -var Version = "development" - -type Cmd struct { - RedisAddr string - Addr string - ApiToken string -} - -func (c *Cmd) Validate() error { - if c.ApiToken == "" { - return errors.New("API Token empty") - } - if c.RedisAddr == "" { - return errors.New("redis Addr empty") - } - if c.Addr == "" { - return errors.New("webserver addr empty") - } - return nil -} - -func main() { - setupFlags(flag.CommandLine) - redisAddr := flag.String("redis", ":6379", "Local Redis String") - addr := flag.String("addr", ":8000", "Local webserver string") - apiToken := flag.String("token", "upstash", "API token set by user") - help := flag.Bool("help", false, "") - flag.Parse() - cmd := Cmd{ - RedisAddr: *redisAddr, - ApiToken: *apiToken, - Addr: *addr, - } - if *help { - printHelp() - return - } - if cmd.Validate() != nil { - - } - config := zap.NewProductionConfig() - config.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.RFC3339) - - logger, err := config.Build() - if err != nil { - log.Fatalf("err: %v", err) - } - defer logger.Sync() - server := internal.Server{Address: cmd.Addr, APIToken: cmd.ApiToken, RedisConn: connectToRedis(cmd.RedisAddr), Logger: logger} - defer server.Serve() -} - -func setupFlags(f *flag.FlagSet) { - f.Usage = func() { - printHelp() - } -} - -func printHelp() { - fmt.Printf(` -upstash-redis-local %s -A local server that mimics upstash-redis for local testing purposes! - - * Connect to any local redis of your choice for testing - * Comlpetely mimics the upstash REST API https://docs.upstash.com/redis/features/restapi - -Developed by Hemanth Krishna (https://github.com/DarthBenro008) - -USAGE: - upstash-redis-local - upstash-redis-local --token upstash --addr :8000 --redis :6379 - -ARGUMENTS: - --token TOKEN The API token to accept as authorised (default: upstash) - --addr ADDR Address for the server to listen on (default: :8000) - --redis ADDR Address to your redids server (default: :6379) - --help Prints this message -`, Version) -} - -func connectToRedis(addr string) redis.Conn { - conn, err := redis.Dial("tcp", addr) - if err != nil { - log.Fatalf("err: %v", err) - } - return conn -} diff --git a/test/containers/upstash/seed.sh b/test/containers/upstash/seed.sh deleted file mode 100644 index e8cfb6338..000000000 --- a/test/containers/upstash/seed.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/sh -set -euo pipefail - -REDIS_HOST="${REDIS_HOST:-upstash-redis}" -REDIS_PORT="${REDIS_PORT:-6379}" -SEED_DIR="/seeds" - -if [ ! -d "$SEED_DIR" ]; then - echo "[upstash-seed] Seed directory not mounted; skipping" - exit 0 -fi - -if ! command -v jq >/dev/null 2>&1; then - echo "[upstash-seed] Installing jq for JSON parsing" - apk add --no-cache jq >/dev/null 2>&1 -fi - -SEED_APPLIED=false -for file in "$SEED_DIR"/*.json; do - if [ ! -f "$file" ]; then - continue - fi - - SEED_APPLIED=true - TYPE=$(jq -r '.type' "$file") - KEY=$(jq -r '.key' "$file") - - echo "[upstash-seed] Applying $TYPE seed from $(basename "$file")" - - case "$TYPE" in - queue) - jq -c '.items[]' "$file" | while read -r item; do - redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" LPUSH "$KEY" "$item" >/dev/null - done - ;; - hash) - jq -c '.items[]' "$file" | while read -r item; do - FIELD=$(echo "$item" | jq -r '.field') - VALUE=$(echo "$item" | jq -r '.value') - redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" HSET "$KEY" "$FIELD" "$VALUE" >/dev/null - done - ;; - string) - VALUE=$(jq -r '.value' "$file") - redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" SET "$KEY" "$VALUE" >/dev/null - ;; - *) - echo "[upstash-seed] Unknown seed type '$TYPE' in $file" >&2 - ;; - esac - -done - -if [ "$SEED_APPLIED" = false ]; then - echo "[upstash-seed] No seed files found" -fi diff --git a/test/containers/upstash/seeds/newsletter-queue.json b/test/containers/upstash/seeds/newsletter-queue.json deleted file mode 100644 index f112e6ed8..000000000 --- a/test/containers/upstash/seeds/newsletter-queue.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "type": "queue", - "key": "queues:newsletter", - "items": [ - { - "email": "demo@example.com", - "source": "seed" - } - ] -} diff --git a/test/e2e/config/global-setup.ts b/test/e2e/config/global-setup.ts index f99d1e2a2..8cdcd262c 100644 --- a/test/e2e/config/global-setup.ts +++ b/test/e2e/config/global-setup.ts @@ -1,15 +1,19 @@ import type { FullConfig } from '@playwright/test' +import { prepareDatabase, resolveTestDatabaseFile } from './runtime/database' +import { startWiremock } from './runtime/wiremock' async function globalSetup(_config: FullConfig) { - // Mark as Playwright environment - process.env['PLAYWRIGHT'] = 'true' + const testDatabaseFile = resolveTestDatabaseFile() - // Mock environment variables for consistency with vitest.config.ts - // These are used by import.meta.env in the browser context + process.env['PLAYWRIGHT'] = 'true' process.env['DEV'] = 'true' process.env['MODE'] = 'test' process.env['NODE_ENV'] = 'development' process.env['PROD'] = 'false' + process.env['ASTRO_DATABASE_FILE'] = testDatabaseFile + + await prepareDatabase() + await startWiremock() } export default globalSetup diff --git a/test/e2e/config/global-teardown.ts b/test/e2e/config/global-teardown.ts new file mode 100644 index 000000000..d517c42f2 --- /dev/null +++ b/test/e2e/config/global-teardown.ts @@ -0,0 +1,8 @@ +import type { FullConfig } from '@playwright/test' +import { stopWiremock } from './runtime/wiremock' + +async function globalTeardown(_config: FullConfig) { + await stopWiremock() +} + +export default globalTeardown diff --git a/test/e2e/config/runtime/database.ts b/test/e2e/config/runtime/database.ts new file mode 100644 index 000000000..3abbc8e5f --- /dev/null +++ b/test/e2e/config/runtime/database.ts @@ -0,0 +1,36 @@ +import { spawn } from 'node:child_process' +import { mkdir } from 'node:fs/promises' +import { testDatabaseFile, testCacheDir } from './paths' + +const runCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv) => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + env: env ?? process.env, + shell: process.platform === 'win32', + }) + + child.on('error', (error) => { + reject(error) + }) + + child.on('exit', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`)) + } + }) + }) +} + +export const resolveTestDatabaseFile = () => testDatabaseFile + +export const prepareDatabase = async () => { + await mkdir(testCacheDir, { recursive: true }) + const env = { + ...process.env, + ASTRO_DATABASE_FILE: testDatabaseFile, + } + await runCommand('npx', ['astro', 'db', 'reset', '--force', '--seed'], env) +} diff --git a/test/e2e/config/runtime/paths.ts b/test/e2e/config/runtime/paths.ts new file mode 100644 index 000000000..d4f7b6e54 --- /dev/null +++ b/test/e2e/config/runtime/paths.ts @@ -0,0 +1,8 @@ +import path from 'node:path' + +const repoRoot = path.resolve(process.cwd()) + +export const testCacheDir = path.join(repoRoot, '.cache', 'astro-db') +export const testDatabaseFile = path.join(testCacheDir, 'e2e.db') +export const containersEnvFile = path.join(repoRoot, 'test', 'containers', '.env') +export const wiremockComposeFile = path.join(repoRoot, 'test', 'containers', 'docker-compose.e2e.yml') diff --git a/test/e2e/config/runtime/wiremock.ts b/test/e2e/config/runtime/wiremock.ts new file mode 100644 index 000000000..acc1b9779 --- /dev/null +++ b/test/e2e/config/runtime/wiremock.ts @@ -0,0 +1,59 @@ +import { spawn } from 'node:child_process' +import { setTimeout as delay } from 'node:timers/promises' +import { containersEnvFile, wiremockComposeFile } from './paths' +import { buildWiremockBaseUrl } from '../../helpers/wiremockConfig' + +const withCommand = (args: string[]) => { + return ['compose', '--env-file', containersEnvFile, '-f', wiremockComposeFile, ...args] +} + +const runDocker = (args: string[]) => { + return new Promise((resolve, reject) => { + const child = spawn('docker', withCommand(args), { + stdio: 'inherit', + shell: process.platform === 'win32', + }) + + child.on('error', (error) => reject(error)) + child.on('exit', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`docker ${args.join(' ')} exited with code ${code}`)) + } + }) + }) +} + +const waitForHealthy = async (service: 'convertkit' | 'resend', timeoutMs = 15000) => { + const deadline = Date.now() + timeoutMs + const url = `${buildWiremockBaseUrl(service)}/__admin/mappings` + + while (Date.now() < deadline) { + try { + const response = await fetch(url) + if (response.ok) { + return + } + } catch { + // Keep polling + } + await delay(500) + } + + throw new Error(`${service} mock did not become healthy within ${timeoutMs}ms`) +} + +export const startWiremock = async () => { + await runDocker(['up', '-d', 'convertkit-mock', 'resend-mock']) + await waitForHealthy('convertkit') + await waitForHealthy('resend') +} + +export const stopWiremock = async () => { + try { + await runDocker(['down']) + } catch (error) { + console.warn('Failed to stop Wiremock containers', error) + } +} diff --git a/test/e2e/db/helpers.ts b/test/e2e/db/helpers.ts new file mode 100644 index 000000000..0cd22aa9a --- /dev/null +++ b/test/e2e/db/helpers.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { libsql } from './libsqlClient' + +const toIso = (value: Date | number | string): string => { + const date = value instanceof Date ? value : new Date(value) + return date.toISOString() +} + +const parseJsonArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value as string[] + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + return [] +} + +export interface ConsentRecordRow { + id: string + dataSubjectId: string + purposes: string[] + source: string + createdAt: string +} + +export const waitForConsentRecord = async ( + dataSubjectId: string, + expectedPurposes: string[], + timeoutMs = 7000, +): Promise => { + const deadline = Date.now() + timeoutMs + let lastError: string | null = null + + while (Date.now() <= deadline) { + try { + const result = await libsql.execute({ + sql: `SELECT id, dataSubjectId, purposes, source, createdAt FROM consentEvents WHERE dataSubjectId = ? ORDER BY createdAt DESC LIMIT 1`, + args: [dataSubjectId], + }) + const row = result.rows[0] + if (row) { + const purposes = parseJsonArray(row['purposes']) + const hasAll = expectedPurposes.every((purpose) => purposes.includes(purpose)) + if (hasAll) { + return { + id: String(row['id']), + dataSubjectId: String(row['dataSubjectId']), + purposes, + source: String(row['source'] ?? 'unknown'), + createdAt: String(row['createdAt']), + } + } + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + + await delay(250) + } + + throw new Error(lastError ?? `Timed out waiting for consent record ${dataSubjectId}`) +} + +export const markNewsletterTokenExpired = async (token: string, expiredAt?: Date) => { + const timestamp = toIso(expiredAt ?? new Date(Date.now() - 60 * 60 * 1000)) + await libsql.execute({ + sql: `UPDATE newsletterConfirmations SET expiresAt = ? WHERE token = ?`, + args: [timestamp, token], + }) +} + +export interface NewsletterConfirmationSeed { + email?: string + dataSubjectId?: string + token?: string + expiresAt: Date + confirmedAt?: Date | null + createdAt?: Date +} + +export const insertNewsletterConfirmation = async ( + seed: NewsletterConfirmationSeed, +): Promise<{ id: string; token: string; email: string }> => { + const id = randomUUID() + const token = seed.token ?? `cron-newsletter-${randomUUID()}` + const email = seed.email ?? `cron-newsletter-${Date.now()}@example.com` + const dataSubjectId = seed.dataSubjectId ?? randomUUID() + const createdAt = seed.createdAt ?? new Date() + const consentTimestamp = createdAt + + await libsql.execute({ + sql: `INSERT INTO newsletterConfirmations (id, token, email, dataSubjectId, firstName, source, userAgent, ipAddress, consentTimestamp, expiresAt, confirmedAt, createdAt) + VALUES (?, ?, ?, ?, NULL, 'cron_test', 'playwright', NULL, ?, ?, ?, ?)`, + args: [ + id, + token, + email, + dataSubjectId, + toIso(consentTimestamp), + toIso(seed.expiresAt), + seed.confirmedAt ? toIso(seed.confirmedAt) : null, + toIso(createdAt), + ], + }) + + return { id, token, email } +} + +export interface DsarRequestSeed { + requestType?: string + email?: string + token?: string + expiresAt?: Date + fulfilledAt?: Date | null + createdAt?: Date +} + +export const insertDsarRequest = async ( + seed: DsarRequestSeed, +): Promise<{ id: string; token: string }> => { + const id = randomUUID() + const token = seed.token ?? `cron-dsar-${randomUUID()}` + const email = seed.email ?? `cron-dsar-${Date.now()}@example.com` + const expiresAt = seed.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000) + const createdAt = seed.createdAt ?? new Date() + const requestType = seed.requestType ?? 'DELETE' + + await libsql.execute({ + sql: `INSERT INTO dsarRequests (id, token, email, requestType, expiresAt, fulfilledAt, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [ + id, + token, + email, + requestType, + toIso(expiresAt), + seed.fulfilledAt ? toIso(seed.fulfilledAt) : null, + toIso(createdAt), + ], + }) + + return { id, token } +} + +export const findNewsletterConfirmationById = async (id: string) => { + const result = await libsql.execute({ + sql: `SELECT id FROM newsletterConfirmations WHERE id = ? LIMIT 1`, + args: [id], + }) + return result.rows[0] ?? null +} + +export const findDsarRequestById = async (id: string) => { + const result = await libsql.execute({ + sql: `SELECT id FROM dsarRequests WHERE id = ? LIMIT 1`, + args: [id], + }) + return result.rows[0] ?? null +} + +export const deleteNewsletterConfirmationById = async (id: string) => { + await libsql.execute({ + sql: `DELETE FROM newsletterConfirmations WHERE id = ?`, + args: [id], + }) +} + +export const deleteDsarRequestById = async (id: string) => { + await libsql.execute({ + sql: `DELETE FROM dsarRequests WHERE id = ?`, + args: [id], + }) +} + +export const deleteConsentRecordsBySubjectId = async (dataSubjectId: string) => { + await libsql.execute({ + sql: `DELETE FROM consentEvents WHERE dataSubjectId = ?`, + args: [dataSubjectId], + }) +} diff --git a/test/e2e/db/index.ts b/test/e2e/db/index.ts new file mode 100644 index 000000000..b2a4c7512 --- /dev/null +++ b/test/e2e/db/index.ts @@ -0,0 +1 @@ +export * from './helpers' diff --git a/test/e2e/db/libsqlClient.ts b/test/e2e/db/libsqlClient.ts new file mode 100644 index 000000000..fba1368bf --- /dev/null +++ b/test/e2e/db/libsqlClient.ts @@ -0,0 +1,15 @@ +import { createClient } from '@libsql/client' +import path from 'node:path' +import { env } from 'node:process' + +const resolveDatabaseUrl = () => { + const file = env['ASTRO_DATABASE_FILE'] ?? path.join(process.cwd(), 'db', 'dev.db') + if (file.startsWith('file:')) { + return file + } + return `file:${path.resolve(file)}` +} + +export const libsql = createClient({ + url: resolveDatabaseUrl(), +}) diff --git a/test/e2e/helpers/cronHealth.ts b/test/e2e/helpers/cronHealth.ts index babf87b3a..0146e5ad4 100644 --- a/test/e2e/helpers/cronHealth.ts +++ b/test/e2e/helpers/cronHealth.ts @@ -1,41 +1,23 @@ import { setTimeout as delay } from 'node:timers/promises' +import { libsql } from '@test/e2e/db/libsqlClient' interface RetryOptions { retries?: number delayMs?: number - timeoutMs?: number } const DEFAULT_OPTIONS: Required = { retries: 5, - delayMs: 1000, - timeoutMs: 4000, + delayMs: 250, } -const fetchWithTimeout = async (url: string, init: RequestInit, timeoutMs: number) => { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(url, { ...init, signal: controller.signal }) - return response - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`) - } - throw error - } finally { - clearTimeout(timer) - } -} - -const withRetries = async (action: () => Promise, label: string, options?: RetryOptions) => { - const { retries, delayMs, timeoutMs } = { ...DEFAULT_OPTIONS, ...options } +export async function ensureCronDependenciesHealthy(options?: RetryOptions): Promise { + const { retries, delayMs } = { ...DEFAULT_OPTIONS, ...options } let lastError: unknown for (let attempt = 1; attempt <= retries; attempt += 1) { try { - await action() + await libsql.execute('SELECT 1') return } catch (error) { lastError = error @@ -46,45 +28,6 @@ const withRetries = async (action: () => Promise, label: string, options?: } } - const message = lastError instanceof Error ? lastError.message : String(lastError) - throw new Error(`${label} health check failed after ${retries} attempts (${timeoutMs}ms timeout): ${message}`) -} - -const buildSupabaseHealthUrl = (baseUrl: string) => new URL('/rest/v1/?select=1', baseUrl).toString() - -const ensureSupabaseReady = async (supabaseUrl: string, serviceRoleKey: string, options?: RetryOptions) => { - const healthUrl = buildSupabaseHealthUrl(supabaseUrl) - await withRetries( - async () => { - const response = await fetchWithTimeout( - healthUrl, - { - headers: { - apikey: serviceRoleKey, - Authorization: `Bearer ${serviceRoleKey}`, - }, - }, - options?.timeoutMs ?? DEFAULT_OPTIONS.timeoutMs - ) - - if (!response.ok) { - throw new Error(`Supabase responded with status ${response.status}`) - } - }, - 'Supabase REST API', - options - ) -} - -export interface CronDependencyConfig extends RetryOptions { - supabaseUrl: string - supabaseServiceKey: string -} - -export async function ensureCronDependenciesHealthy({ - supabaseUrl, - supabaseServiceKey, - ...options -}: CronDependencyConfig): Promise { - await ensureSupabaseReady(supabaseUrl, supabaseServiceKey, options) + const message = lastError instanceof Error ? lastError.message : String(lastError ?? 'Unknown error') + throw new Error(`Astro DB health check failed: ${message}`) } diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 662557e13..45357aea3 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -29,4 +29,4 @@ export { selectTheme, getThemePickerToggle, } from './cookieHelper' -export { wiremock, mocksEnabled } from './mockServices' +export { wiremock } from './mockServices' diff --git a/test/e2e/helpers/mockServices.ts b/test/e2e/helpers/mockServices.ts index f840dfc64..65419cd03 100644 --- a/test/e2e/helpers/mockServices.ts +++ b/test/e2e/helpers/mockServices.ts @@ -4,36 +4,7 @@ * outbound calls were made by the server under test. */ -const defaultHost = process.env['E2E_MOCKS_HOST'] ?? '127.0.0.1' - -const serviceConfig = { - convertkit: { - envUrl: 'CONVERTKIT_MOCK_URL', - envPort: 'CONVERTKIT_HTTP_PORT', - defaultPort: '9010', - }, - resend: { - envUrl: 'RESEND_MOCK_URL', - envPort: 'RESEND_HTTP_PORT', - defaultPort: '9011', - }, -} as const satisfies Record - -const stripTrailingSlash = (value: string) => value.replace(/\/$/, '') - -const buildBaseUrl = (service: keyof typeof serviceConfig) => { - const config = serviceConfig[service] - const explicit = process.env[config.envUrl] - if (explicit) { - return stripTrailingSlash(explicit) - } - const port = process.env[config.envPort] ?? config.defaultPort - return `http://${defaultHost}:${port}` -} +import { buildWiremockBaseUrl, type WiremockService } from './wiremockConfig' export interface WiremockLoggedRequest { id: string @@ -83,8 +54,8 @@ const ensureOk = async (response: Response, serviceName: string, action: string) export class WiremockClient { private readonly baseUrl: string - constructor(private readonly serviceName: keyof typeof serviceConfig) { - this.baseUrl = buildBaseUrl(serviceName) + constructor(private readonly serviceName: WiremockService) { + this.baseUrl = buildWiremockBaseUrl(serviceName) } private get requestsUrl() { @@ -160,8 +131,6 @@ export class WiremockClient { } } -export const mocksEnabled = process.env['E2E_MOCKS'] === '1' - export const wiremock = { convertkit: new WiremockClient('convertkit'), resend: new WiremockClient('resend'), diff --git a/test/e2e/helpers/wiremockConfig.ts b/test/e2e/helpers/wiremockConfig.ts new file mode 100644 index 000000000..714dfb1b0 --- /dev/null +++ b/test/e2e/helpers/wiremockConfig.ts @@ -0,0 +1,23 @@ +import { env } from 'node:process' + +type WiremockService = 'convertkit' | 'resend' + +const getHost = () => env['WIREMOCK_HOST'] ?? '127.0.0.1' + +const getPort = (service: WiremockService) => { + const defaults: Record = { + convertkit: 9010, + resend: 9011, + } + + const envKey = service === 'convertkit' ? 'CONVERTKIT_HTTP_PORT' : 'RESEND_HTTP_PORT' + return Number(env[envKey] ?? defaults[service]) +} + +export const buildWiremockBaseUrl = (service: WiremockService) => { + return `http://${getHost()}:${getPort(service)}` +} + +export const getWiremockHost = getHost +export const getWiremockPort = getPort +export type { WiremockService } diff --git a/test/e2e/specs/03-forms/contact-form.spec.ts b/test/e2e/specs/03-forms/contact-form.spec.ts index 8f39eb241..c22c1df55 100644 --- a/test/e2e/specs/03-forms/contact-form.spec.ts +++ b/test/e2e/specs/03-forms/contact-form.spec.ts @@ -10,9 +10,7 @@ import { expect, test, wiremock, - mocksEnabled, spyOnFetchEndpoint, - injectHeadersIntoFetch, mockFetchEndpointResponse, } from '@test/e2e/helpers' import { TestError } from '@test/errors' @@ -121,50 +119,39 @@ test.describe('Contact Form', () => { }) test('@mocks contact form submits successfully when API is available', async ({ page: playwrightPage }) => { - test.skip(!mocksEnabled, 'E2E_MOCKS=1 is required to verify Resend mock delivery') - const page = await setupContactPage(playwrightPage) - const headerOverride = await injectHeadersIntoFetch(playwrightPage, { - endpoint: '/api/contact', - headers: { 'x-e2e-mocks': '1' }, - }) + const { email } = await fillContactFormWithValidData(page) + const responsePromise = page.waitForResponse(isContactApiResponse) - try { - const { email } = await fillContactFormWithValidData(page) - const responsePromise = page.waitForResponse(isContactApiResponse) + await page.click('#submitBtn') - await page.click('#submitBtn') + const response = await responsePromise + expect(response.status()).toBe(200) - const response = await responsePromise - expect(response.status()).toBe(200) + await expect(page.locator('#formMessages .message-success')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('#formMessages .message-error')).toBeHidden() - await expect(page.locator('#formMessages .message-success')).toBeVisible({ timeout: 5000 }) - await expect(page.locator('#formMessages .message-error')).toBeHidden() - - const loggedRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [email, 'contact@webstackbuilders.com'], - }) + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [email, 'contact@webstackbuilders.com'], + }) - if (!loggedRequest) { - throw new TestError('Resend mock did not capture the transactional email payload') - } + if (!loggedRequest) { + throw new TestError('Resend mock did not capture the transactional email payload') + } - const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { - to?: string | string[] - subject?: string - } + const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { + to?: string | string[] + subject?: string + } - if (Array.isArray(payload.to)) { - expect(payload.to).toContain('info@webstackbuilders.com') - } else { - expect(payload.to).toBe('info@webstackbuilders.com') - } - expect(payload.subject).toContain('Contact Form') - } finally { - await headerOverride.restore() + if (Array.isArray(payload.to)) { + expect(payload.to).toContain('info@webstackbuilders.com') + } else { + expect(payload.to).toBe('info@webstackbuilders.com') } + expect(payload.subject).toContain('Contact Form') }) test('@ready contact form surfaces API error responses to users', async ({ page: playwrightPage }) => { @@ -187,14 +174,12 @@ test.describe('Contact Form', () => { await expect(page.locator('#formMessages .message-error')).toBeVisible({ timeout: 5000 }) await expect(page.locator('#errorMessage')).toContainText('Unable to reach contact API') - if (mocksEnabled) { - const loggedRequests = await wiremock.resend.findRequests({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: email, - }) - expect(loggedRequests.length).toBe(0) - } + const loggedRequests = await wiremock.resend.findRequests({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: email, + }) + expect(loggedRequests.length).toBe(0) } finally { await apiErrorOverride.restore() } diff --git a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts index 2cb046fc4..043986237 100644 --- a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts @@ -3,17 +3,14 @@ * Tests for complete newsletter subscription flow including email confirmation */ import type { Page } from '@playwright/test' -import { env } from 'node:process' -import { createClient, type SupabaseClient } from '@supabase/supabase-js' import { BasePage, test, expect, wiremock, - mocksEnabled, - injectHeadersIntoFetch, } from '@test/e2e/helpers' import { TestError } from '@test/errors' +import { markNewsletterTokenExpired } from '@test/e2e/db' const HOME_PATH = '/' const NEWSLETTER_ENDPOINT = '/api/newsletter' @@ -49,15 +46,6 @@ const extractConfirmationLink = (payload: ResendEmailPayload): string => { return linkMatch[0] } -const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '') -const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY'] - -const supabaseAdminClient: SupabaseClient | null = SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY - ? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { - auth: { autoRefreshToken: false, persistSession: false }, - }) - : null - const createUniqueEmail = () => `newsletter-double-optin-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com` @@ -86,61 +74,52 @@ const submitNewsletterSubscription = async ( playwrightPage: Page, email = createUniqueEmail(), ): Promise => { - const headerOverride = await injectHeadersIntoFetch(playwrightPage, { - endpoint: NEWSLETTER_ENDPOINT, - headers: { 'x-e2e-mocks': '1' }, - }) + await fillNewsletterForm(page, email) - try { - await fillNewsletterForm(page, email) - - const responsePromise = playwrightPage.waitForResponse((response) => { - return response.url().includes(NEWSLETTER_ENDPOINT) && response.request().method() === 'POST' - }) + const responsePromise = playwrightPage.waitForResponse((response) => { + return response.url().includes(NEWSLETTER_ENDPOINT) && response.request().method() === 'POST' + }) - await page.click('#newsletter-submit') + await page.click('#newsletter-submit') - const response = await responsePromise - expect(response.status()).toBe(200) - await expect(page.locator('#newsletter-message')).toContainText('confirm your subscription', { timeout: 5000 }) + const response = await responsePromise + expect(response.status()).toBe(200) + await expect(page.locator('#newsletter-message')).toContainText('confirm your subscription', { timeout: 5000 }) - const loggedRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [email, 'newsletter-confirmation', 'double-optin'], - }, { timeoutMs: 7000 }) + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [email, 'newsletter-confirmation', 'double-optin'], + }, { timeoutMs: 7000 }) - if (!loggedRequest?.request.body) { - throw new TestError('Resend mock captured request without body payload') - } + if (!loggedRequest?.request.body) { + throw new TestError('Resend mock captured request without body payload') + } - const payload = JSON.parse(loggedRequest.request.body) as ResendEmailPayload + const payload = JSON.parse(loggedRequest.request.body) as ResendEmailPayload - if (Array.isArray(payload.to)) { - expect(payload.to).toContain(email) - } else { - expect(payload.to).toBe(email) - } + if (Array.isArray(payload.to)) { + expect(payload.to).toContain(email) + } else { + expect(payload.to).toBe(email) + } - const confirmationLink = extractConfirmationLink(payload) - let confirmationUrl: URL - try { - confirmationUrl = new URL(confirmationLink) - } catch (error) { - throw new TestError(`Invalid confirmation link received: ${confirmationLink}`, { cause: error }) - } + const confirmationLink = extractConfirmationLink(payload) + let confirmationUrl: URL + try { + confirmationUrl = new URL(confirmationLink) + } catch (error) { + throw new TestError(`Invalid confirmation link received: ${confirmationLink}`, { cause: error }) + } - return { - email, - payload, - confirmationUrl, - confirmationLink, - localConfirmationPath: resolveLocalPath(confirmationUrl), - token: parseTokenFromUrl(confirmationUrl), - siteOrigin: confirmationUrl.origin, - } - } finally { - await headerOverride.restore() + return { + email, + payload, + confirmationUrl, + confirmationLink, + localConfirmationPath: resolveLocalPath(confirmationUrl), + token: parseTokenFromUrl(confirmationUrl), + siteOrigin: confirmationUrl.origin, } } @@ -148,36 +127,14 @@ const confirmTokenViaApi = async ( playwrightPage: Page, token: string, siteOrigin: string, - options?: { mockResend?: boolean }, ) => { - const headers: Record = {} - if (options?.mockResend !== false) { - headers['x-e2e-mocks'] = '1' - } return await playwrightPage.request.get( `${siteOrigin}/api/newsletter/confirm?token=${encodeURIComponent(token)}`, - { headers }, ) } -const requireSupabaseClient = (): SupabaseClient => { - if (!supabaseAdminClient) { - throw new TestError('Supabase admin client is not available') - } - return supabaseAdminClient -} - const markConfirmationTokenExpired = async (token: string): Promise => { - const supabase = requireSupabaseClient() - const expiredAt = new Date(Date.now() - 60 * 60 * 1000).toISOString() - const { error } = await supabase - .from('newsletter_confirmations') - .update({ ['expires_at']: expiredAt }) - .eq('token', token) - - if (error) { - throw new TestError('Failed to mark confirmation token as expired in Supabase', { cause: error }) - } + await markNewsletterTokenExpired(token) } const extractUnsubscribeLink = (html?: string): string => { @@ -198,10 +155,6 @@ const extractUnsubscribeLink = (html?: string): string => { test.use({ serviceWorkers: 'block' }) test.describe('Newsletter Double Opt-In Flow', () => { - if (!mocksEnabled) { - test.skip(true, 'E2E_MOCKS=1 is required to verify newsletter double opt-in flow') - } - test.beforeAll(async () => { await wiremock.resend.resetRequests() }) @@ -277,8 +230,6 @@ test.describe('Newsletter Double Opt-In Flow', () => { }) test('@mocks confirmation link expires after time', async ({ page: playwrightPage }) => { - test.skip(!supabaseAdminClient, 'Supabase containers must be running for confirmation expiry coverage') - const page = await BasePage.init(playwrightPage) await page.goto(HOME_PATH) diff --git a/test/e2e/specs/04-components/consentPreferences.spec.ts b/test/e2e/specs/04-components/consentPreferences.spec.ts index 98266ec66..d120ddf7e 100644 --- a/test/e2e/specs/04-components/consentPreferences.spec.ts +++ b/test/e2e/specs/04-components/consentPreferences.spec.ts @@ -4,10 +4,9 @@ */ import type { Page } from '@playwright/test' -import { env } from 'node:process' -import { createClient, type SupabaseClient } from '@supabase/supabase-js' -import { BasePage, expect, mocksEnabled, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers' +import { BasePage, expect, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers' import type { ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' +import { deleteConsentRecordsBySubjectId, waitForConsentRecord } from '@test/e2e/db' const ALLOW_ALL_BUTTON = '#consent-allow-all' const SAVE_BUTTON = '#consent-save-preferences' @@ -15,16 +14,6 @@ const COMPONENT_SELECTOR = 'consent-preferences' const FULL_STACK_TAG = '@containers' const CONSENT_PAGE_PATH = '/consent' -const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '') -const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY'] -const SUPABASE_FALLBACK_ENABLED = env['E2E_SUPABASE_FALLBACK'] === '1' || env['E2E_MOCKS'] === '1' - -const supabaseAdminClient: SupabaseClient | null = SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY - ? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { - auth: { autoRefreshToken: false, persistSession: false }, - }) - : null - const toggleLabel = (checkboxId: string): string => `[data-consent-toggle="${checkboxId}"]` const interceptConsentApi = async (page: Page): Promise => { @@ -34,55 +23,6 @@ const interceptConsentApi = async (page: Page): Promise => }) } -const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -type ConsentRecordRow = { - id: string - data_subject_id: string - purposes: string[] - source: string | null - timestamp: string -} - -/** - * Polls Supabase until the consent record for the provided subject includes the expected purposes. - */ -const waitForSupabaseConsentRecord = async ( - dataSubjectId: string, - expectedPurposes: string[], - timeoutMs = 7_000, -): Promise => { - if (!supabaseAdminClient) { - throw new Error('Supabase admin client unavailable') - } - - const deadline = Date.now() + timeoutMs - let lastError: string | undefined - - while (Date.now() <= deadline) { - const { data, error } = await supabaseAdminClient - .from('consent_records') - .select('id, data_subject_id, purposes, source, timestamp') - .eq('data_subject_id', dataSubjectId) - .order('timestamp', { ascending: false }) - .limit(1) - - if (error) { - lastError = error.message - } else if (data && data.length > 0) { - const record = data[0]! - const hasAllPurposes = expectedPurposes.every((purpose) => record.purposes?.includes(purpose)) - if (hasAllPurposes) { - return record - } - } - - await wait(250) - } - - throw new Error(lastError ?? 'Timed out waiting for consent record to persist in Supabase') -} - async function removeViteErrorOverlay(page: BasePage): Promise { await page.evaluate(() => { const styleId = 'disable-vite-overlay-style' @@ -141,9 +81,6 @@ test.describe('Consent Preferences Component', () => { }) test('@containers full stack consent submission hits backend mocks', async ({ page: playwrightPage }) => { - test.skip(!mocksEnabled, 'E2E_MOCKS=1 is required to run Supabase-backed consent tests') - test.skip(!supabaseAdminClient, 'Supabase containers must be running for full stack consent coverage') - const page = await BasePage.init(playwrightPage) await waitForConsentPreferences(page) @@ -187,31 +124,15 @@ test.describe('Consent Preferences Component', () => { let cleanupId: string | null = null try { - if (!SUPABASE_FALLBACK_ENABLED) { - const record = await waitForSupabaseConsentRecord(dataSubjectId, expectedPurposes) - cleanupId = record.data_subject_id - - const sortedRecordPurposes = [...record.purposes].sort() - const sortedExpectedPurposes = [...expectedPurposes].sort() - expect(sortedRecordPurposes).toEqual(sortedExpectedPurposes) - expect(record.source).toBe('cookies_modal') - } else { - // Supabase fallback mode shares sanitized purposes, which currently exclude the functional flag - const fallbackExpectedPurposes = expectedPurposes.filter((purpose) => purpose !== 'functional') - const sortedResponsePurposes = [...(responseBody.record?.purposes ?? [])].sort() - const sortedFallbackPurposes = [...fallbackExpectedPurposes].sort() - expect(sortedResponsePurposes).toEqual(sortedFallbackPurposes) - } + const record = await waitForConsentRecord(dataSubjectId, expectedPurposes) + cleanupId = record.dataSubjectId await expect(analyticsCheckbox).toBeChecked() await expect(functionalCheckbox).toBeChecked() await expect(marketingCheckbox).toBeChecked() } finally { if (cleanupId) { - await supabaseAdminClient - ?.from('consent_records') - .delete() - .eq('data_subject_id', cleanupId) + await deleteConsentRecordsBySubjectId(cleanupId) } } }) diff --git a/test/e2e/specs/08-api/contact-api.spec.ts b/test/e2e/specs/08-api/contact-api.spec.ts index 803138f37..0f4a84c6c 100644 --- a/test/e2e/specs/08-api/contact-api.spec.ts +++ b/test/e2e/specs/08-api/contact-api.spec.ts @@ -1,21 +1,14 @@ -import { expect, test, wiremock, mocksEnabled } from '@test/e2e/helpers' +import { expect, test, wiremock } from '@test/e2e/helpers' const CONTACT_ENDPOINT = '/api/contact' const RESEND_EMAIL_PATH = '/emails' test.describe('Contact API integrations', () => { - if (!mocksEnabled) { - test.skip(true, 'E2E_MOCKS=1 is required to run contact API integration tests') - } - test.describe.configure({ mode: 'serial' }) test('@mocks delivers transactional email payload to Resend', async ({ request }) => { const uniqueEmail = `contact-${Date.now()}@example.com` const response = await request.post(CONTACT_ENDPOINT, { - headers: { - 'x-e2e-mocks': '1', - }, data: { name: 'Integration Bot', email: uniqueEmail, @@ -56,9 +49,6 @@ test.describe('Contact API integrations', () => { test('@mocks rejects invalid submissions before reaching Resend', async ({ request }) => { const invalidEmail = `invalid-contact-${Date.now()}@example.com` const response = await request.post(CONTACT_ENDPOINT, { - headers: { - 'x-e2e-mocks': '1', - }, data: { name: 'x', email: invalidEmail, diff --git a/test/e2e/specs/08-api/newsletter-api.spec.ts b/test/e2e/specs/08-api/newsletter-api.spec.ts index 8d52edeca..210fd8f9d 100644 --- a/test/e2e/specs/08-api/newsletter-api.spec.ts +++ b/test/e2e/specs/08-api/newsletter-api.spec.ts @@ -1,21 +1,14 @@ -import { expect, test, wiremock, mocksEnabled } from '@test/e2e/helpers' +import { expect, test, wiremock } from '@test/e2e/helpers' const NEWSLETTER_ENDPOINT = '/api/newsletter' const RESEND_EMAIL_PATH = '/emails' test.describe('Newsletter API integrations', () => { - if (!mocksEnabled) { - test.skip(true, 'E2E_MOCKS=1 is required to run newsletter API integration tests') - } - test.describe.configure({ mode: 'serial' }) test('@mocks sends double opt-in email through Resend mock', async ({ request }) => { const uniqueEmail = `newsletter-${Date.now()}@example.com` const response = await request.post(NEWSLETTER_ENDPOINT, { - headers: { - 'x-e2e-mocks': '1', - }, data: { email: uniqueEmail, consentGiven: true, @@ -56,9 +49,6 @@ test.describe('Newsletter API integrations', () => { test('@mocks requires consent before sending any emails', async ({ request }) => { const emailWithoutConsent = 'noconsent@example.com' const response = await request.post(NEWSLETTER_ENDPOINT, { - headers: { - 'x-e2e-mocks': '1', - }, data: { email: emailWithoutConsent, consentGiven: false, diff --git a/test/e2e/specs/08-api/supabase.spec.ts b/test/e2e/specs/08-api/supabase.spec.ts deleted file mode 100644 index 3a26ac5b6..000000000 --- a/test/e2e/specs/08-api/supabase.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Supabase Database Tests - * Verifies Supabase client environment configuration and row-level security expectations. - */ - -import { createClient, type PostgrestError, type SupabaseClient } from '@supabase/supabase-js' -import { randomUUID } from 'node:crypto' -import { env } from 'node:process' -import { expect, test } from '@test/e2e/helpers' - -const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '') -const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY'] -const SUPABASE_ANON_KEY = env['SUPABASE_KEY'] -const SUPABASE_RLS_TABLE = 'consent_records' -const SUPABASE_DISABLED = env['E2E_SUPABASE_FALLBACK'] === '1' - -const supabaseReady = Boolean( - !SUPABASE_DISABLED && - SUPABASE_URL && - SUPABASE_SERVICE_ROLE_KEY && - SUPABASE_ANON_KEY -) - -const createSupabaseClient = (key: string): SupabaseClient => - createClient(SUPABASE_URL!, key, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }) - -const supabaseAdminClient = supabaseReady ? createSupabaseClient(SUPABASE_SERVICE_ROLE_KEY!) : null -const supabaseAnonClient = supabaseReady ? createSupabaseClient(SUPABASE_ANON_KEY!) : null - -const TEST_SOURCE = 'supabase_e2e' -const TEST_PRIVACY_VERSION = '1970-01-01' -const TEST_USER_AGENT = 'playwright/supabase' - -const createdRecordIds: string[] = [] - -const skipUnlessChromium = (browserName: string) => { - test.skip(browserName !== 'chromium', 'Supabase API tests only run once per suite') -} - -const buildConsentRecordPayload = () => ({ - 'data_subject_id': randomUUID(), - 'email': `supabase-e2e-${randomUUID()}@example.com`, - 'purposes': ['marketing'], - 'source': TEST_SOURCE, - 'user_agent': TEST_USER_AGENT, - 'privacy_policy_version': TEST_PRIVACY_VERSION, - 'consent_text': 'Captured for e2e Supabase coverage', -}) - -const recordCleanup = async () => { - if (!supabaseAdminClient || createdRecordIds.length === 0) { - return - } - - await supabaseAdminClient - .from(SUPABASE_RLS_TABLE) - .delete() - .in('id', [...createdRecordIds]) - - createdRecordIds.length = 0 -} - -test.afterEach(async () => { - if (supabaseReady) { - await recordCleanup() - } -}) - -const skipSupabaseReason = !supabaseReady - ? 'Supabase environment is not configured or fallback mode is enabled.' - : undefined - -test.describe('Supabase Database API', () => { - test.describe.configure({ mode: 'serial' }) - if (skipSupabaseReason) { - test.skip(true, skipSupabaseReason) - } - - test('Supabase Configuration - should use correct environment variables', async ({ browserName }) => { - skipUnlessChromium(browserName) - expect(SUPABASE_URL).toBeTruthy() - expect(SUPABASE_SERVICE_ROLE_KEY).toMatch(/^sb_secret_/) - expect(SUPABASE_ANON_KEY).toMatch(/^sb_/) - - const { error, count } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .select('*', { count: 'exact', head: true }) - - expect(error).toBeNull() - expect(typeof count === 'number').toBe(true) - }) - - test('Supabase Configuration - should create clients with proper auth settings', async ({ browserName }) => { - skipUnlessChromium(browserName) - const record = buildConsentRecordPayload() - const { data, error } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(record) - .select() - .single() - - expect(error).toBeNull() - expect(data?.source).toBe(TEST_SOURCE) - createdRecordIds.push(data!.id) - - const { error: anonError } = await supabaseAnonClient! - .from(SUPABASE_RLS_TABLE) - .insert(buildConsentRecordPayload()) - - expectRlsFailure({ error: anonError }) - }) -}) - -const expectRlsFailure = (result: { error: PostgrestError | null; data?: unknown }) => { - const { error, data } = result - if (error) { - expect(['42501', 'PGRST301']).toContain(error.code) - return - } - - if (Array.isArray(data)) { - expect(data.length).toBe(0) - } else { - expect(data ?? null).toBeNull() - } -} - -test.describe('RLS Policies', () => { - test.describe.configure({ mode: 'serial' }) - if (skipSupabaseReason) { - test.skip(true, skipSupabaseReason) - } - - test.describe('Service Role (Admin)', () => { - test('can insert records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const payload = buildConsentRecordPayload() - const { data, error } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(payload) - .select() - .single() - - expect(error).toBeNull() - expect(data?.email).toBe(payload.email) - createdRecordIds.push(data!.id) - }) - - test('can read records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const insertPayload = buildConsentRecordPayload() - const { data: inserted } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(insertPayload) - .select() - .single() - createdRecordIds.push(inserted!.id) - - const { data, error } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .select('*') - .eq('id', inserted!.id) - .single() - - expect(error).toBeNull() - expect(data?.email).toBe(insertPayload.email) - }) - - test('can update records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const insertPayload = buildConsentRecordPayload() - const { data: inserted } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(insertPayload) - .select() - .single() - createdRecordIds.push(inserted!.id) - - const updatedText = 'Updated via admin RLS test' - const { data, error } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .update(Object.fromEntries([[ 'consent_text', updatedText ]])) - .eq('id', inserted!.id) - .select() - .single() - - expect(error).toBeNull() - expect(data?.consent_text).toBe(updatedText) - }) - - test('can delete records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const insertPayload = buildConsentRecordPayload() - const { data: inserted } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(insertPayload) - .select() - .single() - - const { error } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .delete() - .eq('id', inserted!.id) - - expect(error).toBeNull() - }) - }) - - test.describe('Public Client (Anon)', () => { - test('cannot read records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const { data: inserted } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(buildConsentRecordPayload()) - .select() - .single() - createdRecordIds.push(inserted!.id) - - const { data, error } = await supabaseAnonClient! - .from(SUPABASE_RLS_TABLE) - .select('*') - .eq('id', inserted!.id) - .maybeSingle() - - expectRlsFailure({ error, data }) - }) - - test('cannot insert records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const { error } = await supabaseAnonClient! - .from(SUPABASE_RLS_TABLE) - .insert(buildConsentRecordPayload()) - - expectRlsFailure({ error }) - }) - - test('cannot update records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const insertPayload = buildConsentRecordPayload() - const { data: inserted } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(insertPayload) - .select() - .single() - createdRecordIds.push(inserted!.id) - - const { error } = await supabaseAnonClient! - .from(SUPABASE_RLS_TABLE) - .update(Object.fromEntries([[ 'consent_text', 'anon cannot edit' ]])) - .eq('id', inserted!.id) - - expectRlsFailure({ error }) - }) - - test('cannot delete records', async ({ browserName }) => { - skipUnlessChromium(browserName) - const insertPayload = buildConsentRecordPayload() - const { data: inserted } = await supabaseAdminClient! - .from(SUPABASE_RLS_TABLE) - .insert(insertPayload) - .select() - .single() - createdRecordIds.push(inserted!.id) - - const { error } = await supabaseAnonClient! - .from(SUPABASE_RLS_TABLE) - .delete() - .eq('id', inserted!.id) - - expectRlsFailure({ error }) - }) - }) -}) - diff --git a/test/e2e/specs/15-cron/cron.spec.ts b/test/e2e/specs/15-cron/cron.spec.ts index 1040995b6..c6b8846f3 100644 --- a/test/e2e/specs/15-cron/cron.spec.ts +++ b/test/e2e/specs/15-cron/cron.spec.ts @@ -1,41 +1,27 @@ -import { randomUUID } from 'node:crypto' -import { createClient, type SupabaseClient } from '@supabase/supabase-js' -import { expect, mocksEnabled, test } from '@test/e2e/helpers' +import { expect, test } from '@test/e2e/helpers' import { ensureCronDependenciesHealthy } from '@test/e2e/helpers/cronHealth' /** * These env helpers are safe to use in E2E test as they call process.env * directly. Must use "npm run dev:env" for this test case to pass. */ +import { getCronSecret } from '@pages/api/_environment/environmentApi' import { - getCronSecret, - getSuprabaseApiUrl, - getSuprabaseApiServiceRoleKey, - } from '@pages/api/_environment/environmentApi' + deleteDsarRequestById, + deleteNewsletterConfirmationById, + findDsarRequestById, + findNewsletterConfirmationById, + insertDsarRequest, + insertNewsletterConfirmation, +} from '@test/e2e/db' const CRON_SECRET = getCronSecret() -const SUPABASE_URL = getSuprabaseApiUrl() -const SUPABASE_SERVICE_ROLE_KEY = getSuprabaseApiServiceRoleKey() -const requiredEnvMissing = () => { - if (!mocksEnabled) { - return 'E2E_MOCKS=1 is required for cron API integration tests' - } +const skipReason = (() => { if (!CRON_SECRET) { return 'CRON_SECRET must be configured to call cron endpoints' } - if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { - return 'Supabase admin credentials are required to seed cron fixtures' - } return null -} - -const skipReason = requiredEnvMissing() - -const supabaseAdmin: SupabaseClient | null = skipReason - ? null - : createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!, { - auth: { autoRefreshToken: false, persistSession: false }, - }) +})() const cronAuthHeader = CRON_SECRET ? `Bearer ${CRON_SECRET}` : null @@ -46,97 +32,56 @@ const skipUnlessChromiumProject = () => { const dayInMs = 24 * 60 * 60 * 1000 -const createdConfirmationIds = new Set() -const createdDsarIds = new Set() +const createdConfirmationIds: string[] = [] +const createdDsarIds: string[] = [] -const queueCleanup = (bucket: Set, id: string) => { - bucket.add(id) +const queueCleanup = (bucket: string[], id: string) => { + bucket.push(id) return id } -const cleanupRecords = async (table: 'newsletter_confirmations' | 'dsar_requests', ids: Set) => { - if (!supabaseAdmin || ids.size === 0) { - return - } - const values = Array.from(ids) - await supabaseAdmin.from(table).delete().in('id', values) - ids.clear() +const cleanupConfirmations = async () => { + await Promise.all(createdConfirmationIds.map((id) => deleteNewsletterConfirmationById(id))) + createdConfirmationIds.length = 0 } -const insertNewsletterConfirmation = async (options: { +const cleanupDsarRequests = async () => { + await Promise.all(createdDsarIds.map((id) => deleteDsarRequestById(id))) + createdDsarIds.length = 0 +} + +const seedNewsletterConfirmation = async (options: { expiresAt: Date confirmedAt?: Date | null createdAt?: Date }) => { - if (!supabaseAdmin) { - throw new Error('Supabase client unavailable') - } - const payload = { - token: `cron-newsletter-${randomUUID()}`, - email: `cron-newsletter-${Date.now()}@example.com`, - data_subject_id: randomUUID(), - expires_at: options.expiresAt.toISOString(), - confirmed_at: options.confirmedAt ? options.confirmedAt.toISOString() : null, - created_at: (options.createdAt ?? new Date()).toISOString(), - } - - const { data, error } = await supabaseAdmin - .from('newsletter_confirmations') - .insert(payload) - .select('id') - .single() - - if (error || !data) { - throw new Error(`Failed to insert newsletter confirmation: ${error?.message}`) - } - - return queueCleanup(createdConfirmationIds, data.id) + const record = await insertNewsletterConfirmation({ + expiresAt: options.expiresAt, + confirmedAt: options.confirmedAt ?? null, + ...(options.createdAt ? { createdAt: options.createdAt } : {}), + }) + return queueCleanup(createdConfirmationIds, record.id) } -const insertDsarRequest = async (options: { +const seedDsarRequest = async (options: { fulfilledAt?: Date | null createdAt?: Date }) => { - if (!supabaseAdmin) { - throw new Error('Supabase client unavailable') - } - const payload = { - token: `cron-dsar-${randomUUID()}`, - email: `cron-dsar-${Date.now()}@example.com`, - request_type: 'DELETE', - expires_at: new Date(Date.now() + dayInMs).toISOString(), - fulfilled_at: options.fulfilledAt ? options.fulfilledAt.toISOString() : null, - created_at: (options.createdAt ?? new Date()).toISOString(), - } - - const { data, error } = await supabaseAdmin - .from('dsar_requests') - .insert(payload) - .select('id') - .single() - - if (error || !data) { - throw new Error(`Failed to insert DSAR request: ${error?.message}`) - } - - return queueCleanup(createdDsarIds, data.id) + const record = await insertDsarRequest({ + fulfilledAt: options.fulfilledAt ?? null, + ...(options.createdAt ? { createdAt: options.createdAt } : {}), + }) + return queueCleanup(createdDsarIds, record.id) } -const expectMissingById = async (table: 'newsletter_confirmations' | 'dsar_requests', id: string) => { - if (!supabaseAdmin) { - throw new Error('Supabase client unavailable') - } - const { data, error } = await supabaseAdmin - .from(table) - .select('id') - .eq('id', id) - .maybeSingle() - - if (error) { - throw new Error(`Failed to query ${table}: ${error.message}`) - } +const expectNewsletterMissing = async (id: string) => { + const row = await findNewsletterConfirmationById(id) + expect(row).toBeNull() +} - expect(data).toBeNull() +const expectDsarMissing = async (id: string) => { + const row = await findDsarRequestById(id) + expect(row).toBeNull() } test.describe('Cron API endpoints @ready', () => { @@ -146,27 +91,23 @@ test.describe('Cron API endpoints @ready', () => { test.skip(true, skipReason) } else { test.beforeAll(async () => { - await ensureCronDependenciesHealthy({ - supabaseUrl: SUPABASE_URL!, - supabaseServiceKey: SUPABASE_SERVICE_ROLE_KEY!, - timeoutMs: 5000, - }) + await ensureCronDependenciesHealthy() }) } test.afterEach(async () => { - await cleanupRecords('newsletter_confirmations', createdConfirmationIds) - await cleanupRecords('dsar_requests', createdDsarIds) + await cleanupConfirmations() + await cleanupDsarRequests() }) test('@ready cleanup-confirmations removes expired and stale rows', async ({ request }) => { skipUnlessChromiumProject() const now = Date.now() - const expiredId = await insertNewsletterConfirmation({ + const expiredId = await seedNewsletterConfirmation({ expiresAt: new Date(now - 60 * 60 * 1000), }) - const staleId = await insertNewsletterConfirmation({ + const staleId = await seedNewsletterConfirmation({ expiresAt: new Date(now + dayInMs), confirmedAt: new Date(now - 8 * dayInMs), createdAt: new Date(now - 8 * dayInMs), @@ -186,19 +127,19 @@ test.describe('Cron API endpoints @ready', () => { expect(body.deleted.expired).toBeGreaterThanOrEqual(1) expect(body.deleted.oldConfirmed).toBeGreaterThanOrEqual(1) - await expectMissingById('newsletter_confirmations', expiredId) - await expectMissingById('newsletter_confirmations', staleId) + await expectNewsletterMissing(expiredId) + await expectNewsletterMissing(staleId) }) test('@ready cleanup-dsar-requests prunes fulfilled and expired items', async ({ request }) => { skipUnlessChromiumProject() const now = Date.now() - const fulfilledId = await insertDsarRequest({ + const fulfilledId = await seedDsarRequest({ fulfilledAt: new Date(now - 31 * dayInMs), createdAt: new Date(now - 31 * dayInMs), }) - const expiredPendingId = await insertDsarRequest({ + const expiredPendingId = await seedDsarRequest({ fulfilledAt: null, createdAt: new Date(now - 8 * dayInMs), }) @@ -217,8 +158,8 @@ test.describe('Cron API endpoints @ready', () => { expect(body.deleted.fulfilled).toBeGreaterThanOrEqual(1) expect(body.deleted.expired).toBeGreaterThanOrEqual(1) - await expectMissingById('dsar_requests', fulfilledId) - await expectMissingById('dsar_requests', expiredPendingId) + await expectDsarMissing(fulfilledId) + await expectDsarMissing(expiredPendingId) }) test('@ready ping-integrations validates Astro DB availability', async ({ request }) => { From b7f40e134c2fc1d8848183e88520e74d9c47e994 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 11 Dec 2025 04:35:37 +0300 Subject: [PATCH 07/22] Update open-graph e2e test to match implementation changes, removed Twitter image, title, and description cases --- test/e2e/specs/05-metadata/open-graph.spec.ts | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/test/e2e/specs/05-metadata/open-graph.spec.ts b/test/e2e/specs/05-metadata/open-graph.spec.ts index e9f779b8a..db52636d5 100644 --- a/test/e2e/specs/05-metadata/open-graph.spec.ts +++ b/test/e2e/specs/05-metadata/open-graph.spec.ts @@ -102,30 +102,6 @@ test.describe('Open Graph Metadata', () => { expect(['summary', 'summary_large_image']).toContain(cardType) }) - test('@ready Twitter title is present', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto('/') - - const content = await page.getAttribute('meta[name="twitter:title"]', 'content') - expect(content?.trim().length).toBeGreaterThan(0) - }) - - test('@ready Twitter description is present', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto('/') - - const content = await page.getAttribute('meta[name="twitter:description"]', 'content') - expect(content?.trim().length).toBeGreaterThan(0) - }) - - test('@ready Twitter image is present', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto('/') - - const imageUrl = await page.getAttribute('meta[name="twitter:image"]', 'content') - expect(imageUrl).toMatch(/^https?:\/\//) - }) - test('@ready all pages have unique OG descriptions', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) const pages = ['/', '/about', '/services'] From e0765e5a9d8b892b2648dd4bb492442282623eb7 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 11 Dec 2025 23:23:00 +0300 Subject: [PATCH 08/22] Add CRON job to vercel.json for pinging Tursa database with 30 minute frequency --- _TODO.md | 74 +++++++++++++++++++++++++++++++++++ src/pages/api/cron/run-all.ts | 1 - vercel.json | 4 ++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/_TODO.md b/_TODO.md index 3101f97bf..119afade9 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,6 +1,80 @@ # TODO +## Astro DB + +### Remote vs. local databases + +To connect to a hosted remote database, use the --remote flag. This flag enables both readable and writable access to your remote database, allowing you to accept and persist user data in production environments. + +ASTRO_DATABASE_FILE must be set in the environment the "astro build" command is called from for it to take effect. When it is set, the Astro framework will ignore the "--remote" flag if it is also set, and use the local database file passed to the ASTRO_DATABASE_FILE environmental variable. + +**Q:** Should the value of `ASTRO_DATABASE_FILE` be prefixed with a URL schema, like `file:./db/dev.db`? + +**A:** Optional. Astro will resolve a relative path, and add the transport protocol, if necessary. + +**Q:** Does the value of `ASTRO_DB_REMOTE_URL` matter when `ASTRO_DATABASE_FILE` is set in the environment? + +**A:** Remote database settings are only read when the integration runs in "remote" mode i.e. the `--remote` flag is present or `ASTRO_INTERNAL_TEST_REMOTE` is truthy. Once you set `ASTRO_DATABASE_FILE `and run without `--remote`, `ASTRO_DB_REMOTE_URL` has no effect. + +**Q:** Is the value of `ASTRO_DB_APP_TOKEN` relevant when using a local database file? + +**A:** Also only read in the remote mode. When you point Astro at a local SQLite file via `ASTRO_DATABASE_FILE`, the token is unused. + +**Q:** Do both `astro build` and `astro dev` use the same mechanism, or work the same way, with respect to the above? + +**A:** Both commands share the same integration. During astro:config:setup, Astro decides once whether it's in remote mode and wires either the remote or local Vite plugin. + +**Q:** Does the Astro framework create a client for every DB call? Does it create it once on the first access to the DB API, and then reuse the same client for the rest of the execution context? + +**A:** During normal request handling, the framework initializes the client once per worker on first access and reuses it for all subsequent DB calls. When you import anything from `astro:db`, Vite loads the virtual module generated by `vite-plugin-db.js`. The exported `db` token is the return of the `@libsql/client` `createClient` invocation. + +When neither `ASTRO_DATABASE_FILE` nor `--remote` is provided, the integration falls back to the constant `DB_PATH` defined in `consts.js`, which is `.astro/content.db`. + +libSQL supports both HTTP and WebSockets as the transport protocol for a remote server. It also supports using a local file or an in-memory DB. Those can be configured using the following URL schemes in the connection URL: + +- `file`: will use a local file. The host is the path to the file (file:path/to/file.db). +- `libsql`: will use a remote server through the protocol preferred by the library (this might be different across versions). The host is the address of the server (libsql://your.server.io). +- `http:` will use a remote server through HTTP. `https:` can be used to enable a secure connection. The host is the same as for libsql:. + +### Endpoints: + +- contact/ → POST (contact form submission) and OPTIONS (CORS pre-flight) +- cron/cleanup-confirmations → GET +- cron/cleanup-dsar-requests → GET +- cron/ping-integrations → GET +- cron/run-all → GET +- downloads/submit → POST +- gdpr/consent → POST, GET, DELETE +- gdpr/request-data → POST +- gdpr/export → GET +- gdpr/verify → GET +- health/ → GET +- newsletter/ → POST, OPTIONS +- newsletter/confirm → GET +- social-card/ → GET + +### Files importing from `astro:db` + +- _utils/rateLimit.ts +- _utils/rateLimitStore.ts +- cron/cleanup-confirmations.ts +- cron/cleanup-dsar-requests.ts +- cron/ping-integrations.ts +- gdpr/_utils/consentStore.ts +- gdpr/_utils/dsarStore.ts +- newsletter/_token.ts + +### Cross-endpoint dependencies: + +gdpr: Mostly self-contained, but `verify.ts` does import `deleteNewsletterConfirmationsByEmail` from `@pages/api/newsletter/_token` (line 15). That's a direct dependency on the newsletter code. + +newsletter: `confirm.ts` pulls `markConsentRecordsVerified` from `@pages/api/gdpr/_utils/consentStore` (line 10) to mark double opt-in consent. That's the reciprocal dependency. + +Newsletter hits the gdpr consent endpoint using `recordConsent` in `src/pages/api/_logger/index.ts`. + +If we want to make it feel less inconsistent, we could either (a) rename `_logger` to something like `_consentClient` so its purpose is clearer, or (b) move to a microservices architecture and expose a protected `/api/gdpr/verify` endpoint and have newsletter call it over HTTP as well - but that would need additional auth to prevent abuse. + ## Typing client-side API calls and SSR API endpoints Use Astro Actions diff --git a/src/pages/api/cron/run-all.ts b/src/pages/api/cron/run-all.ts index 39ed8c08d..fe76e37ec 100644 --- a/src/pages/api/cron/run-all.ts +++ b/src/pages/api/cron/run-all.ts @@ -11,7 +11,6 @@ const ROUTE = '/api/cron/run-all' const CRON_ENDPOINTS = [ '/api/cron/cleanup-confirmations', '/api/cron/cleanup-dsar-requests', - '/api/cron/ping-integrations', ] const buildErrorResponse = ( diff --git a/vercel.json b/vercel.json index fd5867fb6..30496059d 100644 --- a/vercel.json +++ b/vercel.json @@ -6,6 +6,10 @@ { "path": "/api/cron/run-all", "schedule": "0 2 * * *" + }, + { + "path": "/api/cron/ping-integrations", + "schedule": "*/30 * * * *" } ] } From 38c82b46a93513b778935b2c75657fd1b961f902 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 12 Dec 2025 03:22:53 +0300 Subject: [PATCH 09/22] Fixes to e2e infrastructure for move to Astro DB local database file in tests --- .cache/pages.json | 12 +++- .env.example | 2 +- README.md | 6 +- _TODO.md | 53 ++++++++------ db/dev.db | Bin 77824 -> 0 bytes db/seed.ts | 17 ++++- eslint.config.ts | 3 + package.json | 10 +-- .../PrivacyPolicyVersion/index.ts | 4 +- src/lib/config/environmentServer.ts | 5 ++ src/pages/api/_environment/environmentApi.ts | 65 ++++++++++++++++-- src/pages/api/_errors/apiFunctionHandler.ts | 9 ++- test/containers/README.md | 2 +- test/e2e/config/global-setup.ts | 4 ++ test/e2e/config/global-teardown.ts | 2 + test/e2e/config/runtime/database.ts | 60 +++++++++++++++- test/e2e/config/runtime/mockState.ts | 42 +++++++++++ test/e2e/config/runtime/paths.ts | 4 +- test/e2e/config/runtime/seedVerification.ts | 64 +++++++++++++++++ test/e2e/db/helpers.ts | 17 ++++- test/e2e/db/libsqlClient.ts | 16 ++--- test/e2e/helpers/cronHealth.ts | 3 +- 22 files changed, 340 insertions(+), 60 deletions(-) delete mode 100644 db/dev.db create mode 100644 test/e2e/config/runtime/mockState.ts create mode 100644 test/e2e/config/runtime/seedVerification.ts diff --git a/.cache/pages.json b/.cache/pages.json index 5f4910f3c..a5936162f 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,10 +22,16 @@ "contact", "offline", { - "privacy": ["my-data"] + "privacy": [ + "my-data" + ] }, { - "services": ["consulting", "overview", "web-development"] + "services": [ + "consulting", + "overview", + "web-development" + ] }, { "tags": [ @@ -47,4 +53,4 @@ "typescript" ] } -] +] \ No newline at end of file diff --git a/.env.example b/.env.example index 51f9613db..ffeb27da7 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,6 @@ VERCEL_ORG_ID="team_C7kQw5vXn0PfH3sJt2Gb9LrY" WEBMENTION_IO_TOKEN="dev-webmention-token" # Astro DB - local file-backed dev database (token unused for file connections) -ASTRO_DB_REMOTE_URL="file:./db/dev.db" +ASTRO_DB_REMOTE_URL="file:./.astro/content.db" ASTRO_DB_APP_TOKEN="" # ASTRO_DATABASE_FILE must be exported in the shell (see README) diff --git a/README.md b/README.md index 77d653909..4cf7bf66c 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,16 @@ The underlying `npm run dev` command remains unchanged for CI and Vercel; `dev:e Astro DB schemas now live under `db/config.ts`. Applying those migrations requires `npx astro db push`; the regular `astro build` command never pushes schema changes for you. Keep local and remote pushes separate to avoid promoting unfinished changes: -- **Local development / CI** – Point Astro DB at the file-based database via `ASTRO_DB_REMOTE_URL="file:./db/dev.db"` and run `npx astro db push`. For convenience the `build:ci` script exports `ASTRO_DATABASE_FILE=./db/dev.db` before invoking `astro build`, so `npm run build:ci` is the go-to command anywhere you need a build that reads the local snapshot (including GitHub Actions). +- **Local development / CI** – Point Astro DB at the file-based database via `ASTRO_DB_REMOTE_URL="file:./.astro/content.db"` and run `npx astro db push`. For convenience the `build:ci` script exports `ASTRO_DATABASE_FILE=./.astro/content.db` before invoking `astro build`, so `npm run build:ci` is the go-to command anywhere you need a build that reads the local snapshot (including GitHub Actions). - **Production Turso push** – Export your Turso connection details (`ASTRO_DB_REMOTE_URL` + `ASTRO_DB_APP_TOKEN`) and run `npx astro db push --remote`. The merge-to-main workflow should be the only automation that executes this command. - **Vercel deployments** – Set the build command to `npm run build -- --remote` so the build pipeline talks to Turso in read-only mode while still leaving schema promotion to the dedicated workflow. -> `ASTRO_DATABASE_FILE` is evaluated before Astro loads `.env.*`, so put it directly in the shell invocation (e.g., `ASTRO_DATABASE_FILE=./db/dev.db npm run build`) or reuse the provided `npm run build:ci` helper. Dropping the variable into `.env.development` or `.env.production` will not satisfy the build hook. +> `ASTRO_DATABASE_FILE` is evaluated before Astro loads `.env.*`, so put it directly in the shell invocation (e.g., `ASTRO_DATABASE_FILE=./.astro/content.db npm run build`) or reuse the provided `npm run build:ci` helper. Dropping the variable into `.env.development` or `.env.production` will not satisfy the build hook. ### Build scripts - `npm run build` – Runs `astro build --remote`, hitting the production Turso database in read-only mode. Use this for Vercel deployments and any workflows that need to mirror production infrastructure. -- `npm run build:ci` – Exports `ASTRO_DATABASE_FILE=./db/dev.db` before delegating to `npm run build`, forcing the build to read from the local SQLite snapshot. Use this for local testing and the `Test` GitHub workflow so schema changes stay isolated until the Turso push workflow runs. +- `npm run build:ci` – Exports `ASTRO_DATABASE_FILE=./.astro/content.db` before delegating to `npm run build`, forcing the build to read from the local SQLite snapshot. Use this for local testing and the `Test` GitHub workflow so schema changes stay isolated until the Turso push workflow runs. ## Coding Standards diff --git a/_TODO.md b/_TODO.md index 119afade9..c3c3cbeeb 100644 --- a/_TODO.md +++ b/_TODO.md @@ -3,13 +3,41 @@ ## Astro DB +ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ +ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \ +npx astro db push + +ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ +ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \ +npx astro db execute db/seed.ts + +`--force-reset` still runs a metadata query against the existing SQLite file. Your content.db already has tables (and likely lacks the drizzle/Astro metadata tables). When the reset path tries to map results, it gets undefined/null (drizzle bug on files with existing user tables but missing its own schema), hence Cannot convert undefined or null to object. + +Practical reset path: + +Stop the dev server (to release the file). +Backup/remove the current DB: + +`mv .astro/content.db .astro/content.db.bak` +(or `rm .astro/content.db` if you don't need it) + +Run a clean push: + +`ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \npx astro db push` + +Seed if needed: + +`ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \npx astro db execute db/seed.ts` + +If you prefer to keep the dev DB intact, use a different filename for the reset (e.g., content.e2e.db) and point both env vars to that. + ### Remote vs. local databases To connect to a hosted remote database, use the --remote flag. This flag enables both readable and writable access to your remote database, allowing you to accept and persist user data in production environments. ASTRO_DATABASE_FILE must be set in the environment the "astro build" command is called from for it to take effect. When it is set, the Astro framework will ignore the "--remote" flag if it is also set, and use the local database file passed to the ASTRO_DATABASE_FILE environmental variable. -**Q:** Should the value of `ASTRO_DATABASE_FILE` be prefixed with a URL schema, like `file:./db/dev.db`? +**Q:** Should the value of `ASTRO_DATABASE_FILE` be prefixed with a URL schema, like `file:./.astro/content.db`? **A:** Optional. Astro will resolve a relative path, and add the transport protocol, if necessary. @@ -39,11 +67,13 @@ libSQL supports both HTTP and WebSockets as the transport protocol for a remote ### Endpoints: -- contact/ → POST (contact form submission) and OPTIONS (CORS pre-flight) - cron/cleanup-confirmations → GET - cron/cleanup-dsar-requests → GET - cron/ping-integrations → GET - cron/run-all → GET +- social-card/ → GET + +- contact/ → POST (contact form submission) and OPTIONS (CORS pre-flight) - downloads/submit → POST - gdpr/consent → POST, GET, DELETE - gdpr/request-data → POST @@ -52,7 +82,6 @@ libSQL supports both HTTP and WebSockets as the transport protocol for a remote - health/ → GET - newsletter/ → POST, OPTIONS - newsletter/confirm → GET -- social-card/ → GET ### Files importing from `astro:db` @@ -79,24 +108,6 @@ If we want to make it feel less inconsistent, we could either (a) rename `_logge Use Astro Actions -### Shared Types vs Swagger / Keeping Docs in Sync - -**Type-only sharing (current approach)** - -- Pros: zero extra build tooling, server/client stay aligned as long as both import @pages/api/_contracts. -- Cons: no generated docs/SDKs; discipline is required to keep manual docs current. -- How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge). - -**Code-first OpenAPI (Zod or TS schemas → OpenAPI)** - -- Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they're always in sync. -- Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile. - -**Spec-first OpenAPI + Swagger Codegen** - -- Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs. -- Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation. - **Affected components:** - CallToAction/Newsletter diff --git a/db/dev.db b/db/dev.db deleted file mode 100644 index 861e1c017e1606a1d40071a736be559c26c56a0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77824 zcmeI5OK%(36~~8q)3!`IfxvEzjK@`E#iS!rZy7>OJ4~Tkk{yYx;~IuCoVm2eCOP!X z(2`&XhGJyV&rqO?qCl1fx+;nUMZQH)pu29`MHg9j(Q{{p^B^@cU5(>d`qP%oxsP-I z_jk{^kI_)x{A^XTm{`?~x@?Kl!Z{(45Uz`&AP6J$^*(*IUmww%f%XOcn+Uv*dOIRq zefHbY!T$>92d@jMFH&bm|2X*F=){@7oL)b(J$U`p`026aUz5e*CnNtE`p3}SC^JY0 z0T2LzACtg~pADz4WE1<-T0>YDXpqL)&4$%ejdp1QeF zER>33d41)xa#37a|D^c2D0P^Y#M|ql_Od?{px)7P#gzDiqir54q2 zgq@VDr&PGHS`?*-QoJ=yl|+l}SmK?Hm9@ggJ@HoY9-(nWI63RLOX7NYb(NAGbY00| zkX_m238u9!*O}84*)jE&p}3b~z<$eQM&TiOpI1_A7F5+>rs<`)URl!W%(Ucs)72la z!giV(RVlbuf(=k*?w3q$XObAjU=*KTidTGcEjyH!uSEq*1k|luM8-La9ll8t%qzp` z?091TqU~lj(hWwwWm=`(CR0t>a4*fX2Lw)_sN+h(3L*D4e52B&_7TlI)M(0Xl-CLP zWYEOufv((9;oz2C&)u(Irf42dyu4t0ZqRt}{pqle2D4&w%pig;w>-p6s;yd8tJQp` zILuAvCx_Cv#-rRM*srZz@aoK$AC8kJWD_r6*q}Nm@y_d!FtE<-L!jAfMt7V97KSc3 zk2H%yPHR}~Av3&?APhCWjvbh@O|GSHuN_>?$t?Pjd4TCwlO__Mx!3CCFFqVfZ)T$i zcY@f;b*3ciKOatCnn>(lu)SYv-fV}wJDE5u-f%eF0>vDE4;8j?dy`YhKPI1FqH(3> zKDL`PoyGCsHiyD(c9xB3I6UEo8~^g*#o_eCMB=Ma+p9vNLl*;Ke+uOt;!NSTyBsI5 zM3Am#%V_E*b7yaFL@B4oB_LR(do5@h+A~?%y`$GOW%t+2FloEd?dNXKO0>**tH?8E z@U0oGew~geep39jP+l#GpB7d(iy^k*&Q9Lx8%kfflDKDcHl59s$tT^0!a6SdL##V8 zFE?my%uvnA{R@fVbS{_JXSU5P*|ZFOORa2~4Y_GP(yi{4zL4eaeDU(BWKvUU`&cUe zvbYgk=fy&~bbDo;46GH`OBpIhR5(JhRQ%k&ZZ|95SiODYRC02ZmaoLnw*vje3j{y_ z1V8`;-VFkOUPufI7yr4HpUTf3*H{By}&@T4WlPn9xs@8l8fW2B|$Qg~NnC)8T? zhRO5j>`dP+IGKNp(AYZH6*z`)SFzsTyy)!MLL_oIK(-Yjv}fJ z&fAjuvL2g+JvizeUCBs252k1RP^p}ay8-r9{Qn;0nmV+}uop!SnSTo=4rRWzSj71! zfH=hY){Y|1^Q(i-B5}w(@Q`y9#rEKH7YrVZ;!qm2cNBqbq~0OT7gy2s8k6ISRFbmaBgvD7gxuTT*^ z-v(~N#UUrFmdUk zFeZ#FFV4*`D!JSYJ?K}N%Fa}$X0vi-ekwby&a1O3TbN$R%}%+mEdRITo12xtFVVwy zPne}O9!d{P#?&oSF|?+|-)3e0X*K?io*$g0ma@62Qa-nopIVxlyPBWQ|7QPJ2@;jP zcIn~=WAn-7>A9JFb#aDe7ua+)J2N}KkX@Kl7qfXazpyY>Q7dvzaU1T`W6N{%a=gjw zY==JoR%2Hcy?$Sk>CwAgOH)kyX~KpjD^`TuTz>xQ6#cP{Y?2|SiteL3B0Zz18%SrEp0a3-A-~acukox*v>pk>>00@8p2!H?xfB*=900@8p2!Oz`BXBp7 zERUr}lKq3rIhN1O$*P)NoSRd!Gt>E8c41*o&dydUvOK5eXXh8^`0o;^>55zv{{2Pb zcc(7>;k&5wKmYlEBK19e{{Pr9Lx(^B1V8`;KmY_l00ck)1V8`;K;YOD;Gh5R%E++?gARfK2!H?xfB*=900@8p2!H?xfWV0$Fgh@nPI?ar@aO-9b0?zX z=p+b$00@8p2!H?xfB*=900@8p2%J~~{OA8T|DV|5N5??`1V8`;KmY_l00ck)1V8`; zP6z><|4+z3qnjWA0w4eaAOHd&00JNY0w4eaCz?Ru{LeoMaE884pJ*WHJP3dQ2!H?x zfB*=900@8p2!H?x976*5{Qof=RCEahKmY_l00ck)1V8`;KmY_l00cS+1b+VCk%LPR z009sH0T2KI5C8!X009sH0T2Lz<3a%E|Kl=}=m!XZ00@8p2!H?xfB*=900@8p2t*SI zod3^LDgHkJrlLzD2LwO>1V8`;KmY_l00ck)1V8`;K;T^^AX&RjCM`*gR=vUuDI=N6 zBUYECrRP$sp*?L;URAD{EF-C`X)r~$nCc}pA7d!_<_{Cg2d~A)x%($sH zs+v)k`MUd~%|%hozm;jJH!*K*hB~S|3VeC9Re4N1v6Xn%maxSRcR#9Jaz@iyxA&1T z%&fk(cx1L$N>gtc3T<^`km#CpxTvfYT>9G^`Ch09L_))>mHBo=~!t%z}{-FKW qK;^H~e(=UQiry}xXHDT9fsgd{_XX}W&;2vJCkW5j5xauDz5fBx#&n+m diff --git a/db/seed.ts b/db/seed.ts index c3c3df835..f7b6ce8d7 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -1,10 +1,25 @@ import { randomUUID } from 'node:crypto' -import { consentEvents, rateLimitWindows, db } from 'astro:db' +import { and, consentEvents, db, eq, rateLimitWindows } from 'astro:db' // https://astro.build/db/seed export default async function seed() { + const now = new Date() + // Ensure idempotent seeding by removing prior seed rows before re-inserting + const seededSubjects = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + ] + + for (const subjectId of seededSubjects) { + await db.delete(consentEvents).where(eq(consentEvents.dataSubjectId, subjectId)) + } + + await db + .delete(rateLimitWindows) + .where(and(eq(rateLimitWindows.scope, 'seed'), eq(rateLimitWindows.identifier, 'local'))) + /* * Provide a pair of consent records so GDPR endpoints, dashboard pages, and local tests * always have deterministic data to read without depending on real submissions. diff --git a/eslint.config.ts b/eslint.config.ts index f69a55e47..51ab02a1a 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -421,7 +421,10 @@ export default [ 'src/components/scripts/utils/environmentClient.ts', 'src/lib/config/**/*', 'src/pages/api/_environment/**/*', + 'test/e2e/config/runtime/database.ts', 'test/e2e/config/global-setup.ts', + 'test/e2e/config/runtime/mockState.ts', + 'test/e2e/db/libsqlClient.ts', 'test/e2e/helpers/pageObjectModels/**/*', 'test/e2e/helpers/mockServices.ts', ], diff --git a/package.json b/package.json index f79551c72..0e399710b 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ ], "scripts": { "build": "cross-env NODE_ENV=production npx astro build --remote", - "build:ci": "cross-env ASTRO_DATABASE_FILE=./db/dev.db npm run build", + "build:ci": "cross-env npm run build", "check": "npx astro check", "clean": "FORCE_COLOR=1 npx rimraf dist && FORCE_COLOR=1 npx rimraf .astro", "containers:down": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down -v", @@ -67,12 +67,12 @@ "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.2.0", "@semantic-ui/astro-lit": "^5.1.1", - "@sentry/astro": "^10.29.0", - "@sentry/browser": "^10.29.0", + "@sentry/astro": "^10.30.0", + "@sentry/browser": "^10.30.0", "@shikijs/transformers": "^3.19.0", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "@types/hast": "^3.0.4", "@types/pubsub-js": "^1.8.6", "@vite-pwa/astro": "^1.2.0", @@ -107,7 +107,7 @@ "resend": "^6.6.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "title-case": "4.3.2", "to-ico": "1.1.5", "uuid": "^13.0.0", diff --git a/src/integrations/PrivacyPolicyVersion/index.ts b/src/integrations/PrivacyPolicyVersion/index.ts index 72f29c689..c2cc661fe 100644 --- a/src/integrations/PrivacyPolicyVersion/index.ts +++ b/src/integrations/PrivacyPolicyVersion/index.ts @@ -18,9 +18,9 @@ import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AstroIntegration } from 'astro' -import { getOptionalEnv } from '../../lib/config/environmentServer' +import { getOptionalEnv, getGitHubRepoPath, isGitHub } from '../../lib/config/environmentServer' -const PROJECT_ROOT = process.cwd() +const PROJECT_ROOT = isGitHub() ? getGitHubRepoPath() : process.cwd() const PRIVACY_POLICY_PATH = join(PROJECT_ROOT, 'src', 'pages', 'privacy', 'index.astro') const GIT_DIRECTORY_PATH = join(PROJECT_ROOT, '.git') diff --git a/src/lib/config/environmentServer.ts b/src/lib/config/environmentServer.ts index 0aef72f5c..0c6e31c42 100644 --- a/src/lib/config/environmentServer.ts +++ b/src/lib/config/environmentServer.ts @@ -44,6 +44,11 @@ export const isGitHub = () => { return !!process.env['GITHUB_ACTIONS'] } + +export const getGitHubRepoPath = () => { + return process.env['GITHUB_WORKSPACE']! +} + export const isVercel = () => { return !!process.env['VERCEL'] } diff --git a/src/pages/api/_environment/environmentApi.ts b/src/pages/api/_environment/environmentApi.ts index 46d2e21ac..b2d3623ee 100644 --- a/src/pages/api/_environment/environmentApi.ts +++ b/src/pages/api/_environment/environmentApi.ts @@ -5,6 +5,8 @@ * routes to import it. Vercel exposes environment variables in Vercel serverless * functions with process.env. */ +import { readFileSync, statSync } from 'node:fs' +import path from 'node:path' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { isE2eTest, isUnitTest } from '@lib/config/environmentServer' export { @@ -135,15 +137,19 @@ export function getResendApiKey(): string { */ export function getResendMockBaseUrl(): string | null { const explicit = process.env['RESEND_MOCK_URL'] - const mocksEnabled = Boolean(explicit) || isE2eTest() + if (explicit) { + return explicit.replace(/\/$/, '') + } - if (!mocksEnabled) { - return null + const stateBaseUrl = getWiremockServiceBaseUrl('resend') + if (stateBaseUrl) { + return stateBaseUrl } - if (explicit) { - return explicit.replace(/\/$/, '') + if (!isE2eTest()) { + return null } + const host = process.env['WIREMOCK_HOST'] ?? '127.0.0.1' const port = process.env['RESEND_HTTP_PORT'] ?? '9011' return `http://${host}:${port}` @@ -164,3 +170,52 @@ export function getSentryDsn(): string { } return key } + +type WiremockServiceName = 'convertkit' | 'resend' + +interface WiremockServiceState { + baseUrl?: string +} + +interface WiremockStateFile { + services?: Partial> +} + +const wiremockStateCache: { + mtimeMs: number + data: WiremockStateFile | null +} = { + mtimeMs: -1, + data: null, +} + +const resolveWiremockStatePath = (): string => { + const override = process.env['E2E_WIREMOCK_STATE_PATH'] + if (override?.trim()) { + return override + } + return path.join(process.cwd(), '.cache', 'wiremock-state.json') +} + +const loadWiremockState = (): WiremockStateFile | null => { + const stateFile = resolveWiremockStatePath() + try { + const stats = statSync(stateFile) + if (stats.mtimeMs !== wiremockStateCache.mtimeMs) { + const contents = readFileSync(stateFile, 'utf8') + wiremockStateCache.data = JSON.parse(contents) as WiremockStateFile + wiremockStateCache.mtimeMs = stats.mtimeMs + } + return wiremockStateCache.data + } catch { + wiremockStateCache.mtimeMs = -1 + wiremockStateCache.data = null + return null + } +} + +function getWiremockServiceBaseUrl(serviceName: WiremockServiceName): string | null { + const state = loadWiremockState() + const candidate = state?.services?.[serviceName]?.baseUrl + return candidate ? candidate.replace(/\/$/, '') : null +} diff --git a/src/pages/api/_errors/apiFunctionHandler.ts b/src/pages/api/_errors/apiFunctionHandler.ts index a25172529..52debaac7 100644 --- a/src/pages/api/_errors/apiFunctionHandler.ts +++ b/src/pages/api/_errors/apiFunctionHandler.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { captureException, withScope } from '@sentry/astro' import { ensureApiSentry } from '@pages/api/_sentry' -import { isProd, isTest } from '@pages/api/_environment/environmentApi' +import { isDev, isProd, isTest } from '@pages/api/_environment/environmentApi' import { ApiFunctionError, type ApiFunctionErrorParams } from './ApiFunctionError' ensureApiSentry() @@ -58,6 +58,8 @@ export interface ApiErrorLogEntry { region?: string | undefined consentFunctional: boolean message: string + stack?: string | undefined + cause?: string | undefined requestMeta?: SanitizedRequestMetadata | undefined } @@ -176,6 +178,11 @@ export function formatApiErrorLogEntry( message: error.getSafeMessage(), } + if (isDev()) { + if (error.stack) entry.stack = error.stack + if (error.cause) entry.cause = error.cause instanceof Error ? error.cause.message : String(error.cause) + } + if (details.operation) entry.operation = details.operation if (details.runtime) entry.runtime = details.runtime if (details.region) entry.region = details.region diff --git a/test/containers/README.md b/test/containers/README.md index 822e15daa..7d8bed175 100644 --- a/test/containers/README.md +++ b/test/containers/README.md @@ -35,7 +35,7 @@ test/containers/ `npm run test:e2e` now invokes Playwright's global setup, which: -1. Resets the Astro DB test database (`.cache/astro-db/e2e.db`). +1. Resets the shared development Astro DB (`.astro/content.db`). 2. Runs `docker compose` against `docker-compose.e2e.yml` to boot the ConvertKit and Resend mocks. 3. Registers a teardown hook so the containers stop once the suite completes. diff --git a/test/e2e/config/global-setup.ts b/test/e2e/config/global-setup.ts index 8cdcd262c..5d50cb687 100644 --- a/test/e2e/config/global-setup.ts +++ b/test/e2e/config/global-setup.ts @@ -1,5 +1,7 @@ import type { FullConfig } from '@playwright/test' import { prepareDatabase, resolveTestDatabaseFile } from './runtime/database' +import { verifyDatabaseSeed } from './runtime/seedVerification' +import { writeWiremockState } from './runtime/mockState' import { startWiremock } from './runtime/wiremock' async function globalSetup(_config: FullConfig) { @@ -13,7 +15,9 @@ async function globalSetup(_config: FullConfig) { process.env['ASTRO_DATABASE_FILE'] = testDatabaseFile await prepareDatabase() + await verifyDatabaseSeed() await startWiremock() + await writeWiremockState() } export default globalSetup diff --git a/test/e2e/config/global-teardown.ts b/test/e2e/config/global-teardown.ts index d517c42f2..06a0cdded 100644 --- a/test/e2e/config/global-teardown.ts +++ b/test/e2e/config/global-teardown.ts @@ -1,8 +1,10 @@ import type { FullConfig } from '@playwright/test' +import { deleteWiremockState } from './runtime/mockState' import { stopWiremock } from './runtime/wiremock' async function globalTeardown(_config: FullConfig) { await stopWiremock() + await deleteWiremockState() } export default globalTeardown diff --git a/test/e2e/config/runtime/database.ts b/test/e2e/config/runtime/database.ts index 3abbc8e5f..da1722707 100644 --- a/test/e2e/config/runtime/database.ts +++ b/test/e2e/config/runtime/database.ts @@ -1,6 +1,9 @@ import { spawn } from 'node:child_process' import { mkdir } from 'node:fs/promises' -import { testDatabaseFile, testCacheDir } from './paths' +import { createClient } from '@libsql/client' +import { devDatabaseDir, testDatabaseFile } from './paths' + +const MIGRATION_VERSION = '2024-03-12' const runCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv) => { return new Promise((resolve, reject) => { @@ -26,11 +29,62 @@ const runCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv) => export const resolveTestDatabaseFile = () => testDatabaseFile +const ensureSnapshotTableHasRow = async (databaseUrl: string, authToken?: string) => { + const client = createClient( + authToken ? { url: databaseUrl, authToken } : { url: databaseUrl } + ) + + try { + await client.execute(` + CREATE TABLE IF NOT EXISTS _astro_db_snapshot ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT, + snapshot BLOB + ); + `) + + const result = await client.execute( + 'SELECT snapshot FROM _astro_db_snapshot ORDER BY id DESC LIMIT 1' + ) + + if (result.rows.length === 0) { + const tablesToReset = [ + 'consentEvents', + 'rateLimitWindows', + 'dsarRequests', + 'newsletterConfirmations', + ] + + for (const table of tablesToReset) { + await client.execute(`DROP TABLE IF EXISTS "${table}"`) + } + + const snapshot = JSON.stringify({ version: MIGRATION_VERSION, schema: {} }) + + await client.execute({ + sql: 'INSERT INTO _astro_db_snapshot (version, snapshot) VALUES (?, ?)', + args: [MIGRATION_VERSION, snapshot], + }) + } + } finally { + await client.close() + } +} + +const runAstroDb = async (args: string[], env: NodeJS.ProcessEnv) => { + await runCommand('npx', ['astro', 'db', ...args], env) +} + export const prepareDatabase = async () => { - await mkdir(testCacheDir, { recursive: true }) + await mkdir(devDatabaseDir, { recursive: true }) + // The dev server and Playwright share the same DB file; do not delete it here. + const databaseUrl = `file:${testDatabaseFile}` const env = { ...process.env, ASTRO_DATABASE_FILE: testDatabaseFile, + ASTRO_DB_REMOTE_URL: databaseUrl, } - await runCommand('npx', ['astro', 'db', 'reset', '--force', '--seed'], env) + await ensureSnapshotTableHasRow(databaseUrl, process.env['ASTRO_DB_APP_TOKEN']) + await runAstroDb(['push'], env) + await runAstroDb(['execute', 'db/seed.ts'], env) } diff --git a/test/e2e/config/runtime/mockState.ts b/test/e2e/config/runtime/mockState.ts new file mode 100644 index 000000000..bf312a8a5 --- /dev/null +++ b/test/e2e/config/runtime/mockState.ts @@ -0,0 +1,42 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { buildWiremockBaseUrl } from '../../helpers/wiremockConfig' + +type WiremockService = 'convertkit' | 'resend' + +interface WiremockState { + services: Record + generatedAt: string +} + +const getStateFilePath = () => { + const override = process.env['E2E_WIREMOCK_STATE_PATH'] + if (override) { + return override + } + return path.join(process.cwd(), '.cache', 'wiremock-state.json') +} + +const buildStatePayload = (): WiremockState => { + const services: WiremockState['services'] = { + convertkit: { baseUrl: buildWiremockBaseUrl('convertkit') }, + resend: { baseUrl: buildWiremockBaseUrl('resend') }, + } + + return { + services, + generatedAt: new Date().toISOString(), + } +} + +export const writeWiremockState = async () => { + const stateFile = getStateFilePath() + await mkdir(path.dirname(stateFile), { recursive: true }) + const payload = buildStatePayload() + await writeFile(stateFile, JSON.stringify(payload, null, 2), 'utf8') +} + +export const deleteWiremockState = async () => { + const stateFile = getStateFilePath() + await rm(stateFile, { force: true }) +} diff --git a/test/e2e/config/runtime/paths.ts b/test/e2e/config/runtime/paths.ts index d4f7b6e54..4d0d199ae 100644 --- a/test/e2e/config/runtime/paths.ts +++ b/test/e2e/config/runtime/paths.ts @@ -2,7 +2,7 @@ import path from 'node:path' const repoRoot = path.resolve(process.cwd()) -export const testCacheDir = path.join(repoRoot, '.cache', 'astro-db') -export const testDatabaseFile = path.join(testCacheDir, 'e2e.db') +export const devDatabaseDir = path.join(repoRoot, '.astro') +export const testDatabaseFile = path.join(repoRoot, '.astro', 'content.db') export const containersEnvFile = path.join(repoRoot, 'test', 'containers', '.env') export const wiremockComposeFile = path.join(repoRoot, 'test', 'containers', 'docker-compose.e2e.yml') diff --git a/test/e2e/config/runtime/seedVerification.ts b/test/e2e/config/runtime/seedVerification.ts new file mode 100644 index 000000000..5e72f2e61 --- /dev/null +++ b/test/e2e/config/runtime/seedVerification.ts @@ -0,0 +1,64 @@ +import { access } from 'node:fs/promises' +import { constants } from 'node:fs' +import { getLibsqlClient } from '../../db/libsqlClient' +import { testDatabaseFile } from './paths' + +const seededConsentSubjectIds = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', +] + +const ensureDatabaseFileExists = async () => { + try { + await access(testDatabaseFile, constants.F_OK) + } catch (error) { + throw new Error( + `Astro DB seed verification failed: expected database file at ${testDatabaseFile} is missing.`, + { cause: error instanceof Error ? error : undefined }, + ) + } +} + +const ensureSeededTablesExist = async () => { + const libsql = getLibsqlClient() + const { rows } = await libsql.execute({ + sql: `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('consentEvents', 'rateLimitWindows', 'newsletterConfirmations')`, + }) + + const tables = new Set(rows.map((row) => String(row['name']))) + const missing = ['consentEvents', 'rateLimitWindows', 'newsletterConfirmations'].filter((table) => !tables.has(table)) + + if (missing.length) { + throw new Error( + `Astro DB seed verification failed: missing tables ${missing.join(', ')}. Did migrations run?`, + ) + } +} + +const ensureSeededConsentRecordsExist = async () => { + const libsql = getLibsqlClient() + const missingSubjectIds: string[] = [] + + for (const subjectId of seededConsentSubjectIds) { + const { rows } = await libsql.execute({ + sql: `SELECT dataSubjectId FROM consentEvents WHERE dataSubjectId = ? LIMIT 1`, + args: [subjectId], + }) + + if (!rows.length) { + missingSubjectIds.push(subjectId) + } + } + + if (missingSubjectIds.length) { + throw new Error( + `Astro DB seed verification failed: missing consent records for subjects ${missingSubjectIds.join(', ')}. Rebuild the local snapshot with \`ASTRO_DB_REMOTE_URL="file:./.astro/content.db" npx astro db push\` followed by \`npx astro db execute db/seed.ts\`.`, + ) + } +} + +export const verifyDatabaseSeed = async () => { + await ensureDatabaseFileExists() + await ensureSeededTablesExist() + await ensureSeededConsentRecordsExist() +} diff --git a/test/e2e/db/helpers.ts b/test/e2e/db/helpers.ts index 0cd22aa9a..e426eeca4 100644 --- a/test/e2e/db/helpers.ts +++ b/test/e2e/db/helpers.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' -import { libsql } from './libsqlClient' +import { getLibsqlClient } from './libsqlClient' const toIso = (value: Date | number | string): string => { const date = value instanceof Date ? value : new Date(value) @@ -35,6 +35,7 @@ export const waitForConsentRecord = async ( expectedPurposes: string[], timeoutMs = 7000, ): Promise => { + const libsql = getLibsqlClient() const deadline = Date.now() + timeoutMs let lastError: string | null = null @@ -69,11 +70,16 @@ export const waitForConsentRecord = async ( } export const markNewsletterTokenExpired = async (token: string, expiredAt?: Date) => { + const libsql = getLibsqlClient() const timestamp = toIso(expiredAt ?? new Date(Date.now() - 60 * 60 * 1000)) - await libsql.execute({ + const result = await libsql.execute({ sql: `UPDATE newsletterConfirmations SET expiresAt = ? WHERE token = ?`, args: [timestamp, token], }) + + if (!result.rowsAffected) { + throw new Error(`Unable to mark newsletter token ${token} as expired; no matching rows found`) + } } export interface NewsletterConfirmationSeed { @@ -88,6 +94,7 @@ export interface NewsletterConfirmationSeed { export const insertNewsletterConfirmation = async ( seed: NewsletterConfirmationSeed, ): Promise<{ id: string; token: string; email: string }> => { + const libsql = getLibsqlClient() const id = randomUUID() const token = seed.token ?? `cron-newsletter-${randomUUID()}` const email = seed.email ?? `cron-newsletter-${Date.now()}@example.com` @@ -125,6 +132,7 @@ export interface DsarRequestSeed { export const insertDsarRequest = async ( seed: DsarRequestSeed, ): Promise<{ id: string; token: string }> => { + const libsql = getLibsqlClient() const id = randomUUID() const token = seed.token ?? `cron-dsar-${randomUUID()}` const email = seed.email ?? `cron-dsar-${Date.now()}@example.com` @@ -150,6 +158,7 @@ export const insertDsarRequest = async ( } export const findNewsletterConfirmationById = async (id: string) => { + const libsql = getLibsqlClient() const result = await libsql.execute({ sql: `SELECT id FROM newsletterConfirmations WHERE id = ? LIMIT 1`, args: [id], @@ -158,6 +167,7 @@ export const findNewsletterConfirmationById = async (id: string) => { } export const findDsarRequestById = async (id: string) => { + const libsql = getLibsqlClient() const result = await libsql.execute({ sql: `SELECT id FROM dsarRequests WHERE id = ? LIMIT 1`, args: [id], @@ -166,6 +176,7 @@ export const findDsarRequestById = async (id: string) => { } export const deleteNewsletterConfirmationById = async (id: string) => { + const libsql = getLibsqlClient() await libsql.execute({ sql: `DELETE FROM newsletterConfirmations WHERE id = ?`, args: [id], @@ -173,6 +184,7 @@ export const deleteNewsletterConfirmationById = async (id: string) => { } export const deleteDsarRequestById = async (id: string) => { + const libsql = getLibsqlClient() await libsql.execute({ sql: `DELETE FROM dsarRequests WHERE id = ?`, args: [id], @@ -180,6 +192,7 @@ export const deleteDsarRequestById = async (id: string) => { } export const deleteConsentRecordsBySubjectId = async (dataSubjectId: string) => { + const libsql = getLibsqlClient() await libsql.execute({ sql: `DELETE FROM consentEvents WHERE dataSubjectId = ?`, args: [dataSubjectId], diff --git a/test/e2e/db/libsqlClient.ts b/test/e2e/db/libsqlClient.ts index fba1368bf..2550f1e77 100644 --- a/test/e2e/db/libsqlClient.ts +++ b/test/e2e/db/libsqlClient.ts @@ -1,15 +1,13 @@ import { createClient } from '@libsql/client' import path from 'node:path' -import { env } from 'node:process' const resolveDatabaseUrl = () => { - const file = env['ASTRO_DATABASE_FILE'] ?? path.join(process.cwd(), 'db', 'dev.db') - if (file.startsWith('file:')) { - return file - } - return `file:${path.resolve(file)}` + const databaseFile = process.env['ASTRO_DATABASE_FILE'] ?? path.join(process.cwd(), '.astro', 'content.db') + const absolutePath = databaseFile.startsWith('file:') ? databaseFile : `file:${path.resolve(databaseFile)}` + return absolutePath } -export const libsql = createClient({ - url: resolveDatabaseUrl(), -}) +export const getLibsqlClient = () => { + const url = resolveDatabaseUrl() + return createClient({ url }) +} diff --git a/test/e2e/helpers/cronHealth.ts b/test/e2e/helpers/cronHealth.ts index 0146e5ad4..6f9981f4f 100644 --- a/test/e2e/helpers/cronHealth.ts +++ b/test/e2e/helpers/cronHealth.ts @@ -1,5 +1,5 @@ import { setTimeout as delay } from 'node:timers/promises' -import { libsql } from '@test/e2e/db/libsqlClient' +import { getLibsqlClient } from '@test/e2e/db/libsqlClient' interface RetryOptions { retries?: number @@ -14,6 +14,7 @@ const DEFAULT_OPTIONS: Required = { export async function ensureCronDependenciesHealthy(options?: RetryOptions): Promise { const { retries, delayMs } = { ...DEFAULT_OPTIONS, ...options } let lastError: unknown + const libsql = getLibsqlClient() for (let attempt = 1; attempt <= retries; attempt += 1) { try { From fd9f25b07e1f93364adfae9fe8db7b2e632af3a1 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 12 Dec 2025 03:57:02 +0300 Subject: [PATCH 10/22] Update selector logic to avoid timeout in navigation mobile e2e test --- test/e2e/config/runtime/database.ts | 2 +- .../04-components/navigation-mobile.spec.ts | 55 +++++++------------ 2 files changed, 21 insertions(+), 36 deletions(-) diff --git a/test/e2e/config/runtime/database.ts b/test/e2e/config/runtime/database.ts index da1722707..bf67601ee 100644 --- a/test/e2e/config/runtime/database.ts +++ b/test/e2e/config/runtime/database.ts @@ -67,7 +67,7 @@ const ensureSnapshotTableHasRow = async (databaseUrl: string, authToken?: string }) } } finally { - await client.close() + client.close() } } diff --git a/test/e2e/specs/04-components/navigation-mobile.spec.ts b/test/e2e/specs/04-components/navigation-mobile.spec.ts index 969177aad..dec92beaa 100644 --- a/test/e2e/specs/04-components/navigation-mobile.spec.ts +++ b/test/e2e/specs/04-components/navigation-mobile.spec.ts @@ -253,20 +253,17 @@ test.describe('Mobile Navigation', () => { await setupTestPage(playwrightPage, '/') - // Open mobile menu first - await page.click('button[aria-label="toggle menu"]') + await page.openMobileMenu({ timeout: 15000 }) - // Wait for mobile menu animation with real timers for WebKit compatibility - await playwrightPage.waitForTimeout(600) // Wait for 550ms animation + buffer + const aboutLink = playwrightPage.locator('site-navigation a[href="/about"]').first() + await expect(aboutLink).toBeVisible({ timeout: 10000 }) // Focus on a navigation link and press Enter - await playwrightPage.locator('nav#main-nav a[href="/about"]').focus() + await aboutLink.focus() await playwrightPage.keyboard.press('Enter') - // Wait for navigation to complete and check for About page content instead of URL - await playwrightPage.waitForSelector('text=Building Developer-First Infrastructure', { timeout: 5000 }) - - // Verify we're on the About page by checking breadcrumbs (using proper Playwright selector) + // Wait for the view-transition navigation to finish and verify content + await page.waitForPageLoad({ requireNext: true, timeout: 15000 }) await expect(playwrightPage.locator('nav[aria-label="Breadcrumb"]').getByText('About')).toBeVisible() }) @@ -288,30 +285,25 @@ test.describe('Mobile Navigation', () => { const page = await BasePage.init(playwrightPage) await page.setViewport(375, 667) - // Install fake timers before going to the page - await playwrightPage.clock.install() await setupTestPage(playwrightPage, '/') - // Open menu - await page.click('button[aria-label="toggle menu"]') - - // Use clock manipulation to speed through animations - await waitForMobileMenuAnimation(playwrightPage) + await page.openMobileMenu({ timeout: 15000 }) - const hasExpandedClassBefore = await playwrightPage.locator('#header').evaluate((el) => { - return el.classList.contains('aria-expanded-true') - }) - expect(hasExpandedClassBefore).toBe(true) + const headerExpandedBefore = await playwrightPage.locator('#header').evaluate((el) => + el.classList.contains('aria-expanded-true') + ) + expect(headerExpandedBefore).toBe(true) - // Navigate to another page - await page.click('nav#main-nav a[href="/about"]') - await page.waitForLoadState('networkidle') + // Navigate via View Transitions and wait for completion + await page.navigateToPage('/about') + await page.waitForPageLoad({ requireNext: true, timeout: 15000 }) // Menu should be closed after navigation - const hasExpandedClassAfter = await playwrightPage.locator('#header').evaluate((el) => { - return el.classList.contains('aria-expanded-true') - }) - expect(hasExpandedClassAfter).toBe(false) + await playwrightPage.waitForFunction( + () => !(document.getElementById('header')?.classList.contains('aria-expanded-true') ?? false), + undefined, + { timeout: 5000 } + ) }) test('@ready mobile menu has backdrop overlay', async ({ page: playwrightPage }) => { @@ -461,16 +453,9 @@ test.describe('Mobile Navigation', () => { test('@ready focus is trapped in open mobile menu', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) await page.setViewport(375, 667) - - // Install fake timers before going to the page - await playwrightPage.clock.install() await setupTestPage(playwrightPage, '/') - // Open menu - await page.click('button[aria-label="toggle menu"]') - - // Use clock manipulation to speed through animations - await waitForMobileMenuAnimation(playwrightPage) + await page.openMobileMenu({ timeout: 15000 }) // Tab to first navigation link await playwrightPage.keyboard.press('Tab') From 546e10aa07ea4be5050f4a15f829c3402b4c3506 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 12 Dec 2025 04:00:01 +0300 Subject: [PATCH 11/22] Lengthen timeout to 2.5s in seo tags e2e test --- test/e2e/specs/05-metadata/seo-tags.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/specs/05-metadata/seo-tags.spec.ts b/test/e2e/specs/05-metadata/seo-tags.spec.ts index 7b52f48e9..809b1612c 100644 --- a/test/e2e/specs/05-metadata/seo-tags.spec.ts +++ b/test/e2e/specs/05-metadata/seo-tags.spec.ts @@ -9,10 +9,11 @@ import { BasePage, test, expect } from '@test/e2e/helpers' test.describe('SEO Meta Tags', () => { test('@ready all pages have meta description', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) + test.slow() const pages = ['/', '/about', '/services', '/case-studies', '/contact'] for (const url of pages) { - await page.goto(url) + await page.goto(url, { timeout: 25000 }) await page.expectAttribute('meta[name="description"]', 'content') const content = await page.getAttribute('meta[name="description"]', 'content') From b23df916a633f4ac59a864a3b08f8a159294c3f9 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 12 Dec 2025 21:12:30 +0300 Subject: [PATCH 12/22] Lengthen timeout to 3.5s in theme picker e2e test --- _TODO.md | 67 +++---------------- test/e2e/helpers/cookieHelper.ts | 3 +- .../specs/04-components/theme-picker.spec.ts | 30 ++++----- 3 files changed, 26 insertions(+), 74 deletions(-) diff --git a/_TODO.md b/_TODO.md index c3c3cbeeb..fc8f49bb5 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,69 +1,24 @@ # TODO -## Astro DB +## Refactor API Endpoints to Astro Actions -ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ -ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \ -npx astro db push +### Action / Domain / Responder Pattern -ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ -ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \ -npx astro db execute db/seed.ts +- The action takes HTTP requests (URLs and their methods) and uses that input to interact with the domain, after which it passes the domain's output to one and only one responder. -`--force-reset` still runs a metadata query against the existing SQLite file. Your content.db already has tables (and likely lacks the drizzle/Astro metadata tables). When the reset path tries to map results, it gets undefined/null (drizzle bug on files with existing user tables but missing its own schema), hence Cannot convert undefined or null to object. +- The domain can modify state, interacting with storage and/or manipulating data as needed. It contains the business logic. -Practical reset path: +- The responder builds the entire HTTP response from the domain's output which is given to it by the action. -Stop the dev server (to release the file). -Backup/remove the current DB: +**Here you define the route and the methods (get, post, put, delete)** -`mv .astro/content.db .astro/content.db.bak` -(or `rm .astro/content.db` if you don't need it) +`/actions` or `actions.ts` -Run a clean push: +`/domain` and `/entities` or `entities.ts` -`ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \npx astro db push` +`/responders` or `responders.ts` -Seed if needed: - -`ASTRO_DATABASE_FILE="file:${PWD}/.astro/content.db" \ASTRO_DB_REMOTE_URL="file:${PWD}/.astro/content.db" \npx astro db execute db/seed.ts` - -If you prefer to keep the dev DB intact, use a different filename for the reset (e.g., content.e2e.db) and point both env vars to that. - -### Remote vs. local databases - -To connect to a hosted remote database, use the --remote flag. This flag enables both readable and writable access to your remote database, allowing you to accept and persist user data in production environments. - -ASTRO_DATABASE_FILE must be set in the environment the "astro build" command is called from for it to take effect. When it is set, the Astro framework will ignore the "--remote" flag if it is also set, and use the local database file passed to the ASTRO_DATABASE_FILE environmental variable. - -**Q:** Should the value of `ASTRO_DATABASE_FILE` be prefixed with a URL schema, like `file:./.astro/content.db`? - -**A:** Optional. Astro will resolve a relative path, and add the transport protocol, if necessary. - -**Q:** Does the value of `ASTRO_DB_REMOTE_URL` matter when `ASTRO_DATABASE_FILE` is set in the environment? - -**A:** Remote database settings are only read when the integration runs in "remote" mode i.e. the `--remote` flag is present or `ASTRO_INTERNAL_TEST_REMOTE` is truthy. Once you set `ASTRO_DATABASE_FILE `and run without `--remote`, `ASTRO_DB_REMOTE_URL` has no effect. - -**Q:** Is the value of `ASTRO_DB_APP_TOKEN` relevant when using a local database file? - -**A:** Also only read in the remote mode. When you point Astro at a local SQLite file via `ASTRO_DATABASE_FILE`, the token is unused. - -**Q:** Do both `astro build` and `astro dev` use the same mechanism, or work the same way, with respect to the above? - -**A:** Both commands share the same integration. During astro:config:setup, Astro decides once whether it's in remote mode and wires either the remote or local Vite plugin. - -**Q:** Does the Astro framework create a client for every DB call? Does it create it once on the first access to the DB API, and then reuse the same client for the rest of the execution context? - -**A:** During normal request handling, the framework initializes the client once per worker on first access and reuses it for all subsequent DB calls. When you import anything from `astro:db`, Vite loads the virtual module generated by `vite-plugin-db.js`. The exported `db` token is the return of the `@libsql/client` `createClient` invocation. - -When neither `ASTRO_DATABASE_FILE` nor `--remote` is provided, the integration falls back to the constant `DB_PATH` defined in `consts.js`, which is `.astro/content.db`. - -libSQL supports both HTTP and WebSockets as the transport protocol for a remote server. It also supports using a local file or an in-memory DB. Those can be configured using the following URL schemes in the connection URL: - -- `file`: will use a local file. The host is the path to the file (file:path/to/file.db). -- `libsql`: will use a remote server through the protocol preferred by the library (this might be different across versions). The host is the address of the server (libsql://your.server.io). -- `http:` will use a remote server through HTTP. `https:` can be used to enable a secure connection. The host is the same as for libsql:. ### Endpoints: @@ -104,10 +59,6 @@ Newsletter hits the gdpr consent endpoint using `recordConsent` in `src/pages/ap If we want to make it feel less inconsistent, we could either (a) rename `_logger` to something like `_consentClient` so its purpose is clearer, or (b) move to a microservices architecture and expose a protected `/api/gdpr/verify` endpoint and have newsletter call it over HTTP as well - but that would need additional auth to prevent abuse. -## Typing client-side API calls and SSR API endpoints - -Use Astro Actions - **Affected components:** - CallToAction/Newsletter diff --git a/test/e2e/helpers/cookieHelper.ts b/test/e2e/helpers/cookieHelper.ts index c1a0014fa..f5207de79 100644 --- a/test/e2e/helpers/cookieHelper.ts +++ b/test/e2e/helpers/cookieHelper.ts @@ -4,7 +4,7 @@ */ import type { Page } from '@playwright/test' import { expect } from '@test/e2e/helpers' -import { waitForAnimationFrames } from '@test/e2e/helpers/waitHelpers' +import { waitForAnimationFrames, waitForThemePickerReady } from '@test/e2e/helpers/waitHelpers' type ReloadStrategy = 'reload' | 'cacheBustingGoto' @@ -150,6 +150,7 @@ export async function selectTheme(page: Page, themeId: string): Promise { // Check if modal is already open (modal state now persists) const modal = page.locator('[data-theme-modal]') const toggleButton = getThemePickerToggle(page) + await waitForThemePickerReady(page) await modal.waitFor({ state: 'attached' }) await toggleButton.waitFor({ state: 'visible' }) const isOpen = await modal.evaluate((el) => !el.hasAttribute('hidden')) diff --git a/test/e2e/specs/04-components/theme-picker.spec.ts b/test/e2e/specs/04-components/theme-picker.spec.ts index 9bea5b2c0..253dad3cb 100644 --- a/test/e2e/specs/04-components/theme-picker.spec.ts +++ b/test/e2e/specs/04-components/theme-picker.spec.ts @@ -35,7 +35,7 @@ async function navigateWithMobileSupport(basePage: BasePage, href: string = getD // Trigger client-side navigation (Astro View Transitions) await basePage.navigateToPage(href) - await basePage.waitForPageLoad() + await basePage.waitForPageLoad({ requireNext: true, timeout: 15000 }) // Header components rehydrate asynchronously after View Transitions; make sure // the theme picker + navigation are ready before interacting again. await basePage.waitForHeaderComponents() @@ -69,8 +69,8 @@ test.describe('Theme Picker Component', () => { */ test.beforeEach(async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) - await setupCleanTestPage(page.page, '/') - await page.waitForHeaderComponents() + await setupCleanTestPage(page.page, '/', { reloadStrategy: 'cacheBustingGoto' }) + await page.waitForHeaderComponents({ timeout: 15000 }) }) test('@ready theme picker is visible', async ({ page: playwrightPage }) => { @@ -176,7 +176,7 @@ test.describe('Theme Picker Component', () => { */ test.beforeEach(async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) - await setupCleanTestPage(page.page, '/') + await setupCleanTestPage(page.page, '/', { reloadStrategy: 'cacheBustingGoto' }) await page.waitForHeaderComponents() }) @@ -188,8 +188,8 @@ test.describe('Theme Picker Component', () => { await selectTheme(page.page, 'dark') // Reload page - await page.reload({ waitUntil: 'domcontentloaded' }) - await page.waitForLoadState('networkidle') + await page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }) + await page.waitForHeaderComponents({ timeout: 15000 }) // Verify dark theme persisted const htmlElement = page.locator('html') @@ -259,8 +259,8 @@ test.describe('Theme Picker Component', () => { const page = await BasePage.init(playwrightPage) // Expected: User selection should override system preference await page.emulateMedia({ colorScheme: 'dark' }) - await page.goto('/') - await page.waitForLoadState('networkidle') + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await page.waitForHeaderComponents({ timeout: 15000 }) // Manually switch to light theme await selectTheme(page.page, 'light') @@ -271,8 +271,9 @@ test.describe('Theme Picker Component', () => { expect(dataTheme).toBe('light') // Verify it persists after reload - await page.reload({ waitUntil: 'domcontentloaded' }) - await page.waitForLoadState('networkidle') + const cacheBust = `/?_cb=${Date.now()}` + await page.goto(cacheBust, { waitUntil: 'domcontentloaded', timeout: 35000 }) + await page.waitForHeaderComponents({ timeout: 20000 }) const dataThemeAfterReload = await htmlElement.getAttribute('data-theme') expect(dataThemeAfterReload).toBe('light') @@ -300,8 +301,7 @@ test.describe('Theme Picker Component', () => { // Clear localStorage and dismiss cookie modal before each test await setupTestPage(page.page, '/') await page.evaluate(() => localStorage.clear()) - await page.reload() - await page.waitForHeaderComponents() + await page.waitForHeaderComponents({ timeout: 15000 }) }) test('theme picker button works after View Transition navigation', async ({ page: playwrightPage }) => { @@ -407,8 +407,7 @@ test.describe('Theme Picker Component', () => { const page = await BasePage.init(playwrightPage) await setupTestPage(page.page, '/') await page.evaluate(() => localStorage.clear()) - await page.reload() - await page.waitForHeaderComponents() + await page.waitForHeaderComponents({ timeout: 15000 }) }) test('preserves lang attribute across navigation', async ({ page: playwrightPage }) => { @@ -443,7 +442,8 @@ test.describe('Theme Picker Component', () => { expect(themeAttr).toBe('dark') // Navigate and verify both persist - await page.goto('/articles') + await page.navigateToPage('/articles') + await page.waitForHeaderComponents({ timeout: 15000 }) const langAttrAfter = await page.getAttribute('html', 'lang') const themeAttrAfter = await page.getAttribute('html', 'data-theme') From c863cbbeddeabcbd705eb0e21909e933fea78c21 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 01:16:26 +0300 Subject: [PATCH 13/22] Fix lint error in BasePage --- .cache/pages.json | 12 +++--------- _TODO.md | 14 +++++++++----- test/e2e/helpers/pageObjectModels/BasePage.ts | 8 ++++++-- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.cache/pages.json b/.cache/pages.json index a5936162f..5f4910f3c 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,16 +22,10 @@ "contact", "offline", { - "privacy": [ - "my-data" - ] + "privacy": ["my-data"] }, { - "services": [ - "consulting", - "overview", - "web-development" - ] + "services": ["consulting", "overview", "web-development"] }, { "tags": [ @@ -53,4 +47,4 @@ "typescript" ] } -] \ No newline at end of file +] diff --git a/_TODO.md b/_TODO.md index fc8f49bb5..bdafe3ffd 100644 --- a/_TODO.md +++ b/_TODO.md @@ -7,15 +7,19 @@ - The action takes HTTP requests (URLs and their methods) and uses that input to interact with the domain, after which it passes the domain's output to one and only one responder. -- The domain can modify state, interacting with storage and/or manipulating data as needed. It contains the business logic. - -- The responder builds the entire HTTP response from the domain's output which is given to it by the action. - **Here you define the route and the methods (get, post, put, delete)** `/actions` or `actions.ts` -`/domain` and `/entities` or `entities.ts` +- This layer contains the business logic and the persistence logic (e.g., using a repository pattern or similar data mappers). The domain services are responsible for reading from and writing to the database to fulfill business requirements. + +`/domain` + +- Entities are part of the domain. They represent state and core business rules, but not persistence logic. Defined primarily by its unique identity, rather than its attributes or properties. + +`/entities` or `entities.ts` + +- The responder builds the entire HTTP response from the domain's output which is given to it by the action. The Responder is responsible solely for formatting the final response (e.g., JSON, HTML) to be sent back to the client. `/responders` or `responders.ts` diff --git a/test/e2e/helpers/pageObjectModels/BasePage.ts b/test/e2e/helpers/pageObjectModels/BasePage.ts index 42829129b..0a3397c72 100644 --- a/test/e2e/helpers/pageObjectModels/BasePage.ts +++ b/test/e2e/helpers/pageObjectModels/BasePage.ts @@ -120,13 +120,17 @@ export class BasePage { * and async scripts may not have finished loading. * Automatically dismisses cookie consent modal unless skipCookieDismiss is true. */ - async goto(path: string, options?: { skipCookieDismiss?: boolean; timeout?: number }): Promise { + async goto( + path: string, + options?: { skipCookieDismiss?: boolean; timeout?: number; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' } + ): Promise { const requestedTimeout = options?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT + const waitUntil = options?.waitUntil ?? 'domcontentloaded' const navigate = async (timeout: number) => { return await this._page.goto(path, { timeout, - waitUntil: 'domcontentloaded', + waitUntil, }) } From 693c2d25117e8ecafbf8f45e513416d79b8ca2e6 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 01:35:43 +0300 Subject: [PATCH 14/22] Refactor privacy policy integration to use isomorphic-git from exec'ing git client directly --- package-lock.json | 601 ++++++++++++------ package.json | 1 + .../__tests__/index.spec.ts | 255 ++++---- .../PrivacyPolicyVersion/index.ts | 69 +- .../privacy-policy-version.spec.ts | 53 -- 5 files changed, 596 insertions(+), 383 deletions(-) delete mode 100644 test/unit/integrations/privacy-policy-version.spec.ts diff --git a/package-lock.json b/package-lock.json index 5c1aa411c..4998a7e73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,12 @@ "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.2.0", "@semantic-ui/astro-lit": "^5.1.1", - "@sentry/astro": "^10.29.0", - "@sentry/browser": "^10.29.0", + "@sentry/astro": "^10.30.0", + "@sentry/browser": "^10.30.0", "@shikijs/transformers": "^3.19.0", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "@types/hast": "^3.0.4", "@types/pubsub-js": "^1.8.6", "@vite-pwa/astro": "^1.2.0", @@ -43,6 +43,7 @@ "embla-carousel-autoplay": "^8.6.0", "focus-trap": "7.6.6", "gsap": "^3.14.1", + "isomorphic-git": "^1.36.0", "js-cookie": "^3.0.5", "jsdom": "^27.3.0", "libphonenumber-js": "1.12.31", @@ -61,7 +62,7 @@ "resend": "^6.6.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "title-case": "4.3.2", "to-ico": "1.1.5", "uuid": "^13.0.0", @@ -3180,7 +3181,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3202,7 +3202,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5135,7 +5134,6 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6272,64 +6270,64 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.29.0.tgz", - "integrity": "sha512-M3kycMY6f3KY9a8jDYac+yG0E3ZgWVWSxlOEC5MhYyX+g7mqxkwrb3LFQyuxSm/m+CCgMTCaPOOaB2twXP6EQg==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.30.0.tgz", + "integrity": "sha512-dVsHTUbvgaLNetWAQC6yJFnmgD0xUbVgCkmzNB7S28wIP570GcZ4cxFGPOkXbPx6dEBUfoOREeXzLqjJLtJPfg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.29.0" + "@sentry/core": "10.30.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.29.0.tgz", - "integrity": "sha512-Y7IRsNeS99cEONu1mZWZc3HvbjNnu59Hgymm0swFFKbdgbCgdT6l85kn2oLsuq4Ew8Dw/pL/Sgpwsl9UgYFpUg==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.30.0.tgz", + "integrity": "sha512-+bnQZ6SNF265nTXrRlXTmq5Ila1fRfraDOAahlOT/VM4j6zqCvNZzmeDD9J6IbxiAdhlp/YOkrG3zbr5vgYo0A==", "license": "MIT", "dependencies": { - "@sentry/core": "10.29.0" + "@sentry/core": "10.30.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.29.0.tgz", - "integrity": "sha512-45NVw9PwB9TQ8z+xJ6G6Za+wmQ1RTA35heBSzR6U4bknj8LmA04k2iwnobvxCBEQXeLfcJEO1vFgagMoqMZMBw==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.30.0.tgz", + "integrity": "sha512-Pj/fMIZQkXzIw6YWpxKWUE5+GXffKq6CgXwHszVB39al1wYz1gTIrTqJqt31IBLIihfCy8XxYddglR2EW0BVIQ==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.29.0", - "@sentry/core": "10.29.0" + "@sentry-internal/browser-utils": "10.30.0", + "@sentry/core": "10.30.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.29.0.tgz", - "integrity": "sha512-typY4JrpAQQGPuSyd/BD8+nNCbvTV2UVvKzr+iKgI0m1qc4Dz8tHZ4Nfais2Z8eYn/pL1kqVQN5ERTmJoYFdIw==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.30.0.tgz", + "integrity": "sha512-RIlIz+XQ4DUWaN60CjfmicJq2O2JRtDKM5lw0wB++M5ha0TBh6rv+Ojf6BDgiV3LOQ7lZvCM57xhmNUtrGmelg==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.29.0", - "@sentry/core": "10.29.0" + "@sentry-internal/replay": "10.30.0", + "@sentry/core": "10.30.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/astro": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry/astro/-/astro-10.29.0.tgz", - "integrity": "sha512-pqnPY7qXTcOqtFAmdwafKpRF31FYuobIJXAt7rfadO5QNFUZPjdrEdayax7IS6Sh+CvTp+UW0yY1WGcckVU+nQ==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry/astro/-/astro-10.30.0.tgz", + "integrity": "sha512-+8tqKECUv7pnRDhjEIW0q8MtDF+wdfzm32dtAunIDeMaQ1JTxcILpf0CwxiZBt6aAcsjHPY02IUO4hwZiNCNmQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.29.0", - "@sentry/core": "10.29.0", - "@sentry/node": "10.29.0", + "@sentry/browser": "10.30.0", + "@sentry/core": "10.30.0", + "@sentry/node": "10.30.0", "@sentry/vite-plugin": "^4.1.0" }, "engines": { @@ -6349,16 +6347,16 @@ } }, "node_modules/@sentry/browser": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.29.0.tgz", - "integrity": "sha512-XdbyIR6F4qoR9Z1JCWTgunVcTJjS9p2Th+v4wYs4ME+ZdLC4tuKKmRgYg3YdSIWCn1CBfIgdI6wqETSf7H6Njw==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.30.0.tgz", + "integrity": "sha512-7M/IJUMLo0iCMLNxDV/OHTPI0WKyluxhCcxXJn7nrCcolu8A1aq9R8XjKxm0oTCO8ht5pz8bhGXUnYJj4eoEBA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.29.0", - "@sentry-internal/feedback": "10.29.0", - "@sentry-internal/replay": "10.29.0", - "@sentry-internal/replay-canvas": "10.29.0", - "@sentry/core": "10.29.0" + "@sentry-internal/browser-utils": "10.30.0", + "@sentry-internal/feedback": "10.30.0", + "@sentry-internal/replay": "10.30.0", + "@sentry-internal/replay-canvas": "10.30.0", + "@sentry/core": "10.30.0" }, "engines": { "node": ">=18" @@ -6561,18 +6559,18 @@ } }, "node_modules/@sentry/core": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.29.0.tgz", - "integrity": "sha512-olQ2DU9dA/Bwsz3PtA9KNXRMqBWRQSkPw+MxwWEoU1K1qtiM9L0j6lbEFb5iSY3d7WYD5MB+1d5COugjSBrHtw==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.30.0.tgz", + "integrity": "sha512-IfNuqIoGVO9pwphwbOptAEJJI1SCAfewS5LBU1iL7hjPBHYAnE8tCVzyZN+pooEkQQ47Q4rGanaG1xY8mjTT1A==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.29.0.tgz", - "integrity": "sha512-9j8VzV06VCj+H8tlxpfa7BNN4HzH5exv68WOufdMTXzzWLOXnzrdNDoYplm1G2S3LMvWsc1SVI3a8A0yBY7oWg==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.30.0.tgz", + "integrity": "sha512-Ov++em+Y4H4gNRW9u3d9JDF46BNvnCNW4/jJ/6Dsw0T+Em9dyLXfqyDBEe8VKD0E7ZjuO+Z1W3ldpbhCj5HlSg==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -6605,9 +6603,9 @@ "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.19.0", - "@sentry/core": "10.29.0", - "@sentry/node-core": "10.29.0", - "@sentry/opentelemetry": "10.29.0", + "@sentry/core": "10.30.0", + "@sentry/node-core": "10.30.0", + "@sentry/opentelemetry": "10.30.0", "import-in-the-middle": "^2", "minimatch": "^9.0.0" }, @@ -6616,14 +6614,14 @@ } }, "node_modules/@sentry/node-core": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.29.0.tgz", - "integrity": "sha512-f/Y0okHhPPb5HnYNBqCivJ2YuXtSadvcIx16dzU5mHQxZhgGednUCPEX7rsvPcd4HneQz12HKLqxbAmNu+b3FA==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.30.0.tgz", + "integrity": "sha512-IDgCf0sTtHpnMfdM7nnqdkjFPzNrMKQUZCeoW2msAb+fXIfev2nae43fL4ffGL+S3rnkZp3OL8HDG/4C+Q0iZA==", "license": "MIT", "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.29.0", - "@sentry/opentelemetry": "10.29.0", + "@sentry/core": "10.30.0", + "@sentry/opentelemetry": "10.30.0", "import-in-the-middle": "^2" }, "engines": { @@ -6640,12 +6638,12 @@ } }, "node_modules/@sentry/opentelemetry": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.29.0.tgz", - "integrity": "sha512-5QvtAwS73HlI/+OTF1poAFELzsc0se+PHmMsXGGrOeNBvjCr3ZE8qvke09aeMn7uRImf3Nc9J6i2KtSHJnbKPA==", + "version": "10.30.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.30.0.tgz", + "integrity": "sha512-b4q868+L2uhqKn4xIlf+VLDthBLnUzG60FceJ2Oq8nD2Lk70F2ZxLfHA2eL1F6Oc776gnGd8Tmc1NM6RGRnp0g==", "license": "MIT", "dependencies": { - "@sentry/core": "10.29.0" + "@sentry/core": "10.30.0" }, "engines": { "node": ">=18" @@ -7449,9 +7447,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -7460,7 +7458,7 @@ "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/node/node_modules/magic-string": { @@ -7473,32 +7471,32 @@ } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], @@ -7512,9 +7510,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], @@ -7528,9 +7526,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], @@ -7544,9 +7542,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], @@ -7560,9 +7558,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], @@ -7576,9 +7574,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], @@ -7592,9 +7590,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], @@ -7608,9 +7606,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], @@ -7624,9 +7622,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], @@ -7640,9 +7638,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -7657,10 +7655,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", + "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, @@ -7668,64 +7666,10 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], @@ -7739,9 +7683,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], @@ -7767,14 +7711,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -7925,7 +7869,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -9378,6 +9321,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -10284,6 +10239,12 @@ "node": ">= 0.4" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/async-sema": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", @@ -10616,6 +10577,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-alloc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", @@ -10967,6 +10952,12 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "license": "Apache-2.0" + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -11262,6 +11253,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -11579,6 +11582,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -11778,6 +11796,12 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -13260,12 +13284,30 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -14787,11 +14829,30 @@ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -14878,6 +14939,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -15549,6 +15616,37 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-git": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.36.0.tgz", + "integrity": "sha512-22tU165ptowHYoDEwYJy5EKRzpHiuLMliaR01fH9ZwaUj1z/IqE++tGpjw/pD6eCWoxiOp6TPWX434aJ9zA4Lg==", + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -17763,6 +17861,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-document": { "version": "2.19.2", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", @@ -17812,6 +17922,24 @@ "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", "license": "MIT" }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, + "node_modules/minimisted/node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", @@ -18797,6 +18925,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", @@ -19350,6 +19487,22 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -20539,6 +20692,26 @@ "node": ">= 0.4" } }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -20732,6 +20905,51 @@ "kolorist": "^1.6.0" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21014,6 +21232,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -21876,9 +22103,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "license": "MIT", "peer": true }, @@ -22111,6 +22338,20 @@ "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", "license": "MIT" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-ico": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/to-ico/-/to-ico-1.1.5.tgz", diff --git a/package.json b/package.json index 0e399710b..ecb02496f 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "embla-carousel-autoplay": "^8.6.0", "focus-trap": "7.6.6", "gsap": "^3.14.1", + "isomorphic-git": "^1.36.0", "js-cookie": "^3.0.5", "jsdom": "^27.3.0", "libphonenumber-js": "1.12.31", diff --git a/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts b/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts index 7418ba24a..9035da9f9 100644 --- a/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts +++ b/src/integrations/PrivacyPolicyVersion/__tests__/index.spec.ts @@ -1,39 +1,39 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' +import { log as gitLog } from 'isomorphic-git' import { TestError } from '@test/errors' vi.mock('../../../lib/config/environmentServer', () => ({ getOptionalEnv: vi.fn(() => undefined), + isGitHub: vi.fn(() => false), + getGitHubRepoPath: vi.fn(() => '/github/workspace'), })) -// Mock child_process -vi.mock('node:child_process', () => ({ - execSync: vi.fn(), +vi.mock('isomorphic-git', () => ({ + log: vi.fn(), })) vi.mock('node:fs', () => ({ existsSync: vi.fn(() => true), })) -import { getOptionalEnv } from '../../../lib/config/environmentServer' +import { getOptionalEnv, isGitHub, getGitHubRepoPath } from '../../../lib/config/environmentServer' describe('PrivacyPolicyVersion Integration', () => { let consoleLogSpy: ReturnType let consoleWarnSpy: ReturnType beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.mocked(getOptionalEnv).mockReturnValue(undefined) vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(isGitHub).mockReturnValue(false) + vi.mocked(getGitHubRepoPath).mockReturnValue('/github/workspace') consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - // Reset mocks - vi.clearAllMocks() - - // Reset module cache to get fresh instance - vi.resetModules() }) afterEach(() => { @@ -43,72 +43,85 @@ describe('PrivacyPolicyVersion Integration', () => { vi.useRealTimers() }) - describe('getPrivacyPolicyVersionFromGit', () => { - it('should return git commit date in YYYY-MM-DD format', async () => { - // Mock successful git command - vi.mocked(execSync).mockReturnValue('2024-03-15\n') - - // Import after mocking to get fresh instance - const { privacyPolicyVersion } = await import('../index') + describe('resolvePrivacyPolicyVersion', () => { + it('returns PRIVACY_POLICY_VERSION when provided', async () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce('2024-01-02') - const mockUpdateConfig = vi.fn() - const integration = privacyPolicyVersion() + const { resolvePrivacyPolicyVersion } = await import('../index') - // Execute the astro:config:setup hook - await integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock of AstroIntegrationOptions - config: {}, - }) + const version = await resolvePrivacyPolicyVersion() - expect(execSync).toHaveBeenCalledWith( - expect.stringContaining('git log -1 --format=%cd --date=format:%Y-%m-%d'), - expect.objectContaining({ encoding: 'utf-8' }), - ) + expect(version).toBe('2024-01-02') + expect(gitLog).not.toHaveBeenCalled() + }) - expect(mockUpdateConfig).toHaveBeenCalledWith({ - vite: { - define: { - 'import.meta.env.PRIVACY_POLICY_VERSION': '"2024-03-15"', + it('falls back to git metadata when env var is absent', async () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) + vi.mocked(gitLog).mockResolvedValueOnce([ + { + oid: 'deadbeef', + commit: { + committer: { + timestamp: Math.floor(new Date('2023-05-05T12:00:00Z').getTime() / 1000), + timezoneOffset: 0, + }, }, }, - }) + ] as never) - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Privacy policy version set from git: 2024-03-15'), - ) + const { resolvePrivacyPolicyVersion } = await import('../index') + + const version = await resolvePrivacyPolicyVersion() + + expect(version).toBe('2023-05-05') }) - it('should trim whitespace from git output', async () => { - // Mock git command with extra whitespace - vi.mocked(execSync).mockReturnValue(' 2024-03-15 \n ') + it('falls back to the current date when git is unavailable', async () => { + vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) + vi.mocked(gitLog).mockRejectedValueOnce(new TestError('git missing') as never) + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-12-06T12:00:00Z')) + const { resolvePrivacyPolicyVersion } = await import('../index') + + const version = await resolvePrivacyPolicyVersion() + + expect(version).toBe('2025-12-06') + }) + }) + + describe('integration metadata', () => { + it('should have correct integration name', async () => { const { privacyPolicyVersion } = await import('../index') - const mockUpdateConfig = vi.fn() const integration = privacyPolicyVersion() - await integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock - config: {}, - }) + expect(integration.name).toBe('privacy-policy-version') + }) - expect(mockUpdateConfig).toHaveBeenCalledWith({ - vite: { - define: { - 'import.meta.env.PRIVACY_POLICY_VERSION': '"2024-03-15"', - }, - }, - }) + it('should have astro:config:setup hook', async () => { + const { privacyPolicyVersion } = await import('../index') + + const integration = privacyPolicyVersion() + + expect(integration.hooks).toHaveProperty('astro:config:setup') + expect(typeof integration.hooks['astro:config:setup']).toBe('function') }) + }) - it('logs warning and falls back when git command fails', async () => { - vi.mocked(execSync).mockImplementation(() => { - throw new TestError('Git command failed') - }) - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-12-06T00:00:00Z')) + describe('git lookup behavior', () => { + it('sets version from git log for the privacy policy file', async () => { + vi.mocked(gitLog).mockResolvedValueOnce([ + { + oid: 'deadbeef', + commit: { + committer: { + timestamp: Math.floor(new Date('2024-03-15T12:00:00Z').getTime() / 1000), + timezoneOffset: 0, + }, + }, + }, + ] as never) const { privacyPolicyVersion } = await import('../index') @@ -117,24 +130,41 @@ describe('PrivacyPolicyVersion Integration', () => { await integration.hooks['astro:config:setup']?.({ updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock + // @ts-expect-error - Partial mock of AstroIntegrationOptions config: {}, }) + expect(gitLog).toHaveBeenCalledWith( + expect.objectContaining({ + dir: process.cwd(), + filepath: 'src/pages/privacy/index.astro', + depth: 1, + }), + ) + expect(mockUpdateConfig).toHaveBeenCalledWith({ vite: { define: { - 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-12-06"', + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2024-03-15"', }, }, }) - expect(consoleWarnSpy).toHaveBeenCalled() }) - it('logs warning and falls back when git returns empty string', async () => { - vi.mocked(execSync).mockReturnValue('') - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-05-01T00:00:00Z')) + it('executes from GitHub workspace when running in GitHub Actions', async () => { + vi.mocked(isGitHub).mockReturnValue(true) + vi.mocked(getGitHubRepoPath).mockReturnValue('/github/workspace') + vi.mocked(gitLog).mockResolvedValueOnce([ + { + oid: 'deadbeef', + commit: { + committer: { + timestamp: Math.floor(new Date('2024-03-15T12:00:00Z').getTime() / 1000), + timezoneOffset: 0, + }, + }, + }, + ] as never) const { privacyPolicyVersion } = await import('../index') @@ -147,14 +177,12 @@ describe('PrivacyPolicyVersion Integration', () => { config: {}, }) - expect(mockUpdateConfig).toHaveBeenCalledWith({ - vite: { - define: { - 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-05-01"', - }, - }, - }) - expect(consoleWarnSpy).toHaveBeenCalled() + expect(gitLog).toHaveBeenCalledWith( + expect.objectContaining({ + dir: '/github/workspace', + filepath: 'src/pages/privacy/index.astro', + }), + ) }) it('skips git lookup entirely when repository metadata is missing', async () => { @@ -173,7 +201,7 @@ describe('PrivacyPolicyVersion Integration', () => { config: {}, }) - expect(execSync).not.toHaveBeenCalled() + expect(gitLog).not.toHaveBeenCalled() expect(consoleWarnSpy).toHaveBeenCalledWith( '[privacy-policy-version] Git metadata not found. Skipping git lookup.', ) @@ -185,30 +213,11 @@ describe('PrivacyPolicyVersion Integration', () => { }, }) }) - }) - - describe('integration metadata', () => { - it('should have correct integration name', async () => { - const { privacyPolicyVersion } = await import('../index') - - const integration = privacyPolicyVersion() - - expect(integration.name).toBe('privacy-policy-version') - }) - - it('should have astro:config:setup hook', async () => { - const { privacyPolicyVersion } = await import('../index') - const integration = privacyPolicyVersion() - - expect(integration.hooks).toHaveProperty('astro:config:setup') - expect(typeof integration.hooks['astro:config:setup']).toBe('function') - }) - }) - - describe('git command construction', () => { - it('should target the correct privacy policy file path', async () => { - vi.mocked(execSync).mockReturnValue('2024-03-15') + it('falls back when git returns no commits for the file', async () => { + vi.mocked(gitLog).mockResolvedValueOnce([] as never) + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-05-01T00:00:00Z')) const { privacyPolicyVersion } = await import('../index') @@ -221,34 +230,20 @@ describe('PrivacyPolicyVersion Integration', () => { config: {}, }) - expect(execSync).toHaveBeenCalledWith( - expect.stringContaining('src/pages/privacy/index.astro'), - expect.any(Object), - ) - }) - - it('should use correct git date format', async () => { - vi.mocked(execSync).mockReturnValue('2024-03-15') - - const { privacyPolicyVersion } = await import('../index') - - const mockUpdateConfig = vi.fn() - const integration = privacyPolicyVersion() - - await integration.hooks['astro:config:setup']?.({ - updateConfig: mockUpdateConfig, - // @ts-expect-error - Partial mock - config: {}, + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-05-01"', + }, + }, }) - - expect(execSync).toHaveBeenCalledWith( - expect.stringContaining('--date=format:%Y-%m-%d'), - expect.any(Object), - ) + expect(consoleWarnSpy).toHaveBeenCalled() }) - it('executes git commands from the project root directory', async () => { - vi.mocked(execSync).mockReturnValue('2024-03-15') + it('falls back when git log throws', async () => { + vi.mocked(gitLog).mockRejectedValueOnce(new TestError('Git log failed') as never) + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-12-06T00:00:00Z')) const { privacyPolicyVersion } = await import('../index') @@ -261,10 +256,14 @@ describe('PrivacyPolicyVersion Integration', () => { config: {}, }) - expect(execSync).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ cwd: process.cwd() }), - ) + expect(mockUpdateConfig).toHaveBeenCalledWith({ + vite: { + define: { + 'import.meta.env.PRIVACY_POLICY_VERSION': '"2025-12-06"', + }, + }, + }) + expect(consoleWarnSpy).toHaveBeenCalled() }) }) }) diff --git a/src/integrations/PrivacyPolicyVersion/index.ts b/src/integrations/PrivacyPolicyVersion/index.ts index c2cc661fe..2b6437301 100644 --- a/src/integrations/PrivacyPolicyVersion/index.ts +++ b/src/integrations/PrivacyPolicyVersion/index.ts @@ -14,39 +14,64 @@ * 3. Current date (if git is unavailable) */ -import { execSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' +import * as fs from 'node:fs' +import { join, posix } from 'node:path' import type { AstroIntegration } from 'astro' +import { log as gitLog } from 'isomorphic-git' import { getOptionalEnv, getGitHubRepoPath, isGitHub } from '../../lib/config/environmentServer' -const PROJECT_ROOT = isGitHub() ? getGitHubRepoPath() : process.cwd() -const PRIVACY_POLICY_PATH = join(PROJECT_ROOT, 'src', 'pages', 'privacy', 'index.astro') +const getProjectRoot = (): string => { + if (!isGitHub()) { + return process.cwd() + } + + try { + const repoPath = getGitHubRepoPath() + if (repoPath) { + return repoPath + } + } catch { + // ignore and fall back + } + + console.warn('[privacy-policy-version] GITHUB_WORKSPACE not set. Falling back to process.cwd().') + return process.cwd() +} + +const PROJECT_ROOT = getProjectRoot() +const PRIVACY_POLICY_FILEPATH = posix.join('src', 'pages', 'privacy', 'index.astro') const GIT_DIRECTORY_PATH = join(PROJECT_ROOT, '.git') export const toIsoDateString = (date: Date): string => date.toISOString().slice(0, 10) +const formatGitShortDate = (timestamp: number, timezoneOffset: number): string => { + const localSeconds = timestamp + timezoneOffset * 60 + const date = new Date(localSeconds * 1000) + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + /** * Get privacy policy version from git commit date * @param filePath - Path to privacy policy file (relative to project root) * @returns ISO date string (YYYY-MM-DD) of last commit */ -function getPrivacyPolicyVersionFromGit(filePath: string): string | null { +async function getPrivacyPolicyVersionFromGit(filepath: string): Promise { try { - // Get last commit date for privacy policy file in YYYY-MM-DD format - const lastCommitDate = execSync( - `git log -1 --format=%cd --date=format:%Y-%m-%d -- ${filePath}`, - { encoding: 'utf-8', cwd: PROJECT_ROOT }, - ).trim() - - if (lastCommitDate) { - return lastCommitDate + const entries = await gitLog({ fs, dir: PROJECT_ROOT, filepath, depth: 1 }) + + const latest = entries[0] + if (!latest) { + console.warn( + `[privacy-policy-version] No git commits found for privacy policy file: ${filepath}. Falling back to current date.`, + ) + return null } - console.warn( - `[privacy-policy-version] No git commits found for privacy policy file: ${filePath}. Falling back to current date.`, - ) - return null + const { timestamp, timezoneOffset } = latest.commit.committer + return formatGitShortDate(timestamp, timezoneOffset) } catch (error) { console.warn( `[privacy-policy-version] Could not get privacy policy version from git: ${error instanceof Error ? error.message : String(error)}`, @@ -63,7 +88,7 @@ function getPrivacyPolicyVersionFromGit(filePath: string): string | null { */ function hasGitRepository(): boolean { try { - return existsSync(GIT_DIRECTORY_PATH) + return fs.existsSync(GIT_DIRECTORY_PATH) } catch (error) { console.warn( `[privacy-policy-version] Unable to verify git repository: ${error instanceof Error ? error.message : String(error)}`, @@ -75,7 +100,7 @@ function hasGitRepository(): boolean { /** * Resolve privacy policy version using env, git metadata, or current date fallback. */ -export function resolvePrivacyPolicyVersion(): string { +export async function resolvePrivacyPolicyVersion(): Promise { const rawEnvVersion = getOptionalEnv('PRIVACY_POLICY_VERSION') const envVersion = typeof rawEnvVersion === 'string' ? rawEnvVersion.trim() : '' if (envVersion) { @@ -84,7 +109,7 @@ export function resolvePrivacyPolicyVersion(): string { } if (hasGitRepository()) { - const gitVersion = getPrivacyPolicyVersionFromGit(PRIVACY_POLICY_PATH) + const gitVersion = await getPrivacyPolicyVersionFromGit(PRIVACY_POLICY_FILEPATH) if (gitVersion) { console.log(`✅ Privacy policy version set from git: ${gitVersion}`) return gitVersion @@ -106,7 +131,7 @@ export function privacyPolicyVersion(): AstroIntegration { name: 'privacy-policy-version', hooks: { 'astro:config:setup': async ({ updateConfig }) => { - const version = resolvePrivacyPolicyVersion() + const version = await resolvePrivacyPolicyVersion() // Inject as Vite define so it's available as import.meta.env.PRIVACY_POLICY_VERSION updateConfig({ diff --git a/test/unit/integrations/privacy-policy-version.spec.ts b/test/unit/integrations/privacy-policy-version.spec.ts deleted file mode 100644 index a0dc915a4..000000000 --- a/test/unit/integrations/privacy-policy-version.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, afterEach, vi } from 'vitest' - -vi.mock('node:child_process', () => ({ - execSync: vi.fn(), -})) - -vi.mock('../../../src/lib/config/environmentServer', () => ({ - getOptionalEnv: vi.fn(), -})) - -import { execSync } from 'node:child_process' - -import { getOptionalEnv } from '../../../src/lib/config/environmentServer' -import { resolvePrivacyPolicyVersion } from '../../../src/integrations/PrivacyPolicyVersion/index' - -describe('resolvePrivacyPolicyVersion', () => { - afterEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - vi.useRealTimers() - }) - - it('returns PRIVACY_POLICY_VERSION when provided', () => { - vi.mocked(getOptionalEnv).mockReturnValueOnce('2024-01-02') - const version = resolvePrivacyPolicyVersion() - - expect(version).toBe('2024-01-02') - expect(execSync).not.toHaveBeenCalled() - }) - - it('falls back to git metadata when env var is absent', () => { - vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) - vi.mocked(execSync).mockReturnValue('2023-05-05\n' as never) - - const version = resolvePrivacyPolicyVersion() - - expect(version).toBe('2023-05-05') - }) - - it('falls back to the current date when git is unavailable', () => { - vi.mocked(getOptionalEnv).mockReturnValueOnce(undefined) - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-12-06T12:00:00Z')) - vi.mocked(execSync).mockImplementation(() => { - throw new Error('git missing') - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const version = resolvePrivacyPolicyVersion() - - expect(version).toBe('2025-12-06') - }) -}) From 7a4d52d603f6e345b4ee1826e543d5049de17094 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 01:49:29 +0300 Subject: [PATCH 15/22] Update runner unit test as we migrated to two separate CRON jobs from one consolidated CRON job (database ping has a 30 min frequency now) --- src/pages/api/cron/__tests__/runner.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts index 309c94210..c99e1f8a4 100644 --- a/src/pages/api/cron/__tests__/runner.spec.ts +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -64,11 +64,10 @@ describe('cron runner', () => { expect(warnSpy).toHaveBeenCalled() }) - it('calls downstream cron endpoints sequentially', async () => { + it('calls downstream cron endpoints', async () => { fetchMock .mockResolvedValueOnce(createResponse('/api/cron/cleanup-confirmations')) .mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests')) - .mockResolvedValueOnce(createResponse('/api/cron/ping-integrations')) const request = new Request('https://example.com/api/cron/run-all', { method: 'GET', @@ -82,8 +81,8 @@ describe('cron runner', () => { expect(response.status).toBe(200) expect(Array.isArray(body.results)).toBe(true) - expect(body.results).toHaveLength(3) - expect(fetchMock).toHaveBeenCalledTimes(3) + expect(body.results).toHaveLength(2) + expect(fetchMock).toHaveBeenCalledTimes(2) const headersUsed = fetchMock.mock.calls[0]?.[1]?.headers as Record expect(headersUsed['Authorization'] ?? headersUsed['authorization']).toBe('Bearer cron-secret') @@ -100,7 +99,6 @@ describe('cron runner', () => { }), ) .mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests')) - .mockResolvedValueOnce(createResponse('/api/cron/ping-integrations')) const request = new Request('https://example.com/api/cron/run-all', { method: 'GET', From 1f54691936587265170781e36297985bc6d888f7 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 02:28:30 +0300 Subject: [PATCH 16/22] Remove getResendMockBaseUrl test helper from api/contact and api/newsletter/email, and api environment file. It is only used for e2e tests and these routes will be refactored to Astro Actions. This quiets a CodeQL warning related to the environment routines --- src/pages/api/_environment/environmentApi.ts | 77 +-------- src/pages/api/contact/index.ts | 47 +----- src/pages/api/newsletter/_email.ts | 65 +------- test/e2e/db/helpers.ts | 37 +++++ test/e2e/specs/03-forms/contact-form.spec.ts | 35 +--- .../03-forms/newsletter-double-optin.spec.ts | 154 +----------------- test/e2e/specs/08-api/contact-api.spec.ts | 39 +---- test/e2e/specs/08-api/newsletter-api.spec.ts | 50 ++---- 8 files changed, 70 insertions(+), 434 deletions(-) diff --git a/src/pages/api/_environment/environmentApi.ts b/src/pages/api/_environment/environmentApi.ts index b2d3623ee..189c0b7f5 100644 --- a/src/pages/api/_environment/environmentApi.ts +++ b/src/pages/api/_environment/environmentApi.ts @@ -5,10 +5,8 @@ * routes to import it. Vercel exposes environment variables in Vercel serverless * functions with process.env. */ -import { readFileSync, statSync } from 'node:fs' -import path from 'node:path' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { isE2eTest, isUnitTest } from '@lib/config/environmentServer' +import { isUnitTest } from '@lib/config/environmentServer' export { isCI, isE2eTest, @@ -131,30 +129,6 @@ export function getResendApiKey(): string { return key } -/** - * Returns the base URL for the local Resend mock when running e2e tests. - * Falls back to localhost + RESEND_HTTP_PORT when RESEND_MOCK_URL is not provided. - */ -export function getResendMockBaseUrl(): string | null { - const explicit = process.env['RESEND_MOCK_URL'] - if (explicit) { - return explicit.replace(/\/$/, '') - } - - const stateBaseUrl = getWiremockServiceBaseUrl('resend') - if (stateBaseUrl) { - return stateBaseUrl - } - - if (!isE2eTest()) { - return null - } - - const host = process.env['WIREMOCK_HOST'] ?? '127.0.0.1' - const port = process.env['RESEND_HTTP_PORT'] ?? '9011' - return `http://${host}:${port}` -} - /** * Gets the Sentry DSN. This value is set in Vercel env vars and * made available to serverless functions by default. @@ -170,52 +144,3 @@ export function getSentryDsn(): string { } return key } - -type WiremockServiceName = 'convertkit' | 'resend' - -interface WiremockServiceState { - baseUrl?: string -} - -interface WiremockStateFile { - services?: Partial> -} - -const wiremockStateCache: { - mtimeMs: number - data: WiremockStateFile | null -} = { - mtimeMs: -1, - data: null, -} - -const resolveWiremockStatePath = (): string => { - const override = process.env['E2E_WIREMOCK_STATE_PATH'] - if (override?.trim()) { - return override - } - return path.join(process.cwd(), '.cache', 'wiremock-state.json') -} - -const loadWiremockState = (): WiremockStateFile | null => { - const stateFile = resolveWiremockStatePath() - try { - const stats = statSync(stateFile) - if (stats.mtimeMs !== wiremockStateCache.mtimeMs) { - const contents = readFileSync(stateFile, 'utf8') - wiremockStateCache.data = JSON.parse(contents) as WiremockStateFile - wiremockStateCache.mtimeMs = stats.mtimeMs - } - return wiremockStateCache.data - } catch { - wiremockStateCache.mtimeMs = -1 - wiremockStateCache.data = null - return null - } -} - -function getWiremockServiceBaseUrl(serviceName: WiremockServiceName): string | null { - const state = loadWiremockState() - const candidate = state?.services?.[serviceName]?.baseUrl - return candidate ? candidate.replace(/\/$/, '') : null -} diff --git a/src/pages/api/contact/index.ts b/src/pages/api/contact/index.ts index 0c31c855e..bf032a175 100644 --- a/src/pages/api/contact/index.ts +++ b/src/pages/api/contact/index.ts @@ -9,7 +9,7 @@ import { Resend } from 'resend' import { v4 as uuidv4, validate as uuidValidate } from 'uuid' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { getResendApiKey, getResendMockBaseUrl, isDev, isTest } from '@pages/api/_environment/environmentApi' +import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' import { checkContactRateLimit } from '@pages/api/_utils/rateLimit' import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' @@ -173,10 +173,9 @@ function formatFileSize(bytes: number): string { */ async function sendEmail( emailData: EmailData, - files: FileAttachment[], - resendMockBaseUrl: string | null + files: FileAttachment[] ): Promise { - if (!resendMockBaseUrl && (isTest() || isDev())) { + if (isTest() || isDev()) { return } @@ -205,42 +204,6 @@ async function sendEmail( }) } - if (resendMockBaseUrl) { - const mockAuthorizationHeader = (() => { - try { - return `Bearer ${getResendApiKey()}` - } catch { - return 'Bearer mock-resend-key' - } - })() - - try { - const response = await fetch(`${resendMockBaseUrl}/emails`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: mockAuthorizationHeader, - }, - body: JSON.stringify({ - ...resendPayload, - attachments: resendPayload.attachments?.map((attachment) => ({ - filename: attachment.filename, - content: (attachment.content as Buffer).toString('base64'), - })), - }), - }) - - if (!response.ok) { - const body = await response.text().catch(() => 'Unable to read mock response body') - throw new Error(`Resend mock responded with ${response.status}: ${body}`) - } - - return - } catch (error) { - handleSendError(error) - } - } - const resend = new Resend(getResendApiKey()) try { @@ -283,8 +246,6 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { 'unknown' try { - const resendMockBaseUrl = getResendMockBaseUrl() - const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) if (!checkContactRateLimit(rateLimitIdentifier)) { throw new ApiFunctionError({ @@ -455,7 +416,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { html: htmlContent, } - await sendEmail(emailData, files, resendMockBaseUrl) + await sendEmail(emailData, files) return new Response( JSON.stringify({ diff --git a/src/pages/api/newsletter/_email.ts b/src/pages/api/newsletter/_email.ts index 19a83cc5b..19512b3e0 100644 --- a/src/pages/api/newsletter/_email.ts +++ b/src/pages/api/newsletter/_email.ts @@ -4,7 +4,7 @@ */ import { Resend } from 'resend' -import { getResendApiKey, getResendMockBaseUrl, isDev, isTest } from '@pages/api/_environment/environmentApi' +import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' @@ -15,14 +15,6 @@ function getResendClient(): Resend { return new Resend(getResendApiKey()) } -function getMockAuthorizationHeader(): string { - try { - return `Bearer ${getResendApiKey()}` - } catch { - return 'Bearer mock-resend-key' - } -} - /** * Generate the HTML content for the confirmation email */ @@ -203,13 +195,12 @@ export async function sendConfirmationEmail( token: string, firstName?: string ): Promise { - const resendMockBaseUrl = getResendMockBaseUrl() const siteUrl = getSiteUrl() const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` const expiresIn = '24 hours' - // Skip actual email sending when no mock is available in dev/test - if (!resendMockBaseUrl && (isDev() || isTest())) { + // Skip actual email sending in dev/test (handled by Astro Actions later) + if (isDev() || isTest()) { console.log('[DEV/TEST MODE] Newsletter confirmation email would be sent:', { email, token }) return } @@ -237,29 +228,6 @@ export async function sendConfirmationEmail( }) } - if (resendMockBaseUrl) { - try { - const response = await fetch(`${resendMockBaseUrl}/emails`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: getMockAuthorizationHeader(), - }, - body: JSON.stringify(resendPayload), - }) - - if (!response.ok) { - const body = await response.text().catch(() => 'Unable to read mock response body') - throw new Error(`Resend mock responded with ${response.status}: ${body}`) - } - - console.log('[Newsletter Email] Confirmation sent to mock service:', { email }) - return - } catch (error) { - handleSendError(error) - } - } - const resend = getResendClient() try { @@ -296,9 +264,7 @@ export async function sendWelcomeEmail( email: string, firstName?: string ): Promise { - const resendMockBaseUrl = getResendMockBaseUrl() - - if (!resendMockBaseUrl && (isDev() || isTest())) { + if (isDev() || isTest()) { console.log('[DEV/TEST MODE] Newsletter welcome email would be sent:', { email }) return } @@ -444,29 +410,6 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com }) } - if (resendMockBaseUrl) { - try { - const response = await fetch(`${resendMockBaseUrl}/emails`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: getMockAuthorizationHeader(), - }, - body: JSON.stringify(resendPayload), - }) - - if (!response.ok) { - const body = await response.text().catch(() => 'Unable to read mock response body') - throw new Error(`Resend mock responded with ${response.status}: ${body}`) - } - - console.log('[Newsletter Email] Welcome sent to mock service:', { email }) - return - } catch (error) { - handleSendError(error) - } - } - try { const result = await resend.emails.send(resendPayload) diff --git a/test/e2e/db/helpers.ts b/test/e2e/db/helpers.ts index e426eeca4..cd377bf76 100644 --- a/test/e2e/db/helpers.ts +++ b/test/e2e/db/helpers.ts @@ -82,6 +82,43 @@ export const markNewsletterTokenExpired = async (token: string, expiredAt?: Date } } +export const findLatestNewsletterConfirmationTokenByEmail = async (email: string): Promise => { + const libsql = getLibsqlClient() + const result = await libsql.execute({ + sql: `SELECT token FROM newsletterConfirmations WHERE email = ? ORDER BY createdAt DESC LIMIT 1`, + args: [email], + }) + const row = result.rows[0] + if (!row) { + return null + } + const token = row['token'] + return token ? String(token) : null +} + +export const waitForLatestNewsletterConfirmationTokenByEmail = async ( + email: string, + timeoutMs = 7000, +): Promise => { + const deadline = Date.now() + timeoutMs + let lastError: string | null = null + + while (Date.now() <= deadline) { + try { + const token = await findLatestNewsletterConfirmationTokenByEmail(email) + if (token) { + return token + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + + await delay(250) + } + + throw new Error(lastError ?? `Timed out waiting for newsletter token for ${email}`) +} + export interface NewsletterConfirmationSeed { email?: string dataSubjectId?: string diff --git a/test/e2e/specs/03-forms/contact-form.spec.ts b/test/e2e/specs/03-forms/contact-form.spec.ts index c22c1df55..c6c45f2c1 100644 --- a/test/e2e/specs/03-forms/contact-form.spec.ts +++ b/test/e2e/specs/03-forms/contact-form.spec.ts @@ -9,7 +9,6 @@ import { BasePage, expect, test, - wiremock, spyOnFetchEndpoint, mockFetchEndpointResponse, } from '@test/e2e/helpers' @@ -17,7 +16,6 @@ import { TestError } from '@test/errors' import { TEST_CONTACT_DATA, TEST_EMAILS } from '@test/e2e/fixtures/test-data' const CONTACT_PATH = '/contact' -const RESEND_EMAIL_PATH = '/emails' const isContactApiResponse = (response: Response) => response.url().includes('/api/contact') @@ -120,7 +118,7 @@ test.describe('Contact Form', () => { test('@mocks contact form submits successfully when API is available', async ({ page: playwrightPage }) => { const page = await setupContactPage(playwrightPage) - const { email } = await fillContactFormWithValidData(page) + await fillContactFormWithValidData(page) const responsePromise = page.waitForResponse(isContactApiResponse) await page.click('#submitBtn') @@ -130,33 +128,11 @@ test.describe('Contact Form', () => { await expect(page.locator('#formMessages .message-success')).toBeVisible({ timeout: 5000 }) await expect(page.locator('#formMessages .message-error')).toBeHidden() - - const loggedRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [email, 'contact@webstackbuilders.com'], - }) - - if (!loggedRequest) { - throw new TestError('Resend mock did not capture the transactional email payload') - } - - const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { - to?: string | string[] - subject?: string - } - - if (Array.isArray(payload.to)) { - expect(payload.to).toContain('info@webstackbuilders.com') - } else { - expect(payload.to).toBe('info@webstackbuilders.com') - } - expect(payload.subject).toContain('Contact Form') }) test('@ready contact form surfaces API error responses to users', async ({ page: playwrightPage }) => { const page = await setupContactPage(playwrightPage) - const { email } = await fillContactFormWithValidData(page) + await fillContactFormWithValidData(page) const apiErrorOverride = await mockFetchEndpointResponse(playwrightPage, { endpoint: '/api/contact', @@ -173,13 +149,6 @@ test.describe('Contact Form', () => { await expect(page.locator('#formMessages .message-error')).toBeVisible({ timeout: 5000 }) await expect(page.locator('#errorMessage')).toContainText('Unable to reach contact API') - - const loggedRequests = await wiremock.resend.findRequests({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: email, - }) - expect(loggedRequests.length).toBe(0) } finally { await apiErrorOverride.restore() } diff --git a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts index 043986237..717abcc97 100644 --- a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts @@ -7,22 +7,11 @@ import { BasePage, test, expect, - wiremock, } from '@test/e2e/helpers' -import { TestError } from '@test/errors' -import { markNewsletterTokenExpired } from '@test/e2e/db' +import { markNewsletterTokenExpired, waitForLatestNewsletterConfirmationTokenByEmail } from '@test/e2e/db' const HOME_PATH = '/' const NEWSLETTER_ENDPOINT = '/api/newsletter' -const RESEND_EMAIL_PATH = '/emails' - -interface ResendEmailPayload { - to?: string | string[] - html?: string - text?: string - subject?: string - tags?: Array<{ name: string; value: string }> -} const waitForNewsletterForm = async (page: BasePage) => { await page.waitForSelector('#newsletter-email', { timeout: 5000 }) @@ -37,38 +26,16 @@ const fillNewsletterForm = async (page: BasePage, email: string) => { await page.check('#newsletter-gdpr-consent') } -const extractConfirmationLink = (payload: ResendEmailPayload): string => { - const confirmationPattern = /https?:\/\/[^\s"']+\/newsletter\/confirm\/[A-Za-z0-9_-]+/ - const linkMatch = payload.html?.match(confirmationPattern) ?? payload.text?.match(confirmationPattern) - if (!linkMatch?.[0]) { - throw new TestError('Confirmation email payload did not contain a confirmation link') - } - return linkMatch[0] -} - const createUniqueEmail = () => `newsletter-double-optin-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com` type NewsletterSubscriptionCapture = { email: string - payload: ResendEmailPayload - confirmationUrl: URL - confirmationLink: string localConfirmationPath: string token: string siteOrigin: string } -const resolveLocalPath = (url: URL): string => `${url.pathname}${url.search ?? ''}` - -const parseTokenFromUrl = (confirmationUrl: URL): string => { - const token = confirmationUrl.pathname.split('/').filter(Boolean).pop() - if (!token) { - throw new TestError('Unable to parse newsletter confirmation token from URL') - } - return token -} - const submitNewsletterSubscription = async ( page: BasePage, playwrightPage: Page, @@ -86,40 +53,14 @@ const submitNewsletterSubscription = async ( expect(response.status()).toBe(200) await expect(page.locator('#newsletter-message')).toContainText('confirm your subscription', { timeout: 5000 }) - const loggedRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [email, 'newsletter-confirmation', 'double-optin'], - }, { timeoutMs: 7000 }) - - if (!loggedRequest?.request.body) { - throw new TestError('Resend mock captured request without body payload') - } - - const payload = JSON.parse(loggedRequest.request.body) as ResendEmailPayload - - if (Array.isArray(payload.to)) { - expect(payload.to).toContain(email) - } else { - expect(payload.to).toBe(email) - } - - const confirmationLink = extractConfirmationLink(payload) - let confirmationUrl: URL - try { - confirmationUrl = new URL(confirmationLink) - } catch (error) { - throw new TestError(`Invalid confirmation link received: ${confirmationLink}`, { cause: error }) - } + const token = await waitForLatestNewsletterConfirmationTokenByEmail(email) + const siteOrigin = new URL(playwrightPage.url()).origin return { email, - payload, - confirmationUrl, - confirmationLink, - localConfirmationPath: resolveLocalPath(confirmationUrl), - token: parseTokenFromUrl(confirmationUrl), - siteOrigin: confirmationUrl.origin, + localConfirmationPath: `/newsletter/confirm/${token}`, + token, + siteOrigin, } } @@ -137,28 +78,9 @@ const markConfirmationTokenExpired = async (token: string): Promise => { await markNewsletterTokenExpired(token) } -const extractUnsubscribeLink = (html?: string): string => { - if (!html) { - throw new TestError('Confirmation email payload did not contain HTML content') - } - const anchorMatch = html.match(/]*data-testid="unsubscribe-link"[^>]*>/i) - if (!anchorMatch?.[0]) { - throw new TestError('Confirmation email payload did not include an unsubscribe link') - } - const hrefMatch = anchorMatch[0].match(/href="([^"]+)"/i) - if (!hrefMatch?.[1]) { - throw new TestError('Unsubscribe link did not include an href attribute') - } - return hrefMatch[1] -} - test.use({ serviceWorkers: 'block' }) test.describe('Newsletter Double Opt-In Flow', () => { - test.beforeAll(async () => { - await wiremock.resend.resetRequests() - }) - test('@mocks user can confirm newsletter subscription via email link', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) await page.goto(HOME_PATH) @@ -179,56 +101,6 @@ test.describe('Newsletter Double Opt-In Flow', () => { await expect(page.locator('#user-email')).toHaveText(subscription.email) }) - test('@mocks confirmation email contains correct content', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto(HOME_PATH) - - const { payload } = await submitNewsletterSubscription(page, playwrightPage) - - expect(payload.subject).toBe('Confirm your newsletter subscription - Webstack Builders') - expect(payload.html).toContain('Confirm Your Subscription') - expect(payload.html).toContain('data-testid="unsubscribe-link"') - expect(payload.text).toContain('Confirm your subscription by clicking this link') - expect(payload.text).toContain('Unsubscribe:') - expect(payload.tags).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'type', value: 'newsletter-confirmation' }), - expect.objectContaining({ name: 'flow', value: 'double-optin' }), - ]), - ) - }) - - test('@mocks welcome email sent after confirmation', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto(HOME_PATH) - - const subscription = await submitNewsletterSubscription(page, playwrightPage) - const confirmResponse = await confirmTokenViaApi(playwrightPage, subscription.token, subscription.siteOrigin) - expect(confirmResponse.status()).toBe(200) - - const confirmPayload = await confirmResponse.json() - expect(confirmPayload.success).toBe(true) - - const welcomeRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [subscription.email, 'newsletter-welcome', 'post-confirmation'], - }, { timeoutMs: 7000 }) - - if (!welcomeRequest?.request.body) { - throw new TestError('Resend mock did not capture welcome email payload') - } - - const welcomePayload = JSON.parse(welcomeRequest.request.body) as ResendEmailPayload - expect(welcomePayload.subject).toContain('Welcome') - expect(welcomePayload.tags).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'type', value: 'newsletter-welcome' }), - expect.objectContaining({ name: 'flow', value: 'post-confirmation' }), - ]), - ) - }) - test('@mocks confirmation link expires after time', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) await page.goto(HOME_PATH) @@ -256,18 +128,4 @@ test.describe('Newsletter Double Opt-In Flow', () => { await expect(page.locator('#expired-state')).toBeVisible({ timeout: 5000 }) await expect(page.locator('#success-state')).toHaveClass(/hidden/) }) - - test('@mocks unsubscribe link works', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto(HOME_PATH) - - const subscription = await submitNewsletterSubscription(page, playwrightPage) - const unsubscribeHref = extractUnsubscribeLink(subscription.payload.html) - const unsubscribeUrl = new URL(unsubscribeHref) - - const localPathWithHash = `${unsubscribeUrl.pathname}${unsubscribeUrl.search}${unsubscribeUrl.hash}` - await page.goto(localPathWithHash) - - await expect(page.locator('h1')).toContainText(/privacy/i) - }) }) diff --git a/test/e2e/specs/08-api/contact-api.spec.ts b/test/e2e/specs/08-api/contact-api.spec.ts index 0f4a84c6c..822a35c83 100644 --- a/test/e2e/specs/08-api/contact-api.spec.ts +++ b/test/e2e/specs/08-api/contact-api.spec.ts @@ -1,12 +1,9 @@ -import { expect, test, wiremock } from '@test/e2e/helpers' +import { expect, test } from '@test/e2e/helpers' const CONTACT_ENDPOINT = '/api/contact' -const RESEND_EMAIL_PATH = '/emails' test.describe('Contact API integrations', () => { - test.describe.configure({ mode: 'serial' }) - - test('@mocks delivers transactional email payload to Resend', async ({ request }) => { + test('@mocks accepts valid submissions', async ({ request }) => { const uniqueEmail = `contact-${Date.now()}@example.com` const response = await request.post(CONTACT_ENDPOINT, { data: { @@ -20,33 +17,9 @@ test.describe('Contact API integrations', () => { expect(response.status()).toBe(200) const body = await response.json() expect(body.success).toBe(true) - - const loggedRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [uniqueEmail, 'contact@webstackbuilders.com'], - }) - - if (!loggedRequest) { - throw new Error('Resend mock did not capture the transactional email payload') - } - - const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { - from: string - to: string | string[] - subject: string - } - - expect(payload.from).toContain('contact@webstackbuilders.com') - if (Array.isArray(payload.to)) { - expect(payload.to).toContain('info@webstackbuilders.com') - } else { - expect(payload.to).toBe('info@webstackbuilders.com') - } - expect(payload.subject).toContain('Integration Bot') }) - test('@mocks rejects invalid submissions before reaching Resend', async ({ request }) => { + test('@mocks rejects invalid submissions', async ({ request }) => { const invalidEmail = `invalid-contact-${Date.now()}@example.com` const response = await request.post(CONTACT_ENDPOINT, { data: { @@ -57,11 +30,5 @@ test.describe('Contact API integrations', () => { }) expect(response.status()).toBe(400) - const requests = await wiremock.resend.findRequests({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: invalidEmail, - }) - expect(requests.length).toBe(0) }) }) diff --git a/test/e2e/specs/08-api/newsletter-api.spec.ts b/test/e2e/specs/08-api/newsletter-api.spec.ts index 210fd8f9d..81b494dfa 100644 --- a/test/e2e/specs/08-api/newsletter-api.spec.ts +++ b/test/e2e/specs/08-api/newsletter-api.spec.ts @@ -1,12 +1,10 @@ -import { expect, test, wiremock } from '@test/e2e/helpers' +import { expect, test } from '@test/e2e/helpers' +import { findLatestNewsletterConfirmationTokenByEmail, waitForLatestNewsletterConfirmationTokenByEmail } from '@test/e2e/db' const NEWSLETTER_ENDPOINT = '/api/newsletter' -const RESEND_EMAIL_PATH = '/emails' test.describe('Newsletter API integrations', () => { - test.describe.configure({ mode: 'serial' }) - - test('@mocks sends double opt-in email through Resend mock', async ({ request }) => { + test('@mocks creates confirmation token for double opt-in', async ({ request }) => { const uniqueEmail = `newsletter-${Date.now()}@example.com` const response = await request.post(NEWSLETTER_ENDPOINT, { data: { @@ -19,35 +17,17 @@ test.describe('Newsletter API integrations', () => { const body = await response.json() expect(body.requiresConfirmation).toBe(true) - const loggedRequest = await wiremock.resend.expectRequest({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: [uniqueEmail, 'newsletter@webstackbuilders.com'], - }) - - if (!loggedRequest) { - throw new Error('Resend mock did not capture the newsletter double opt-in payload') - } + const token = await waitForLatestNewsletterConfirmationTokenByEmail(uniqueEmail) + expect(token).toBeTruthy() - const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { - from: string - to: string | string[] - html?: string - text?: string - } - - expect(payload.from).toContain('newsletter@webstackbuilders.com') - const confirmLink = payload.html?.match(/newsletter\/confirm\/([A-Za-z0-9_-]+)/) - expect(confirmLink?.[1]).toBeTruthy() - if (Array.isArray(payload.to)) { - expect(payload.to).toContain(uniqueEmail) - } else { - expect(payload.to).toBe(uniqueEmail) - } + const confirmResponse = await request.get(`/api/newsletter/confirm?token=${encodeURIComponent(token)}`) + expect(confirmResponse.status()).toBe(200) + const confirmBody = await confirmResponse.json() + expect(confirmBody.success).toBe(true) }) - test('@mocks requires consent before sending any emails', async ({ request }) => { - const emailWithoutConsent = 'noconsent@example.com' + test('@mocks requires consent before creating tokens', async ({ request }) => { + const emailWithoutConsent = `noconsent-${Date.now()}@example.com` const response = await request.post(NEWSLETTER_ENDPOINT, { data: { email: emailWithoutConsent, @@ -56,11 +36,7 @@ test.describe('Newsletter API integrations', () => { }) expect(response.status()).toBe(400) - const requests = await wiremock.resend.findRequests({ - method: 'POST', - urlPath: RESEND_EMAIL_PATH, - bodyIncludes: emailWithoutConsent, - }) - expect(requests.length).toBe(0) + const token = await findLatestNewsletterConfirmationTokenByEmail(emailWithoutConsent) + expect(token).toBe(null) }) }) From aacd0f348b5ed54001e1d61247e02021430ab238 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 02:58:24 +0300 Subject: [PATCH 17/22] Move database ping from Vercle CRON job to GitHub Action workflow, since free Vercel plan only allows once-a-day ping and the database will sleep if not pinged hourly --- .github/workflows/ping-turso.yml | 49 +++++++++ _TODO.md | 2 - src/pages/api/cron/__tests__/cleanup.spec.ts | 73 -------------- src/pages/api/cron/ping-integrations.ts | 100 ------------------- test/e2e/specs/15-cron/cron.spec.ts | 17 ---- vercel.json | 4 - 6 files changed, 49 insertions(+), 196 deletions(-) create mode 100644 .github/workflows/ping-turso.yml delete mode 100644 src/pages/api/cron/ping-integrations.ts diff --git a/.github/workflows/ping-turso.yml b/.github/workflows/ping-turso.yml new file mode 100644 index 000000000..787bfb4e4 --- /dev/null +++ b/.github/workflows/ping-turso.yml @@ -0,0 +1,49 @@ +name: Ping Turso + +on: + schedule: + - cron: '*/30 * * * *' + workflow_dispatch: + +concurrency: + group: ping-turso + cancel-in-progress: true + +jobs: + ping: + name: Ping Turso Production DB + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Execute keep-alive query + env: + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} + run: | + node --input-type=module -e "\ + import { createClient } from '@libsql/client';\ + const url = process.env.ASTRO_DB_REMOTE_URL;\ + const authToken = process.env.ASTRO_DB_APP_TOKEN;\ + if (!url || !authToken) {\ + throw new Error('Missing ASTRO_DB_REMOTE_URL or ASTRO_DB_APP_TOKEN');\ + }\ + const client = createClient({ url, authToken });\ + try {\ + await client.execute('SELECT 1');\ + console.log('[ping-turso] OK');\ + } finally {\ + client.close();\ + }\ + " diff --git a/_TODO.md b/_TODO.md index bdafe3ffd..ea77ea196 100644 --- a/_TODO.md +++ b/_TODO.md @@ -28,7 +28,6 @@ - cron/cleanup-confirmations → GET - cron/cleanup-dsar-requests → GET -- cron/ping-integrations → GET - cron/run-all → GET - social-card/ → GET @@ -48,7 +47,6 @@ - _utils/rateLimitStore.ts - cron/cleanup-confirmations.ts - cron/cleanup-dsar-requests.ts -- cron/ping-integrations.ts - gdpr/_utils/consentStore.ts - gdpr/_utils/dsarStore.ts - newsletter/_token.ts diff --git a/src/pages/api/cron/__tests__/cleanup.spec.ts b/src/pages/api/cron/__tests__/cleanup.spec.ts index b6b1f4b1a..6fbcf9ca3 100644 --- a/src/pages/api/cron/__tests__/cleanup.spec.ts +++ b/src/pages/api/cron/__tests__/cleanup.spec.ts @@ -5,16 +5,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { APIRoute } from 'astro' import { GET as cleanupDsar } from '@pages/api/cron/cleanup-dsar-requests' import { GET as cleanupConfirmations } from '@pages/api/cron/cleanup-confirmations' -import { GET as pingIntegrations } from '@pages/api/cron/ping-integrations' const dbDeleteMock = vi.hoisted(() => vi.fn()) -const dbSelectMock = vi.hoisted(() => vi.fn()) const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) vi.mock('astro:db', () => ({ db: { delete: dbDeleteMock, - select: dbSelectMock, }, dsarRequests: {}, newsletterConfirmations: {}, @@ -59,23 +56,6 @@ const createDeleteChain = (options?: { data?: unknown[]; error?: unknown }): Del } } -type SelectChain = { - from: ReturnType -} - -const createSelectChain = (options?: { data?: unknown[]; error?: unknown }): SelectChain => { - const limit = () => { - if (options?.error) { - return Promise.reject(options.error) - } - return Promise.resolve(options?.data ?? []) - } - - return { - from: vi.fn(() => ({ limit })), - } -} - describe('Cron cleanup endpoints', () => { let warnSpy: ReturnType let logSpy: ReturnType @@ -83,7 +63,6 @@ describe('Cron cleanup endpoints', () => { beforeEach(() => { vi.clearAllMocks() dbDeleteMock.mockReset() - dbSelectMock.mockReset() getCronSecretMock.mockClear() warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -203,56 +182,4 @@ describe('Cron cleanup endpoints', () => { }) }) - describe('ping-integrations', () => { - const run = (request: Request) => - pingIntegrations(buildContext(request) as unknown as Parameters[0]) - - const buildRequest = (headers?: HeadersInit) => { - const init: RequestInit = { method: 'GET' } - if (typeof headers !== 'undefined') { - init.headers = headers - } - return new Request('http://localhost/api/cron/ping-integrations', init) - } - - it('rejects requests without valid secret', async () => { - const response = await run(buildRequest()) - const body = await response.json() - - expect(response.status).toBe(401) - expect(body.error.code).toBe('UNAUTHORIZED') - }) - - it('pings Astro DB once authorized', async () => { - dbSelectMock.mockReturnValueOnce( - createSelectChain({ data: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }] }) - ) - - const response = await run( - buildRequest({ - authorization: 'Bearer cron-secret', - }), - ) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.success).toBe(true) - expect(typeof body.astroDb.durationMs).toBe('number') - expect(body.astroDb.rowsChecked).toBe(5) - }) - - it('surfaces Astro DB errors when ping fails', async () => { - dbSelectMock.mockReturnValueOnce(createSelectChain({ error: { message: 'boom' } })) - - const response = await run( - buildRequest({ - authorization: 'Bearer cron-secret', - }), - ) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error.code).toBe('CRON_ASTRO_DB_PING_FAILED') - }) - }) }) diff --git a/src/pages/api/cron/ping-integrations.ts b/src/pages/api/cron/ping-integrations.ts deleted file mode 100644 index 60e25f048..000000000 --- a/src/pages/api/cron/ping-integrations.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Cron API Route to ping third-party integrations to keep them awake. - * Currently pings Astro DB (newsletter confirmations table). - */ -import type { APIRoute } from 'astro' -import { db, newsletterConfirmations } from 'astro:db' -import { - getCronSecret, -} from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext } from '@pages/api/_utils/requestContext' - -export const prerender = false - -const ROUTE = '/api/cron/ping-integrations' - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage }) - -const pingAstroDb = async () => { - const start = Date.now() - try { - const data = await db - .select({ id: newsletterConfirmations.id }) - .from(newsletterConfirmations) - .limit(1) - - return { - rowsChecked: data.length, - durationMs: Date.now() - start, - } - } catch (error) { - throw new ApiFunctionError(error, { - message: 'Astro DB ping failed', - status: 500, - code: 'CRON_ASTRO_DB_PING_FAILED', - route: ROUTE, - operation: 'pingAstroDb', - }) - } -} - -export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { - const { context: apiContext } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - clientAddress, - cookies, - }) - - const authHeader = request.headers.get('authorization') - - if (authHeader !== `Bearer ${getCronSecret()}`) { - console.warn('Unauthorized cron attempt - invalid CRON_SECRET') - apiContext.extra = { - ...(apiContext.extra || {}), - authHeader: authHeader ? 'PRESENT' : 'MISSING', - clientAddress, - } - - return buildErrorResponse( - new ApiFunctionError({ - message: 'Unauthorized', - status: 401, - code: 'UNAUTHORIZED', - }), - apiContext, - 'Unauthorized cron access', - ) - } - - try { - const astroDbResult = await pingAstroDb() - - return new Response( - JSON.stringify({ - success: true, - astroDb: astroDbResult, - timestamp: new Date().toISOString(), - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - authHeader: 'REDACTED', - } - return buildErrorResponse(error, apiContext, 'Failed to ping backing services') - } -} diff --git a/test/e2e/specs/15-cron/cron.spec.ts b/test/e2e/specs/15-cron/cron.spec.ts index c6b8846f3..91b60f496 100644 --- a/test/e2e/specs/15-cron/cron.spec.ts +++ b/test/e2e/specs/15-cron/cron.spec.ts @@ -162,21 +162,4 @@ test.describe('Cron API endpoints @ready', () => { await expectDsarMissing(expiredPendingId) }) - test('@ready ping-integrations validates Astro DB availability', async ({ request }) => { - skipUnlessChromiumProject() - - const response = await request.get('/api/cron/ping-integrations', { - headers: { - authorization: cronAuthHeader!, - }, - }) - - expect(response.ok()).toBeTruthy() - const body = (await response.json()) as { - astroDb: { rowsChecked: number; durationMs: number } - } - - expect(body.astroDb.rowsChecked).toBeGreaterThanOrEqual(0) - expect(body.astroDb.durationMs).toBeGreaterThanOrEqual(0) - }) }) diff --git a/vercel.json b/vercel.json index 30496059d..fd5867fb6 100644 --- a/vercel.json +++ b/vercel.json @@ -6,10 +6,6 @@ { "path": "/api/cron/run-all", "schedule": "0 2 * * *" - }, - { - "path": "/api/cron/ping-integrations", - "schedule": "*/30 * * * *" } ] } From d982882df911487b93fe2eb71206d1595d92803d Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 03:07:38 +0300 Subject: [PATCH 18/22] Fix CodeQL error in Social/Embed client code related to regex on untrusted user code --- .../__tests__/detectEmbedPlatform.spec.ts | 26 ++++ src/components/Social/Embed/client/index.ts | 114 +++++++++++++----- 2 files changed, 110 insertions(+), 30 deletions(-) create mode 100644 src/components/Social/Embed/client/__tests__/detectEmbedPlatform.spec.ts diff --git a/src/components/Social/Embed/client/__tests__/detectEmbedPlatform.spec.ts b/src/components/Social/Embed/client/__tests__/detectEmbedPlatform.spec.ts new file mode 100644 index 000000000..3bd75dc7a --- /dev/null +++ b/src/components/Social/Embed/client/__tests__/detectEmbedPlatform.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { detectEmbedPlatform } from '@components/Social/Embed/client' + +describe('detectEmbedPlatform', () => { + it('detects platforms by hostname (not substring)', () => { + expect(detectEmbedPlatform('https://twitter.com/user/status/123')).toBe('x') + expect(detectEmbedPlatform('https://x.com/user/status/123')).toBe('x') + expect(detectEmbedPlatform('https://www.linkedin.com/posts/something')).toBe('linkedin') + expect(detectEmbedPlatform('https://bsky.app/profile/example/post/123')).toBe('bluesky') + expect(detectEmbedPlatform('https://www.reddit.com/r/webdev/comments/abc123/test')).toBe('reddit') + expect(detectEmbedPlatform('https://youtu.be/dQw4w9WgXcQ')).toBe('youtube') + expect(detectEmbedPlatform('https://gist.github.com/user/abcdef123456')).toBe('github-gist') + expect(detectEmbedPlatform('https://codepen.io/user/pen/abc123')).toBe('codepen') + }) + + it('does not treat arbitrary URLs containing provider strings as trusted', () => { + expect(detectEmbedPlatform('https://evil.example/?next=https://codepen.io/user/pen/abc')).toBe('x') + expect(detectEmbedPlatform('https://codepen.io.evil.example/user/pen/abc')).toBe('x') + expect(detectEmbedPlatform('https://evil.example/gist.github.com/user/abcdef')).toBe('x') + }) + + it('detects Mastodon by path shape across instances', () => { + expect(detectEmbedPlatform('https://mastodon.social/@user/123456789')).toBe('mastodon') + expect(detectEmbedPlatform('https://example.instance/users/user/statuses/123456789')).toBe('mastodon') + }) +}) diff --git a/src/components/Social/Embed/client/index.ts b/src/components/Social/Embed/client/index.ts index 18a2a9339..b4834535d 100644 --- a/src/components/Social/Embed/client/index.ts +++ b/src/components/Social/Embed/client/index.ts @@ -35,6 +35,79 @@ interface OEmbedResponse { type?: string } +const safeParseAbsoluteHttpUrl = (value: string): URL | null => { + try { + const parsed = new URL(value) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + return parsed + } catch { + return null + } +} + +const hostMatchesDomain = (hostname: string, domain: string): boolean => { + const normalizedHost = hostname.toLowerCase() + const normalizedDomain = domain.toLowerCase() + + return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`) +} + +const isUrlOnAllowedDomains = (value: string, allowedDomains: string[]): boolean => { + const parsed = safeParseAbsoluteHttpUrl(value) + if (!parsed) return false + + return allowedDomains.some((domain) => hostMatchesDomain(parsed.hostname, domain)) +} + +const mastodonStatusPathMatchers: RegExp[] = [ + /^\/\@[^\/]+\/\d+(?:\/.*)?$/, + /^\/users\/[^\/]+\/statuses\/\d+(?:\/.*)?$/, +] + +const isMastodonStatusUrl = (value: string): boolean => { + const parsed = safeParseAbsoluteHttpUrl(value) + if (!parsed) return false + + return mastodonStatusPathMatchers.some((matcher) => matcher.test(parsed.pathname)) +} + +/** + * Detect the embed platform for a given URL. + * + * Uses URL parsing + hostname allowlists to avoid substring-matching attacks. + */ +export const detectEmbedPlatform = (url: string): EmbedPlatform => { + if (isUrlOnAllowedDomains(url, ['twitter.com', 'x.com'])) { + return 'x' + } + if (isUrlOnAllowedDomains(url, ['linkedin.com'])) { + return 'linkedin' + } + if (isUrlOnAllowedDomains(url, ['bsky.app', 'bsky.social'])) { + return 'bluesky' + } + if (isMastodonStatusUrl(url)) { + return 'mastodon' + } + if (isUrlOnAllowedDomains(url, ['reddit.com'])) { + return 'reddit' + } + if (isUrlOnAllowedDomains(url, ['youtube.com', 'youtu.be'])) { + return 'youtube' + } + if (isUrlOnAllowedDomains(url, ['gist.github.com'])) { + return 'github-gist' + } + if (isUrlOnAllowedDomains(url, ['codepen.io'])) { + return 'codepen' + } + + console.warn(`Could not detect platform for URL: ${url}, defaulting to 'x'`) + return 'x' +} + /** * Manager class for all social embeds using singleton pattern * Can be registered directly: registerScript(EmbedManager) @@ -231,35 +304,7 @@ class EmbedInstance { } private detectPlatform(url: string): EmbedPlatform { - const urlLower = url.toLowerCase() - - if (urlLower.includes('twitter.com') || urlLower.includes('x.com')) { - return 'x' - } - if (urlLower.includes('linkedin.com')) { - return 'linkedin' - } - if (urlLower.includes('bsky.app') || urlLower.includes('bsky.social')) { - return 'bluesky' - } - if (urlLower.match(/mastodon\.|\/\@[^\/]+\/\d+/)) { - return 'mastodon' - } - if (urlLower.includes('reddit.com')) { - return 'reddit' - } - if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be')) { - return 'youtube' - } - if (urlLower.includes('gist.github.com')) { - return 'github-gist' - } - if (urlLower.includes('codepen.io')) { - return 'codepen' - } - - console.warn(`Could not detect platform for URL: ${url}, defaulting to 'x'`) - return 'x' + return detectEmbedPlatform(url) } private setupIntersectionObserver(): void { @@ -408,7 +453,11 @@ class EmbedInstance { try { // Parse the Mastodon URL to extract the instance domain // Format: https://mastodon.social/@username/123456789 - const urlObj = new URL(this.url) + const urlObj = safeParseAbsoluteHttpUrl(this.url) + if (!urlObj) { + return null + } + const instance = urlObj.hostname const encodedUrl = encodeURIComponent(this.url) @@ -452,6 +501,11 @@ class EmbedInstance { } private renderGitHubGist(wrapper: HTMLElement): void { + if (!isUrlOnAllowedDomains(this.url, ['gist.github.com'])) { + console.warn('GitHub Gist embed blocked: URL host not allowed') + return + } + // For GitHub Gist, we create a script tag that loads the gist const script = document.createElement('script') script.src = `${this.url}.js` From 6b0536f76d2324dc9cd91da57a01b25143835900 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 03:18:36 +0300 Subject: [PATCH 19/22] Swap reex sanitizer from ad hoc to use sanitize-html package in WebMentions server code. Quiets CodeQL warning. --- _TODO.md | 2 + package-lock.json | 141 ++++++++++++++---- package.json | 12 +- .../server/__tests__/index.spec.ts | 27 ++++ src/components/WebMentions/server/index.ts | 40 +++-- 5 files changed, 182 insertions(+), 40 deletions(-) diff --git a/_TODO.md b/_TODO.md index ea77ea196..5b8ebc0ef 100644 --- a/_TODO.md +++ b/_TODO.md @@ -17,6 +17,8 @@ - Entities are part of the domain. They represent state and core business rules, but not persistence logic. Defined primarily by its unique identity, rather than its attributes or properties. +Is an entry point to the domain logic forming the core of the application, modifying state and persistence as needed. This may be a Transaction Script, Service Layer, Application Service, or something similar. + `/entities` or `entities.ts` - The responder builds the entire HTTP response from the domain's output which is given to it by the action. The Responder is responsible solely for formatting the final response (e.g., JSON, HTML) to be sent back to the client. diff --git a/package-lock.json b/package-lock.json index 4998a7e73..5f0bdae2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@semantic-ui/astro-lit": "^5.1.1", "@sentry/astro": "^10.30.0", "@sentry/browser": "^10.30.0", - "@shikijs/transformers": "^3.19.0", + "@shikijs/transformers": "^3.20.0", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.18", @@ -42,7 +42,7 @@ "embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "focus-trap": "7.6.6", - "gsap": "^3.14.1", + "gsap": "^3.14.2", "isomorphic-git": "^1.36.0", "js-cookie": "^3.0.5", "jsdom": "^27.3.0", @@ -60,6 +60,7 @@ "remark-emoji": "^5.0.2", "remark-linkify-regex": "^1.2.1", "resend": "^6.6.0", + "sanitize-html": "^2.17.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "tailwindcss": "^4.1.18", @@ -72,7 +73,7 @@ }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.5.0", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@happy-dom/global-registrator": "^20.0.11", "@playwright/test": "1.57.0", "@testing-library/dom": "10.4.1", @@ -87,9 +88,10 @@ "@types/glidejs__glide": "^3.6.6", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^27.0.0", - "@types/node": "^25.0.0", + "@types/node": "^25.0.1", "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.7", + "@types/sanitize-html": "^2.16.0", "@types/to-ico": "1.1.3", "@types/uuid": "^11.0.0", "@types/yargs": "17.0.35", @@ -100,7 +102,7 @@ "cross-spawn": "7.0.6", "dedent": "^1.7.0", "dotenv-cli": "11.0.0", - "eslint": "9.39.1", + "eslint": "9.39.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-astro": "1.5.0", "eslint-plugin-import": "2.32.0", @@ -3833,9 +3835,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -6721,13 +6723,35 @@ } }, "node_modules/@shikijs/transformers": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.19.0.tgz", - "integrity": "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.20.0.tgz", + "integrity": "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.19.0", - "@shikijs/types": "3.19.0" + "@shikijs/core": "3.20.0", + "@shikijs/types": "3.20.0" + } + }, + "node_modules/@shikijs/transformers/node_modules/@shikijs/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.20.0.tgz", + "integrity": "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.20.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/transformers/node_modules/@shikijs/types": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.20.0.tgz", + "integrity": "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" } }, "node_modules/@shikijs/types": { @@ -8076,9 +8100,9 @@ } }, "node_modules/@types/node": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.0.tgz", - "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz", + "integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -8138,6 +8162,36 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, + "node_modules/@types/sanitize-html": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz", + "integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, + "node_modules/@types/sanitize-html/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/@types/sax": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", @@ -12540,7 +12594,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12550,9 +12603,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "peer": true, @@ -12563,7 +12616,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -14162,9 +14215,9 @@ "license": "ISC" }, "node_modules/gsap": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.1.tgz", - "integrity": "sha512-nOFRJpVu+U0IPgKzfC5MEdZrJ1iVidGaOuxK0+Un+7vMOHa5rw/SKOaMaRRyHZiJKaeAEALTBwnj6R3gifyGBw==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/h3": { @@ -15403,7 +15456,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18699,6 +18751,12 @@ "node": ">=4" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parse-statements": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", @@ -20594,6 +20652,39 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/sass-formatter": { "version": "0.7.9", "resolved": "https://registry.npmjs.org/sass-formatter/-/sass-formatter-0.7.9.tgz", diff --git a/package.json b/package.json index ecb02496f..24d523362 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@semantic-ui/astro-lit": "^5.1.1", "@sentry/astro": "^10.30.0", "@sentry/browser": "^10.30.0", - "@shikijs/transformers": "^3.19.0", + "@shikijs/transformers": "^3.20.0", "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "^4.1.18", @@ -88,7 +88,7 @@ "embla-carousel": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "focus-trap": "7.6.6", - "gsap": "^3.14.1", + "gsap": "^3.14.2", "isomorphic-git": "^1.36.0", "js-cookie": "^3.0.5", "jsdom": "^27.3.0", @@ -106,6 +106,7 @@ "remark-emoji": "^5.0.2", "remark-linkify-regex": "^1.2.1", "resend": "^6.6.0", + "sanitize-html": "^2.17.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "tailwindcss": "^4.1.18", @@ -118,7 +119,7 @@ }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.5.0", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@happy-dom/global-registrator": "^20.0.11", "@playwright/test": "1.57.0", "@testing-library/dom": "10.4.1", @@ -133,9 +134,10 @@ "@types/glidejs__glide": "^3.6.6", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^27.0.0", - "@types/node": "^25.0.0", + "@types/node": "^25.0.1", "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.7", + "@types/sanitize-html": "^2.16.0", "@types/to-ico": "1.1.3", "@types/uuid": "^11.0.0", "@types/yargs": "17.0.35", @@ -146,7 +148,7 @@ "cross-spawn": "7.0.6", "dedent": "^1.7.0", "dotenv-cli": "11.0.0", - "eslint": "9.39.1", + "eslint": "9.39.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-astro": "1.5.0", "eslint-plugin-import": "2.32.0", diff --git a/src/components/WebMentions/server/__tests__/index.spec.ts b/src/components/WebMentions/server/__tests__/index.spec.ts index c25f2bb08..f6cffdf66 100644 --- a/src/components/WebMentions/server/__tests__/index.spec.ts +++ b/src/components/WebMentions/server/__tests__/index.spec.ts @@ -74,6 +74,33 @@ describe('fetchWebmentions', () => { const sanitizedHtml = secondResult.content?.value ?? '' expect(sanitizedHtml).toBe('

Love this post

') + expect(sanitizedHtml).not.toContain('Hello

x', + }, + } as Webmention, + ], + }, + ), + ) + + const injectedResults = await fetchAgain(secondTargetUrl) + const injectedValue = injectedResults.at(0)?.content?.value ?? '' + expect(injectedValue).toContain('

Hello

') + expect(injectedValue).not.toContain(' { diff --git a/src/components/WebMentions/server/index.ts b/src/components/WebMentions/server/index.ts index 162b0949a..ba4ef9e68 100644 --- a/src/components/WebMentions/server/index.ts +++ b/src/components/WebMentions/server/index.ts @@ -7,6 +7,7 @@ import { WEBMENTION_IO_TOKEN } from 'astro:env/server' import { isDev } from '@lib/config/environmentServer' import type { Webmention, WebmentionResponse } from '@components/WebMentions/@types' +import sanitizeHtml from 'sanitize-html' const allowedTypes = new Set(['mention-of', 'in-reply-to', 'like-of', 'repost-of']) const PLACEHOLDER_TOKENS = new Set(['', 'updateme', 'your_api_token_here']) @@ -27,16 +28,35 @@ const logTimestamps = new Map() let missingTokenWarningShown = false /** - * Sanitize HTML content - * In production, consider using DOMPurify or sanitize-html + * Sanitize untrusted HTML from webmentions. + * + * Regex-based sanitization is error-prone; use an allowlist sanitizer instead. */ -const sanitizeHTML = (html: string): string => { - // Basic sanitization - remove script tags - // For production, install and use sanitize-html or DOMPurify - return html - .replace(/)<[^<]*)*<\/script>/gi, '') - .replace(/)<[^<]*)*<\/iframe>/gi, '') -} +const sanitizeHTML = (html: string): string => + sanitizeHtml(html, { + allowedTags: [ + 'p', + 'br', + 'a', + 'strong', + 'em', + 'b', + 'i', + 'code', + 'pre', + 'blockquote', + 'ul', + 'ol', + 'li', + 'span', + ], + allowedAttributes: { + a: ['href', 'title'], + span: ['title'], + }, + allowedSchemes: ['http', 'https'], + allowProtocolRelative: false, + }) /** * Check if a webmention is from your own domain @@ -61,7 +81,7 @@ const cleanWebmention = (entry: Webmention): Webmention => { // Really long html mentions, usually newsletters or compilations entry.content.value = html.length > 2000 - ? `mentioned this in ${entry['wm-source']}` + ? sanitizeHTML(`mentioned this in ${entry['wm-source']}`) : sanitizeHTML(html) } else if (text) { entry.content.value = sanitizeHTML(text) From 494300e91c3597fe717f118ea19dda36487d349c Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 03:29:31 +0300 Subject: [PATCH 20/22] Fix CodeQL js/bad-tag-filter in test/unit/helpers/litRuntime by using JSDOM fixture --- test/unit/helpers/litRuntime.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/unit/helpers/litRuntime.ts b/test/unit/helpers/litRuntime.ts index c093aca30..21c741d69 100644 --- a/test/unit/helpers/litRuntime.ts +++ b/test/unit/helpers/litRuntime.ts @@ -176,8 +176,21 @@ const componentModuleImporters = import.meta.glob { - const withoutScripts = html.replace(//gi, '') - return withoutScripts.replace(/\sdata-astro-cid-[^=\s>]+="[^"]*"/gi, '') + const dom = new JSDOM(`${html}`) + + for (const script of dom.window.document.querySelectorAll('script')) { + script.remove() + } + + for (const element of dom.window.document.querySelectorAll('*')) { + for (const attribute of [...element.attributes]) { + if (attribute.name.startsWith('data-astro-cid-')) { + element.removeAttribute(attribute.name) + } + } + } + + return dom.window.document.body.innerHTML } const hasFileExtension = (specifier: string): boolean => /\.[a-zA-Z0-9]+$/.test(specifier) From 18679c58e4207f951051d5d556ce63f99e6e1c68 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 03:38:35 +0300 Subject: [PATCH 21/22] Add permissions blocks to GitHub Actions workflows for GITHUB_TOKEN permissions --- .github/workflows/branch-protection.yml | 3 +++ .github/workflows/build.yml | 3 +++ .github/workflows/ping-turso.yml | 3 +++ .github/workflows/test.yml | 13 +++++++++++++ 4 files changed, 22 insertions(+) diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml index 67dac26c2..8dbb1b010 100644 --- a/.github/workflows/branch-protection.yml +++ b/.github/workflows/branch-protection.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, edited, synchronize, reopened] +permissions: + contents: read + jobs: check-branch-name: name: Validate Branch Name diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d2b5e9ab4..f6947468e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,9 @@ on: types: - completed +permissions: + contents: read + jobs: verify-ci: name: Verify CI Results diff --git a/.github/workflows/ping-turso.yml b/.github/workflows/ping-turso.yml index 787bfb4e4..2465ad1a0 100644 --- a/.github/workflows/ping-turso.yml +++ b/.github/workflows/ping-turso.yml @@ -5,6 +5,9 @@ on: - cron: '*/30 * * * *' workflow_dispatch: +permissions: + contents: read + concurrency: group: ping-turso cancel-in-progress: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbb01d761..f762900dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,9 @@ on: branches: - main +permissions: + contents: read + env: CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} CONVERTKIT_FORM_ID: ${{ secrets.CONVERTKIT_FORM_ID }} @@ -55,6 +58,12 @@ jobs: needs: lint if: ${{ !startsWith(github.head_ref || github.ref_name, 'hotfix/') }} + permissions: + actions: write + checks: write + contents: read + pull-requests: write + steps: - name: Checkout repository uses: actions/checkout@v6 @@ -98,6 +107,10 @@ jobs: # Temporarily disabled until the simplified flow is battle-tested if: ${{ false && !startsWith(github.head_ref || github.ref_name, 'hotfix/') }} + permissions: + actions: write + contents: read + env: CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} CONVERTKIT_FORM_ID: ${{ secrets.CONVERTKIT_FORM_ID }} From 0c1e67633179cf23153aad43239bc834f4197fce Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 13 Dec 2025 03:41:53 +0300 Subject: [PATCH 22/22] Updated testimonials.spec.ts to remove the useless assignment flagged by CodeQL --- test/e2e/specs/04-components/testimonials.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/specs/04-components/testimonials.spec.ts b/test/e2e/specs/04-components/testimonials.spec.ts index 3f913bec5..a50402c2b 100644 --- a/test/e2e/specs/04-components/testimonials.spec.ts +++ b/test/e2e/specs/04-components/testimonials.spec.ts @@ -293,7 +293,7 @@ test.describe('Testimonials Component', () => { }, testimonialsEmblaSelector) // Wait longer than rotation interval to verify it doesn't advance by checking over time - let textAfterHover = initialText + let textAfterHover: string | null for (let index = 0; index < 7; index++) { await waitForAnimationFrames(page.page, 60) textAfterHover = await testimonials.locator('blockquote').first().textContent()