diff --git a/bun.lock b/bun.lock index 4c78e7e..1582ce4 100644 --- a/bun.lock +++ b/bun.lock @@ -48,9 +48,11 @@ "@cloudflare/workers-types": "^4.20260509.1", "@pokle/basecoat": "0.3.10-beta3.pokle-selections", "@tailwindcss/vite": "^4.3.0", + "jsdom": "^25.0.1", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "vite": "^7.3.3", + "vitest": "^4.1.5", "wrangler": "4.87.0", }, }, @@ -81,8 +83,10 @@ "kysely-d1": "^0.4.0", }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.2", "@cloudflare/workers-types": "^4.20260509.1", "typescript": "^6.0.3", + "vitest": "^4.1.5", "wrangler": "4.87.0", }, }, @@ -140,6 +144,8 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -236,6 +242,16 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], @@ -568,6 +584,8 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agents": ["agents@0.12.3", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^7.29.0", "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "1.29.0", "@rolldown/plugin-babel": "^0.2.3", "cron-schedule": "^6.0.0", "mimetext": "^3.0.28", "nanoid": "^5.1.11", "partyserver": "^0.5.5", "partysocket": "1.1.18", "yargs": "^18.0.0" }, "peerDependencies": { "@cloudflare/ai-chat": ">=0.6.1 <1.0.0", "@cloudflare/codemode": ">=0.3.4 <1.0.0", "@tanstack/ai": ">=0.10.2 <1.0.0", "@x402/core": "^2.0.0", "@x402/evm": "^2.0.0", "ai": "^6.0.0", "react": "^19.0.0", "vite": ">=6.0.0 <9.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@cloudflare/ai-chat", "@cloudflare/codemode", "@tanstack/ai", "@x402/core", "@x402/evm", "vite"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-DZn90m9TEhaVjAb9UAsc5BeBlZRaC0eet18Hz1uMXr+YZdsRrL/RkSSyzf3v3xkBmnBYCCVw7+3/i4bVsYCVRQ=="], "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], @@ -584,6 +602,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "auth-api": ["auth-api@workspace:web/workers/auth-api"], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], @@ -622,6 +642,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "competition-api": ["competition-api@workspace:web/workers/competition-api"], @@ -648,10 +670,18 @@ "csscolorparser": ["csscolorparser@1.0.3", "", {}, "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w=="], + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -670,6 +700,8 @@ "enhanced-resolve": ["enhanced-resolve@5.21.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -680,6 +712,8 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -710,6 +744,8 @@ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -742,13 +778,21 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -758,6 +802,8 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -770,6 +816,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "jsdom": ["jsdom@25.0.1", "", { "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.12", "parse5": "^7.1.2", "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^2.11.2" }, "optionalPeers": ["canvas"] }, "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], @@ -816,7 +864,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -832,9 +880,9 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mimetext": ["mimetext@3.0.28", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@babel/runtime-corejs3": "^7.26.0", "js-base64": "^3.7.7", "mime-types": "^2.1.35" } }, "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g=="], @@ -852,6 +900,8 @@ "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -862,6 +912,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "partyserver": ["partyserver@0.5.5", "", { "dependencies": { "nanoid": "^5.1.9" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" } }, "sha512-7zub8oV8Od9dY2aXGrgzhX5GLceaWOg7xB5VWXtDcqt2BWVDIOCAgaF0AmBMSu3AXhJHsFdzPnA8SSZdybXMbQ=="], @@ -894,6 +946,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], @@ -920,10 +974,14 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rrweb-cssom": ["rrweb-cssom@0.7.1", "", {}, "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -972,6 +1030,8 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -988,8 +1048,16 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1014,6 +1082,16 @@ "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -1028,6 +1106,10 @@ "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1048,6 +1130,8 @@ "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1080,6 +1164,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "agents/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "agents/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -1088,35 +1174,51 @@ "better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "mcp-api/@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.14.9", "", { "dependencies": { "cjs-module-lexer": "^1.2.3", "esbuild": "0.27.3", "miniflare": "4.20260421.0", "wrangler": "4.84.1", "zod": "^3.25.76" }, "peerDependencies": { "@vitest/runner": "^4.1.0", "@vitest/snapshot": "^4.1.0", "vitest": "^4.1.0" } }, "sha512-XLJTOd+3A3BB6vPzD7gi1LTVKFSVge9HhGWXnLouPzySphMLbg+6xXELykFxZKyqP1AEjT2tc28AkBjM9GteFQ=="], + "cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimetext/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mcp-api/@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.14.9", "", { "dependencies": { "cjs-module-lexer": "^1.2.3", "esbuild": "0.27.3", "miniflare": "4.20260421.0", "wrangler": "4.84.1", "zod": "^3.25.76" }, "peerDependencies": { "@vitest/runner": "^4.1.0", "@vitest/snapshot": "^4.1.0", "vitest": "^4.1.0" } }, "sha512-XLJTOd+3A3BB6vPzD7gi1LTVKFSVge9HhGWXnLouPzySphMLbg+6xXELykFxZKyqP1AEjT2tc28AkBjM9GteFQ=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "vitest/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "agents/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "agents/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "agents/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mcp-api/@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260421.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260421.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw=="], "mcp-api/@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.84.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260421.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260421.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260421.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Xe1S/Bik7pNdtdJ+asHsEZC2dX9k3WxYn2BbxFtOrrLVxN/LKi750zsrjX41jSAk00M/O1l7jzyQV4sQqw8ftg=="], - "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], diff --git a/tsconfig.json b/tsconfig.json index 41bfa8c..39e23b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,7 @@ "**/node_modules", "web/workers/competition-api/test", "web/workers/auth-api/src", + "web/workers/auth-api/test", "web/workers/mcp-api/src", "web/workers/mcp-api/test" ] diff --git a/web/db/migrations/0007_user_preferences.sql b/web/db/migrations/0007_user_preferences.sql new file mode 100644 index 0000000..f7d3637 --- /dev/null +++ b/web/db/migrations/0007_user_preferences.sql @@ -0,0 +1,17 @@ +-- Per-user app preferences and custom theme. Replaces the +-- `glidecomp:preferences` and `glidecomp:theme` localStorage keys for +-- authenticated users so settings sync across devices. +-- +-- One row per user. Both blobs are opaque JSON owned by the client; the +-- server only enforces size limits and treats them as strings. +-- +-- theme_json is nullable so "reset to default" is a clean NULL rather than +-- a sentinel value. CASCADE on user delete piggybacks on the existing +-- account-deletion flow — no extra cleanup code needed. + +CREATE TABLE "user_preferences" ( + "user_id" TEXT PRIMARY KEY NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, + "prefs_json" TEXT NOT NULL DEFAULT '{}', + "theme_json" TEXT, + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/web/frontend/package.json b/web/frontend/package.json index 3ddd7e0..3293572 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -9,16 +9,19 @@ "build": "vite build", "preview": "vite preview", "deploy": "vite build && wrangler pages deploy dist --project-name=glidecomp", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "//": "Basecoat fork - see docs/basecoat-fork.md for build/publish instructions", "devDependencies": { "@cloudflare/workers-types": "^4.20260509.1", "@tailwindcss/vite": "^4.3.0", "@pokle/basecoat": "0.3.10-beta3.pokle-selections", + "jsdom": "^25.0.1", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "vite": "^7.3.3", + "vitest": "^4.1.5", "wrangler": "4.87.0" }, "dependencies": { diff --git a/web/frontend/src/analysis/config.ts b/web/frontend/src/analysis/config.ts index 2c1ca2e..c74cf16 100644 --- a/web/frontend/src/analysis/config.ts +++ b/web/frontend/src/analysis/config.ts @@ -1,9 +1,13 @@ /** * Configuration storage abstraction. - * Currently backed by localStorage, designed for future migration to backend API. + * + * localStorage is the synchronous read cache; cloud sync (when signed in) + * is layered on top via auth/preferences-sync, which observes mutations + * through schedulePush() and reconciles on startup via clearCache(). */ import { resolveThresholds, DEFAULT_GAP_PARAMETERS, type DetectionThresholds, type PartialThresholds, type GAPParameters } from '@glidecomp/engine'; +import { preferencesSync } from '../auth/preferences-sync'; export interface MapLocation { center: [lng: number, lat: number]; @@ -98,6 +102,8 @@ class ConfigStore { detail: merged, }) ); + + preferencesSync.schedulePush('prefs'); } /** diff --git a/web/frontend/src/auth/preferences-sync.test.ts b/web/frontend/src/auth/preferences-sync.test.ts new file mode 100644 index 0000000..fcb76c2 --- /dev/null +++ b/web/frontend/src/auth/preferences-sync.test.ts @@ -0,0 +1,586 @@ +/** + * Unit tests for PreferencesSync. + * + * Strategy: instantiate fresh PreferencesSync per test, stub global fetch, + * use jsdom's real localStorage. Auto-bootstrap is gated off in test mode + * (see preferences-sync.ts), so the module is quiet on import. + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { PreferencesSync } from "./preferences-sync"; +import type { AuthUser } from "./client"; + +const USER: AuthUser = { + id: "u1", + name: "Test User", + email: "u@test.com", + image: null, + username: "test", +}; + +const STORAGE_KEY_PREFS = "glidecomp:preferences"; +const STORAGE_KEY_THEME = "glidecomp:theme"; + +const SAMPLE_PREFS = { + units: { speed: "mph", altitude: "ft", distance: "mi", climbRate: "ft/min" }, + mapProvider: "leaflet", +}; +const ALT_PREFS = { units: { speed: "knots" } }; + +const SAMPLE_THEME = { + name: "Test Theme", + author: "Tester", + version: 1, + colors: { + background: "#000", + foreground: "#fff", + card: "#111", + "card-foreground": "#fff", + popover: "#111", + "popover-foreground": "#fff", + primary: "#f00", + "primary-foreground": "#fff", + secondary: "#222", + "secondary-foreground": "#fff", + muted: "#333", + "muted-foreground": "#aaa", + accent: "#444", + "accent-foreground": "#fff", + destructive: "#f00", + border: "#555", + input: "#666", + ring: "#777", + }, + radius: "0.5rem", + buttonRadius: "0.25rem", + fonts: { + heading: { family: "Roboto", weight: 700, size: 24 }, + body: { family: "Roboto", weight: 400, size: 16 }, + button: { family: "Roboto", weight: 500, size: 14 }, + caption: { family: "Roboto", weight: 400, size: 12 }, + nav: { family: "Roboto", weight: 500, size: 14 }, + }, +}; + +type FetchMock = ReturnType; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +let fetchMock: FetchMock; +let sync: PreferencesSync; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + localStorage.clear(); + sync = new PreferencesSync(); +}); + +afterEach(() => { + sync.dispose(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +// ── hydrate ───────────────────────────────────────────────────────────────── + +describe("hydrate", () => { + test("anonymous user: no fetch, no localStorage writes", async () => { + await sync.hydrate(null); + expect(fetchMock).not.toHaveBeenCalled(); + expect(localStorage.getItem(STORAGE_KEY_PREFS)).toBeNull(); + }); + + test("cloud empty + local empty: GET only, no PUT", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ prefs: {}, theme: null, updated_at: null }) + ); + + await sync.hydrate(USER); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "/api/auth/preferences", + expect.objectContaining({ credentials: "include" }) + ); + expect(localStorage.getItem(STORAGE_KEY_PREFS)).toBeNull(); + }); + + test("cloud empty + local has prefs: uploads local as one-time migration", async () => { + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ prefs: {}, theme: null, updated_at: null }) + ) + .mockResolvedValueOnce(jsonResponse({ updated_at: "2026-05-10T00:00:00Z" })); + + await sync.hydrate(USER); + // Let the fire-and-forget put() resolve + await flushMicrotasks(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const putCall = fetchMock.mock.calls[1]; + expect(putCall[1].method).toBe("PUT"); + const body = JSON.parse(putCall[1].body); + expect(body.prefs).toEqual(SAMPLE_PREFS); + expect(body.theme).toBeUndefined(); + // localStorage unchanged — local was the source + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_PREFS)!)).toEqual( + SAMPLE_PREFS + ); + }); + + test("cloud has prefs + local empty: cloud written to localStorage, event fires", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ prefs: SAMPLE_PREFS, theme: null, updated_at: "now" }) + ); + const events: unknown[] = []; + window.addEventListener("glidecomp:preferences-changed", (e) => + events.push((e as CustomEvent).detail) + ); + + await sync.hydrate(USER); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_PREFS)!)).toEqual( + SAMPLE_PREFS + ); + expect(events.length).toBe(1); + }); + + test("cloud has prefs + local has different prefs: cloud wins", async () => { + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(ALT_PREFS)); + fetchMock.mockResolvedValueOnce( + jsonResponse({ prefs: SAMPLE_PREFS, theme: null, updated_at: "now" }) + ); + + await sync.hydrate(USER); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_PREFS)!)).toEqual( + SAMPLE_PREFS + ); + // Should NOT have made a PUT (cloud already had data) + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("cloud has theme: writes localStorage and applies", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ prefs: {}, theme: SAMPLE_THEME, updated_at: "now" }) + ); + + await sync.hydrate(USER); + // Wait for the dynamic import of theme.ts and applyTheme call to settle + await flushMicrotasks(); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_THEME)!)).toEqual( + SAMPLE_THEME + ); + // applyTheme sets CSS custom properties on documentElement + expect( + document.documentElement.style.getPropertyValue("--background") + ).toBe("#000"); + }); + + test("GET network error: stays local-only, no writes", async () => { + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(ALT_PREFS)); + fetchMock.mockRejectedValueOnce(new Error("network down")); + + await sync.hydrate(USER); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_PREFS)!)).toEqual( + ALT_PREFS + ); + }); + + test("GET 401: stays local-only, no PUT attempted", async () => { + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })); + + await sync.hydrate(USER); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY_PREFS)!)).toEqual( + SAMPLE_PREFS + ); + }); + + test("strips mapLocation when uploading local-only prefs to cloud", async () => { + const localWithMap = { + ...SAMPLE_PREFS, + mapLocation: { center: [10, 20], zoom: 12, pitch: 0, bearing: 0 }, + }; + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(localWithMap)); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ prefs: {}, theme: null, updated_at: null }) + ) + .mockResolvedValueOnce(jsonResponse({ updated_at: "now" })); + + await sync.hydrate(USER); + await flushMicrotasks(); + + const body = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(body.prefs).toEqual(SAMPLE_PREFS); + expect(body.prefs.mapLocation).toBeUndefined(); + }); + + test("preserves local mapLocation when cloud prefs win", async () => { + const localMap = { center: [10, 20], zoom: 12, pitch: 0, bearing: 0 }; + localStorage.setItem( + STORAGE_KEY_PREFS, + JSON.stringify({ ...ALT_PREFS, mapLocation: localMap }) + ); + fetchMock.mockResolvedValueOnce( + jsonResponse({ prefs: SAMPLE_PREFS, theme: null, updated_at: "now" }) + ); + + await sync.hydrate(USER); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY_PREFS)!); + expect(stored.units).toEqual(SAMPLE_PREFS.units); + expect(stored.mapLocation).toEqual(localMap); + }); + + test("uploads BOTH prefs and theme when both are local-only", async () => { + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + localStorage.setItem(STORAGE_KEY_THEME, JSON.stringify(SAMPLE_THEME)); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ prefs: {}, theme: null, updated_at: null }) + ) + .mockResolvedValueOnce(jsonResponse({ updated_at: "now" })); + + await sync.hydrate(USER); + await flushMicrotasks(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const body = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(body.prefs).toEqual(SAMPLE_PREFS); + expect(body.theme).toEqual(SAMPLE_THEME); + }); +}); + +// ── schedulePush ──────────────────────────────────────────────────────────── + +describe("schedulePush", () => { + test("no-op when user is null", () => { + vi.useFakeTimers(); + sync.schedulePush("prefs"); + vi.advanceTimersByTime(5000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("debounces: rapid calls produce a single PUT", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + sync.schedulePush("prefs"); + sync.schedulePush("prefs"); + expect(fetchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].method).toBe("PUT"); + }); + + test("prefs and theme have independent debouncers", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + localStorage.setItem(STORAGE_KEY_THEME, JSON.stringify(SAMPLE_THEME)); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + sync.schedulePush("theme"); + + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(2); + const bodies = fetchMock.mock.calls.map((c) => JSON.parse(c[1].body)); + const hasPrefs = bodies.some((b) => b.prefs !== undefined); + const hasTheme = bodies.some((b) => b.theme !== undefined); + expect(hasPrefs).toBe(true); + expect(hasTheme).toBe(true); + }); + + test("pushes prefs from current localStorage at flush time, not schedule time", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + // Mutate localStorage AFTER scheduling but BEFORE flush + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(ALT_PREFS)); + await vi.advanceTimersByTimeAsync(2000); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.prefs).toEqual(ALT_PREFS); + }); + + test("schedulePush('prefs') skips PUT when only mapLocation changed", async () => { + await primeSignedIn(); + // localStorage has only mapLocation — nothing else worth syncing + localStorage.setItem( + STORAGE_KEY_PREFS, + JSON.stringify({ mapLocation: { center: [0, 0], zoom: 5, pitch: 0, bearing: 0 } }) + ); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("schedulePush('prefs') strips mapLocation but PUTs other fields", async () => { + await primeSignedIn(); + localStorage.setItem( + STORAGE_KEY_PREFS, + JSON.stringify({ + ...SAMPLE_PREFS, + mapLocation: { center: [0, 0], zoom: 5, pitch: 0, bearing: 0 }, + }) + ); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.prefs.mapLocation).toBeUndefined(); + expect(body.prefs.units).toEqual(SAMPLE_PREFS.units); + }); + + test("schedulePush('theme') with no localStorage entry sends theme=null", async () => { + await primeSignedIn(); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("theme"); + await vi.advanceTimersByTimeAsync(2000); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.theme).toBeNull(); + }); +}); + +// ── put failure modes ────────────────────────────────────────────────────── + +describe("put error handling", () => { + test("401 disables further pushes for the session", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Subsequent schedule should be ignored + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("transient 500 retries up to 3 times, then gives up", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValue(new Response("", { status: 500 })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); // debounce fires, attempt 0 + await vi.advanceTimersByTimeAsync(1000); // backoff for attempt 1 + await vi.advanceTimersByTimeAsync(2000); // backoff for attempt 2 + await vi.advanceTimersByTimeAsync(4000); // backoff for attempt 3 + + // Initial + 3 retries = 4 total + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + test("network error retries", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(1000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("uses keepalive: true on the PUT", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchMock.mock.calls[0][1].keepalive).toBe(true); + }); +}); + +// ── pagehide flush ────────────────────────────────────────────────────────── + +describe("pagehide flush", () => { + test("dispatching pagehide flushes pending prefs push immediately", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fetchMock.mockResolvedValue(jsonResponse({ updated_at: "now" })); + + vi.useFakeTimers(); + sync.schedulePush("prefs"); + expect(fetchMock).not.toHaveBeenCalled(); + + // Fire pagehide BEFORE the 2s debounce elapses + window.dispatchEvent(new Event("pagehide")); + await flushMicrotasks(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +// ── cross-tab storage events ──────────────────────────────────────────────── + +describe("cross-tab storage event", () => { + // jsdom's StorageEvent constructor rejects our polyfilled Storage (it's + // not a real jsdom Storage instance). Build the event manually so we can + // attach our shim as storageArea. + function fireStorage( + key: string | null, + newValue: string | null, + area: Storage = localStorage + ): void { + const ev = new Event("storage") as StorageEvent; + Object.defineProperty(ev, "key", { value: key, configurable: true }); + Object.defineProperty(ev, "newValue", { value: newValue, configurable: true }); + Object.defineProperty(ev, "storageArea", { value: area, configurable: true }); + window.dispatchEvent(ev); + } + + test("prefs storage event fires preferences-changed and refreshes cache", async () => { + const events: unknown[] = []; + window.addEventListener("glidecomp:preferences-changed", (e) => + events.push((e as CustomEvent).detail) + ); + // Simulate another tab writing prefs directly into our localStorage + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fireStorage(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + + await flushMicrotasks(); + + expect(events.length).toBe(1); + // The detail should be a merged UserPreferences with our units + expect((events[0] as { units: unknown }).units).toEqual(SAMPLE_PREFS.units); + }); + + test("theme storage event applies the new theme via CSS variables", async () => { + localStorage.setItem(STORAGE_KEY_THEME, JSON.stringify(SAMPLE_THEME)); + fireStorage(STORAGE_KEY_THEME, JSON.stringify(SAMPLE_THEME)); + + await flushMicrotasks(); + + expect( + document.documentElement.style.getPropertyValue("--background") + ).toBe("#000"); + }); + + test("theme storage event with newValue=null re-applies default theme", async () => { + // Pre-state: a non-default theme is currently applied + localStorage.setItem(STORAGE_KEY_THEME, JSON.stringify(SAMPLE_THEME)); + fireStorage(STORAGE_KEY_THEME, JSON.stringify(SAMPLE_THEME)); + await flushMicrotasks(); + + // Other tab clears the theme (resetTheme) + localStorage.removeItem(STORAGE_KEY_THEME); + fireStorage(STORAGE_KEY_THEME, null); + await flushMicrotasks(); + + // BASECOAT_LIGHT_THEME has background #ffffff + expect( + document.documentElement.style.getPropertyValue("--background") + ).toBe("#ffffff"); + }); + + test("storage event for unrelated key is ignored", async () => { + const events: unknown[] = []; + window.addEventListener("glidecomp:preferences-changed", (e) => + events.push((e as CustomEvent).detail) + ); + fireStorage("some-other-key", "irrelevant"); + + await flushMicrotasks(); + + expect(events.length).toBe(0); + }); + + test("storage event from sessionStorage is ignored", async () => { + const events: unknown[] = []; + window.addEventListener("glidecomp:preferences-changed", (e) => + events.push((e as CustomEvent).detail) + ); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fireStorage(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS), sessionStorage); + + await flushMicrotasks(); + + expect(events.length).toBe(0); + }); + + test("storage event does NOT trigger a cloud PUT (source tab handles that)", async () => { + await primeSignedIn(); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + fireStorage(STORAGE_KEY_PREFS, JSON.stringify(SAMPLE_PREFS)); + + await flushMicrotasks(); + // Some additional time in case any scheduled push slipped through + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(3000); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** + * Bring `sync` into a signed-in state. We do this by calling hydrate with a + * cloud response that has nothing — which leaves user set on the instance + * without writing anything else. + */ +async function primeSignedIn(): Promise { + fetchMock.mockResolvedValueOnce( + jsonResponse({ prefs: {}, theme: null, updated_at: null }) + ); + await sync.hydrate(USER); + fetchMock.mockReset(); +} + +/** + * Drain pending microtasks. Multiple awaits cover deep promise chains, + * including the first resolution of a dynamic import (which can take a + * handful of microtasks). Pure Promise-based — works under fake timers. + */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } +} diff --git a/web/frontend/src/auth/preferences-sync.ts b/web/frontend/src/auth/preferences-sync.ts new file mode 100644 index 0000000..359ce61 --- /dev/null +++ b/web/frontend/src/auth/preferences-sync.ts @@ -0,0 +1,361 @@ +/** + * Cloud sync layer for user preferences and theme. + * + * Architecture + * ──────────── + * - localStorage stays the synchronous read cache (no startup flicker, works + * offline). Cloud is the source of truth across devices when signed in. + * - On startup we hydrate: fetch cloud, reconcile against local. Cloud wins + * where it has data; missing-from-cloud fields get uploaded from local + * (one-time migration of existing users). + * - Mutations fire `schedulePush(kind)` from config.ts and theme.ts. Pushes + * are debounced 2s and PUT the *current* full localStorage value. + * - Conflict resolution is last-write-wins. No CAS, no version field. + * + * Module-cycle hygiene + * ──────────────────── + * theme.ts and config.ts statically import this module to call schedulePush. + * This module imports them only via dynamic import() inside async paths, so + * there's no init-time cycle. + */ + +import type { AuthUser } from "./client"; +import type { GlideCompTheme } from "../theme"; + +const STORAGE_KEY_PREFS = "glidecomp:preferences"; +const STORAGE_KEY_THEME = "glidecomp:theme"; +const DEBOUNCE_MS = 2000; +const MAX_RETRIES = 3; +const BACKOFF_BASE_MS = 1000; + +/** + * Preference fields that stay device-local and never sync to cloud. + * Stripped on upload, preserved when cloud values overwrite localStorage. + * + * - `mapLocation`: the user's saved viewport (zoom/pitch/bearing/centre). + * Different devices have different screens and use cases — what the user + * was looking at on their phone shouldn't dictate what their laptop opens + * to. Pan events also fire continuously, so syncing would generate noise. + */ +const LOCAL_ONLY_PREF_KEYS = ["mapLocation"] as const; + +type Kind = "prefs" | "theme"; + +type CloudResponse = { + prefs: Record; + theme: Record | null; + updated_at: string | null; +}; + +export class PreferencesSync { + private user: AuthUser | null = null; + private hydrating = false; + private prefsTimer: ReturnType | null = null; + private themeTimer: ReturnType | null = null; + private readonly pagehideHandler = () => this.flushPending(); + private readonly storageHandler = (e: StorageEvent) => { + void this.onStorage(e); + }; + + /** + * @param quiet When true, skip attaching window listeners. Used for the + * module-level singleton in test mode so it doesn't double-handle events + * alongside test-instantiated copies. Production passes false. + */ + constructor(quiet: boolean = false) { + if (typeof window !== "undefined" && !quiet) { + // Flush pending pushes when the page is unloading so edits made within + // the debounce window aren't lost. keepalive: true (in put()) tells the + // browser to commit to delivering the request even after teardown. + window.addEventListener("pagehide", this.pagehideHandler); + // Cross-tab sync: the browser fires `storage` on *other* tabs of the + // same origin when localStorage changes. We use it to propagate edits + // across same-device tabs without needing cloud round-trips. The source + // tab never sees its own writes here (per spec). + window.addEventListener("storage", this.storageHandler); + } + } + + /** + * Detach listeners and cancel pending timers. Production never calls this + * (the singleton lives until page unload), but tests instantiate fresh + * instances per case and need clean teardown to avoid listener accumulation. + */ + dispose(): void { + if (typeof window !== "undefined") { + window.removeEventListener("pagehide", this.pagehideHandler); + window.removeEventListener("storage", this.storageHandler); + } + if (this.prefsTimer !== null) { + clearTimeout(this.prefsTimer); + this.prefsTimer = null; + } + if (this.themeTimer !== null) { + clearTimeout(this.themeTimer); + this.themeTimer = null; + } + this.user = null; + } + + /** + * Another tab on the same origin wrote to localStorage. Refresh our + * in-memory state and fire the same change event a direct mutation would + * fire, so reactive UI updates. We deliberately do NOT trigger a cloud + * PUT — the writing tab already scheduled one. + */ + private async onStorage(e: StorageEvent): Promise { + // sessionStorage events also fire on this listener; ignore them. + if (e.storageArea !== localStorage) return; + + if (e.key === STORAGE_KEY_PREFS) { + const { config } = await import("../analysis/config"); + config.clearCache(); + window.dispatchEvent( + new CustomEvent("glidecomp:preferences-changed", { + detail: config.getPreferences(), + }) + ); + } else if (e.key === STORAGE_KEY_THEME) { + try { + const themeMod = await import("../theme"); + // newValue is null when the other tab removed the key (resetTheme). + // loadSavedTheme returns null in that case; fall back to the same + // default theme.ts's autoApply uses. + const next = themeMod.loadSavedTheme() ?? themeMod.BASECOAT_LIGHT_THEME; + themeMod.applyTheme(next); + } catch { + /* malformed cloud/local theme — leave current theme applied */ + } + } + } + + /** + * Reconcile local + cloud at startup. Called once after auth state is known. + * Safe to call with null user (no-op). + */ + async hydrate(user: AuthUser | null): Promise { + this.user = user; + if (!user) return; + + let cloud: CloudResponse; + try { + const res = await fetch("/api/auth/preferences", { + credentials: "include", + }); + if (!res.ok) return; // 401 / 5xx — stay local-only this session + cloud = (await res.json()) as CloudResponse; + } catch { + return; // network error — stay local-only + } + + this.hydrating = true; + try { + await this.reconcile(cloud); + } finally { + this.hydrating = false; + } + } + + /** Schedule a debounced cloud PUT for the given storage key. */ + schedulePush(kind: Kind): void { + if (this.hydrating || !this.user) return; + if (kind === "prefs") { + if (this.prefsTimer !== null) clearTimeout(this.prefsTimer); + this.prefsTimer = setTimeout(() => { + this.prefsTimer = null; + void this.flushOne("prefs"); + }, DEBOUNCE_MS); + } else { + if (this.themeTimer !== null) clearTimeout(this.themeTimer); + this.themeTimer = setTimeout(() => { + this.themeTimer = null; + void this.flushOne("theme"); + }, DEBOUNCE_MS); + } + } + + /** Fire any scheduled pushes immediately. Safe on unload. */ + private flushPending(): void { + if (this.prefsTimer !== null) { + clearTimeout(this.prefsTimer); + this.prefsTimer = null; + void this.flushOne("prefs"); + } + if (this.themeTimer !== null) { + clearTimeout(this.themeTimer); + this.themeTimer = null; + void this.flushOne("theme"); + } + } + + // ── private ──────────────────────────────────────────────────────────────── + + private async reconcile(cloud: CloudResponse): Promise { + const localPrefsRaw = localStorage.getItem(STORAGE_KEY_PREFS); + const localThemeRaw = localStorage.getItem(STORAGE_KEY_THEME); + const localPrefs = safeParse(localPrefsRaw); + const localTheme = safeParse(localThemeRaw); + + const cloudHasPrefs = isNonEmptyObject(cloud.prefs); + const cloudHasTheme = cloud.theme !== null; + const localHasPrefs = isNonEmptyObject(localPrefs); + const localHasTheme = isNonEmptyObject(localTheme); + + // One-time upload of local-only fields: existing users keep their settings + // when they first sign in to a cloud-synced session. LOCAL_ONLY_PREF_KEYS + // are stripped here too so they never reach the server. + const upload: { prefs?: unknown; theme?: unknown } = {}; + if (!cloudHasPrefs && localHasPrefs) upload.prefs = stripLocalOnly(localPrefs); + if (!cloudHasTheme && localHasTheme) upload.theme = localTheme; + if (upload.prefs !== undefined || upload.theme !== undefined) { + void this.put(upload); + } + + // Cloud wins where it has data, but we preserve LOCAL_ONLY_PREF_KEYS from + // localStorage so per-device state (e.g. map viewport) survives hydration. + if (cloudHasPrefs) { + const merged = mergeLocalOnly(cloud.prefs, localPrefs); + localStorage.setItem(STORAGE_KEY_PREFS, JSON.stringify(merged)); + const { config } = await import("../analysis/config"); + // clearCache forces the next getPreferences() to re-read localStorage, + // applying cloud values. The dispatched event lets reactive UI update. + config.clearCache(); + window.dispatchEvent( + new CustomEvent("glidecomp:preferences-changed", { + detail: config.getPreferences(), + }) + ); + } + if (cloudHasTheme && cloud.theme) { + localStorage.setItem(STORAGE_KEY_THEME, JSON.stringify(cloud.theme)); + // Theme shape is owned by the client; server stores opaque JSON. If a + // future schema change makes the cloud value invalid, swallow the + // error rather than crashing the page — localStorage still holds the + // saved theme; the user can clear it via the editor. + try { + const { applyTheme } = await import("../theme"); + applyTheme(cloud.theme as unknown as GlideCompTheme); + } catch { + /* malformed cloud theme — leave the current applied theme in place */ + } + } + } + + private async flushOne(kind: Kind): Promise { + if (kind === "prefs") { + const raw = localStorage.getItem(STORAGE_KEY_PREFS); + if (!raw) return; + const parsed = safeParse(raw); + if (!isNonEmptyObject(parsed)) return; + const stripped = stripLocalOnly(parsed); + // Don't bother PUTting if the only changed field was local-only + // (e.g. map pan). The stripped object may be empty. + if (Object.keys(stripped).length === 0) return; + await this.put({ prefs: stripped }); + } else { + const raw = localStorage.getItem(STORAGE_KEY_THEME); + // theme can legitimately be null (user reset), and we want to tell the + // server about that — that's why a missing-from-localStorage theme + // PUTs theme=null rather than skipping. + const parsed = raw ? safeParse(raw) : null; + await this.put({ theme: parsed }); + } + } + + private async put(body: object, attempt: number = 0): Promise { + let res: Response; + try { + res = await fetch("/api/auth/preferences", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + keepalive: true, + }); + } catch { + if (attempt < MAX_RETRIES) { + await delay(BACKOFF_BASE_MS * 2 ** attempt); + return this.put(body, attempt + 1); + } + return; + } + if (res.status === 401) { + // Session expired — stop syncing. localStorage already has the write. + this.user = null; + return; + } + // 4xx is permanent (validation, auth, payload too large). Retrying the + // same body won't change the outcome and would waste cycles. Only retry + // network errors (handled in the catch above) and 5xx. + if (res.status >= 500 && attempt < MAX_RETRIES) { + await delay(BACKOFF_BASE_MS * 2 ** attempt); + return this.put(body, attempt + 1); + } + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +function stripLocalOnly( + prefs: Record +): Record { + const out: Record = { ...prefs }; + for (const k of LOCAL_ONLY_PREF_KEYS) delete out[k]; + return out; +} + +function mergeLocalOnly( + cloudPrefs: Record, + localPrefs: unknown +): Record { + const out: Record = { ...cloudPrefs }; + if (!isNonEmptyObject(localPrefs)) return out; + for (const k of LOCAL_ONLY_PREF_KEYS) { + if (localPrefs[k] !== undefined) out[k] = localPrefs[k]; + } + return out; +} + +function safeParse(s: string | null): unknown { + if (s == null) return null; + try { + return JSON.parse(s); + } catch { + return null; + } +} + +function isNonEmptyObject(v: unknown): v is Record { + return ( + typeof v === "object" && + v !== null && + !Array.isArray(v) && + Object.keys(v as Record).length > 0 + ); +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// In test mode the singleton stays inert (no listeners, no bootstrap) so it +// doesn't shadow test-instantiated copies. Tests construct fresh +// `new PreferencesSync()` for the cases that need active listeners. +export const preferencesSync = new PreferencesSync( + import.meta.env.MODE === "test" +); + +// ── auto-bootstrap on import ───────────────────────────────────────────────── +// Mirrors theme.ts's autoApply pattern. Any page that imports this module +// (directly or transitively via theme.ts/config.ts) gets hydration. +// +// Skipped under vitest (MODE === 'test') so tests get a quiet module — they +// instantiate PreferencesSync directly with mocked fetch. +async function bootstrap(): Promise { + const { getCurrentUser } = await import("./client"); + const user = await getCurrentUser(); + await preferencesSync.hydrate(user); +} +if (import.meta.env.MODE !== "test") { + void bootstrap(); +} diff --git a/web/frontend/src/theme.ts b/web/frontend/src/theme.ts index b2bf321..51d3f53 100644 --- a/web/frontend/src/theme.ts +++ b/web/frontend/src/theme.ts @@ -3,6 +3,8 @@ * Import this module from every page entry point to apply saved themes on load. */ +import { preferencesSync } from "./auth/preferences-sync"; + // ── Types ──────────────────────────────────────────────────────────────────── export interface ThemeFont { @@ -173,6 +175,7 @@ const STORAGE_KEY = "glidecomp:theme"; export function saveTheme(theme: GlideCompTheme): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(theme)); + preferencesSync.schedulePush("theme"); } export function loadSavedTheme(): GlideCompTheme | null { @@ -188,6 +191,7 @@ export function loadSavedTheme(): GlideCompTheme | null { export function resetTheme(): void { localStorage.removeItem(STORAGE_KEY); applyTheme(AVOCADO_THEME); + preferencesSync.schedulePush("theme"); } // ── Apply ──────────────────────────────────────────────────────────────────── diff --git a/web/frontend/test-setup.ts b/web/frontend/test-setup.ts new file mode 100644 index 0000000..5aa6521 --- /dev/null +++ b/web/frontend/test-setup.ts @@ -0,0 +1,55 @@ +import { beforeAll, beforeEach } from "vitest"; + +// Node 22+ ships an experimental web-storage shim that's installed onto +// globalThis as an empty `{}` (no clear/setItem/getItem methods). It also +// shadows whatever jsdom would otherwise provide. We bypass both with a +// minimal in-memory Storage implementation that satisfies the spec surface +// our code uses. + +function createMemStorage(): Storage { + let store: Record = {}; + return { + get length() { + return Object.keys(store).length; + }, + clear() { + store = {}; + }, + getItem(key: string) { + return Object.prototype.hasOwnProperty.call(store, key) + ? store[key] + : null; + }, + key(index: number) { + return Object.keys(store)[index] ?? null; + }, + removeItem(key: string) { + delete store[key]; + }, + setItem(key: string, value: string) { + store[key] = String(value); + }, + } as Storage; +} + +beforeAll(() => { + const localShim = createMemStorage(); + const sessionShim = createMemStorage(); + for (const target of [globalThis, window]) { + Object.defineProperty(target, "localStorage", { + value: localShim, + writable: true, + configurable: true, + }); + Object.defineProperty(target, "sessionStorage", { + value: sessionShim, + writable: true, + configurable: true, + }); + } +}); + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); +}); diff --git a/web/frontend/vitest.config.ts b/web/frontend/vitest.config.ts new file mode 100644 index 0000000..44c572d --- /dev/null +++ b/web/frontend/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// Minimal vitest config — does NOT inherit vite.config.ts because that file +// sets `root: 'src'` and adds dev/build plugins (tailwind, SPA rewrites, +// sample-comp middleware) that aren't needed for tests and slow startup. +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + globals: false, + setupFiles: ["./test-setup.ts"], + }, +}); diff --git a/web/workers/auth-api/package.json b/web/workers/auth-api/package.json index 622b315..809ec7a 100644 --- a/web/workers/auth-api/package.json +++ b/web/workers/auth-api/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "wrangler dev --persist-to ../../.wrangler/state", "deploy": "wrangler deploy", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@better-auth/api-key": "^1.6.9", @@ -17,8 +18,10 @@ "kysely-d1": "^0.4.0" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.2", "@cloudflare/workers-types": "^4.20260509.1", "typescript": "^6.0.3", + "vitest": "^4.1.5", "wrangler": "4.87.0" } } diff --git a/web/workers/auth-api/src/index.ts b/web/workers/auth-api/src/index.ts index 712dcb0..4d78cd6 100644 --- a/web/workers/auth-api/src/index.ts +++ b/web/workers/auth-api/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { createAuth, isLocalDev, type AuthEnv } from "./auth"; +import { mountPreferencesRoutes } from "./routes/preferences"; const app = new Hono<{ Bindings: AuthEnv }>(); @@ -26,7 +27,7 @@ app.use( cors({ origin: (origin) => (origin && isAllowedOrigin(origin) ? origin : ""), credentials: true, - allowMethods: ["GET", "POST", "OPTIONS"], + allowMethods: ["GET", "POST", "PUT", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization"], }) ); @@ -172,6 +173,10 @@ app.post("/api/auth/dev-login", async (c) => { // GET /api/auth/me with the x-api-key header — enableSessionForAPIKeys makes // this return the user associated with the key. +// Per-user preferences storage (registered before the better-auth catch-all +// so /api/auth/preferences resolves here, not to better-auth's handler). +mountPreferencesRoutes(app); + // Better Auth catch-all handler app.all("/api/auth/*", async (c) => { const auth = createAuth(c.env); diff --git a/web/workers/auth-api/src/routes/preferences.ts b/web/workers/auth-api/src/routes/preferences.ts new file mode 100644 index 0000000..73a97c3 --- /dev/null +++ b/web/workers/auth-api/src/routes/preferences.ts @@ -0,0 +1,141 @@ +// Per-user preferences storage. Replaces the `glidecomp:preferences` and +// `glidecomp:theme` localStorage keys for authenticated users so settings +// sync across devices. +// +// Both blobs are opaque JSON owned by the client; the server only enforces +// size limits and stores them verbatim. + +import type { Hono } from "hono"; +import { createAuth, type AuthEnv } from "../auth"; + +// Cap the entire request body. Theme blobs are the larger of the two +// (~5-10KB with all fonts/colors); 64KB leaves generous headroom while +// preventing obvious abuse. +const MAX_BODY_BYTES = 64 * 1024; + +type PreferencesRow = { + prefs_json: string; + theme_json: string | null; + updated_at: string; +}; + +async function getSessionUser( + env: AuthEnv, + headers: Headers +): Promise<{ id: string } | null> { + const auth = createAuth(env); + const session = await auth.api.getSession({ headers }); + return session?.user ?? null; +} + +function parseStoredJson(text: string | null | undefined): unknown { + if (text == null) return null; + try { + return JSON.parse(text); + } catch { + // Stored value is corrupt — surface as null rather than 500ing. The + // client treats null/empty as "no override" and falls back to defaults. + return null; + } +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export function mountPreferencesRoutes(app: Hono<{ Bindings: AuthEnv }>) { + app.get("/api/auth/preferences", async (c) => { + const user = await getSessionUser(c.env, c.req.raw.headers); + if (!user) return c.json({ error: "Not authenticated" }, 401); + + const row = await c.env.glidecomp_auth + .prepare( + `SELECT prefs_json, theme_json, updated_at + FROM user_preferences WHERE user_id = ?` + ) + .bind(user.id) + .first(); + + if (!row) { + return c.json({ prefs: {}, theme: null, updated_at: null }); + } + + return c.json({ + prefs: parseStoredJson(row.prefs_json) ?? {}, + theme: parseStoredJson(row.theme_json), + updated_at: row.updated_at, + }); + }); + + app.put("/api/auth/preferences", async (c) => { + const user = await getSessionUser(c.env, c.req.raw.headers); + if (!user) return c.json({ error: "Not authenticated" }, 401); + + const raw = await c.req.text(); + if (raw.length > MAX_BODY_BYTES) { + return c.json({ error: "Request body too large" }, 413); + } + + let body: unknown; + try { + body = raw.length === 0 ? {} : JSON.parse(raw); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + if (!isPlainObject(body)) { + return c.json({ error: "Body must be a JSON object" }, 400); + } + + const hasPrefs = "prefs" in body; + const hasTheme = "theme" in body; + if (!hasPrefs && !hasTheme) { + return c.json( + { error: "Body must include 'prefs' and/or 'theme'" }, + 400 + ); + } + + if (hasPrefs && !isPlainObject(body.prefs)) { + return c.json({ error: "'prefs' must be a JSON object" }, 400); + } + if (hasTheme && body.theme !== null && !isPlainObject(body.theme)) { + return c.json({ error: "'theme' must be a JSON object or null" }, 400); + } + + // Partial update done atomically: a single INSERT … ON CONFLICT statement + // with CASE guards. CASE branches off `?` (1 = field present, 0 = absent) + // so absent fields preserve the existing column value. Two concurrent PUTs + // from different devices can no longer lose each other's updates. + // + // For the INSERT (no conflict) path we still need values for prefs_json / + // theme_json — fall back to the column defaults when the field is absent. + const prefsValue = hasPrefs ? JSON.stringify(body.prefs) : "{}"; + const themeValue = hasTheme + ? body.theme === null + ? null + : JSON.stringify(body.theme) + : null; + const now = new Date().toISOString(); + + await c.env.glidecomp_auth + .prepare( + `INSERT INTO user_preferences (user_id, prefs_json, theme_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + prefs_json = CASE WHEN ? = 1 THEN excluded.prefs_json ELSE prefs_json END, + theme_json = CASE WHEN ? = 1 THEN excluded.theme_json ELSE theme_json END, + updated_at = excluded.updated_at` + ) + .bind( + user.id, + prefsValue, + themeValue, + now, + hasPrefs ? 1 : 0, + hasTheme ? 1 : 0 + ) + .run(); + + return c.json({ updated_at: now }); + }); +} diff --git a/web/workers/auth-api/test/apply-migrations.ts b/web/workers/auth-api/test/apply-migrations.ts new file mode 100644 index 0000000..4e1a527 --- /dev/null +++ b/web/workers/auth-api/test/apply-migrations.ts @@ -0,0 +1,6 @@ +import { applyD1Migrations, env } from "cloudflare:test"; + +// Runs outside isolated storage — DDL persists across test files. Per-test +// data is wiped automatically between tests by the pool's storage isolation. +// applyD1Migrations is idempotent, safe to call multiple times. +await applyD1Migrations(env.glidecomp_auth, env.TEST_MIGRATIONS); diff --git a/web/workers/auth-api/test/env.d.ts b/web/workers/auth-api/test/env.d.ts new file mode 100644 index 0000000..3dce005 --- /dev/null +++ b/web/workers/auth-api/test/env.d.ts @@ -0,0 +1,8 @@ +import type { D1Migration } from "cloudflare:test"; +import type { AuthEnv } from "../src/auth"; + +declare module "cloudflare:test" { + interface ProvidedEnv extends AuthEnv { + TEST_MIGRATIONS: D1Migration[]; + } +} diff --git a/web/workers/auth-api/test/helpers.ts b/web/workers/auth-api/test/helpers.ts new file mode 100644 index 0000000..3ba613a --- /dev/null +++ b/web/workers/auth-api/test/helpers.ts @@ -0,0 +1,54 @@ +import { SELF } from "cloudflare:test"; + +/** + * Sign in via the dev-login endpoint and return a Cookie header value usable + * for subsequent authenticated requests. + * + * Better Auth issues cookies via Set-Cookie. We strip attributes (Path, HttpOnly, + * Expires, etc.) and join the bare name=value pairs with "; " — that's the + * format the Cookie request header wants. + */ +export async function loginAs( + email: string, + name: string = email +): Promise { + const res = await SELF.fetch("https://test/api/auth/dev-login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name }), + }); + if (!res.ok) { + throw new Error( + `dev-login failed: ${res.status} ${await res.text().catch(() => "")}` + ); + } + const setCookies = res.headers.getSetCookie(); + if (setCookies.length === 0) { + throw new Error("dev-login returned no Set-Cookie headers"); + } + return setCookies.map((sc) => sc.split(";")[0]).join("; "); +} + +/** Make a request to the worker. Pass `cookie` from loginAs() to authenticate. */ +export async function request( + method: string, + path: string, + options: { body?: unknown; cookie?: string; raw?: string } = {} +): Promise { + const headers: Record = {}; + if (options.body !== undefined || options.raw !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (options.cookie) { + headers["Cookie"] = options.cookie; + } + + const body = + options.raw !== undefined + ? options.raw + : options.body !== undefined + ? JSON.stringify(options.body) + : undefined; + + return SELF.fetch(`https://test${path}`, { method, headers, body }); +} diff --git a/web/workers/auth-api/test/preferences.test.ts b/web/workers/auth-api/test/preferences.test.ts new file mode 100644 index 0000000..d68a201 --- /dev/null +++ b/web/workers/auth-api/test/preferences.test.ts @@ -0,0 +1,362 @@ +import { env } from "cloudflare:test"; +import { describe, expect, test } from "vitest"; +import { loginAs, request } from "./helpers"; + +const SAMPLE_PREFS = { + units: { speed: "mph", altitude: "ft", distance: "mi", climbRate: "ft/min" }, + thresholds: { thermal: { minDuration: 60 } }, + mapProvider: "leaflet", +}; + +const SAMPLE_THEME = { + name: "Test Theme", + author: "Tester", + version: 1, + colors: { + background: "#000", + foreground: "#fff", + primary: "#f00", + }, + radius: "0.5rem", + buttonRadius: "0.25rem", + fonts: { + heading: { family: "Roboto", weight: 700, size: "2rem" }, + body: { family: "Roboto", weight: 400, size: "1rem" }, + button: { family: "Roboto", weight: 500, size: "1rem" }, + caption: { family: "Roboto", weight: 400, size: "0.875rem" }, + nav: { family: "Roboto", weight: 500, size: "1rem" }, + }, +}; + +// ── GET /api/auth/preferences ─────────────────────────────────────────────── + +describe("GET /api/auth/preferences", () => { + test("rejects unauthenticated request with 401", async () => { + const res = await request("GET", "/api/auth/preferences"); + expect(res.status).toBe(401); + }); + + test("returns empty defaults when no row exists", async () => { + const cookie = await loginAs("alice@test.com", "Alice"); + const res = await request("GET", "/api/auth/preferences", { cookie }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + prefs: {}, + theme: null, + updated_at: null, + }); + }); +}); + +// ── PUT /api/auth/preferences — auth + validation ─────────────────────────── + +describe("PUT /api/auth/preferences validation", () => { + test("rejects unauthenticated request with 401", async () => { + const res = await request("PUT", "/api/auth/preferences", { + body: { prefs: {} }, + }); + expect(res.status).toBe(401); + }); + + test("rejects empty body with 400", async () => { + const cookie = await loginAs("val-empty@test.com"); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + raw: "", + }); + expect(res.status).toBe(400); + }); + + test("rejects body with neither prefs nor theme", async () => { + const cookie = await loginAs("val-neither@test.com"); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + body: { other: "field" }, + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toMatch(/prefs.*theme/); + }); + + test("rejects malformed JSON", async () => { + const cookie = await loginAs("val-malformed@test.com"); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + raw: "{not json", + }); + expect(res.status).toBe(400); + }); + + test("rejects non-object body", async () => { + const cookie = await loginAs("val-array@test.com"); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + raw: "[1,2,3]", + }); + expect(res.status).toBe(400); + }); + + test("rejects prefs that is not an object", async () => { + const cookie = await loginAs("val-prefs-string@test.com"); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: "string-not-object" }, + }); + expect(res.status).toBe(400); + }); + + test("rejects theme that is not an object or null", async () => { + const cookie = await loginAs("val-theme-num@test.com"); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + body: { theme: 42 }, + }); + expect(res.status).toBe(400); + }); + + test("rejects oversized body with 413", async () => { + const cookie = await loginAs("val-oversized@test.com"); + // Build a prefs blob > 64KB + const huge = "x".repeat(70 * 1024); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: { padding: huge } }, + }); + expect(res.status).toBe(413); + }); +}); + +// ── PUT /api/auth/preferences — write/read roundtrips ─────────────────────── + +describe("PUT /api/auth/preferences roundtrips", () => { + test("PUT prefs then GET reflects the saved value", async () => { + const cookie = await loginAs("carol@test.com"); + const putRes = await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS }, + }); + expect(putRes.status).toBe(200); + const putData = (await putRes.json()) as { updated_at: string }; + expect(typeof putData.updated_at).toBe("string"); + + const getRes = await request("GET", "/api/auth/preferences", { cookie }); + expect(getRes.status).toBe(200); + const data = (await getRes.json()) as { + prefs: unknown; + theme: unknown; + updated_at: string; + }; + expect(data.prefs).toEqual(SAMPLE_PREFS); + expect(data.theme).toBeNull(); + expect(data.updated_at).toBe(putData.updated_at); + }); + + test("PUT theme then GET reflects the saved theme", async () => { + const cookie = await loginAs("dave@test.com"); + const putRes = await request("PUT", "/api/auth/preferences", { + cookie, + body: { theme: SAMPLE_THEME }, + }); + expect(putRes.status).toBe(200); + + const getRes = await request("GET", "/api/auth/preferences", { cookie }); + const data = (await getRes.json()) as { prefs: unknown; theme: unknown }; + expect(data.prefs).toEqual({}); + expect(data.theme).toEqual(SAMPLE_THEME); + }); + + test("PUT prefs and theme together saves both", async () => { + const cookie = await loginAs("eve@test.com"); + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS, theme: SAMPLE_THEME }, + }); + const getRes = await request("GET", "/api/auth/preferences", { cookie }); + const data = (await getRes.json()) as { prefs: unknown; theme: unknown }; + expect(data.prefs).toEqual(SAMPLE_PREFS); + expect(data.theme).toEqual(SAMPLE_THEME); + }); + + test("partial update: PUT prefs only does not clobber theme", async () => { + const cookie = await loginAs("frank@test.com"); + // First save both + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS, theme: SAMPLE_THEME }, + }); + // Then update only prefs + const newPrefs = { ...SAMPLE_PREFS, mapProvider: "mapbox" }; + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: newPrefs }, + }); + const getRes = await request("GET", "/api/auth/preferences", { cookie }); + const data = (await getRes.json()) as { prefs: unknown; theme: unknown }; + expect(data.prefs).toEqual(newPrefs); + expect(data.theme).toEqual(SAMPLE_THEME); + }); + + test("partial update: PUT theme only does not clobber prefs", async () => { + const cookie = await loginAs("grace@test.com"); + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS, theme: SAMPLE_THEME }, + }); + const newTheme = { ...SAMPLE_THEME, name: "Renamed" }; + await request("PUT", "/api/auth/preferences", { + cookie, + body: { theme: newTheme }, + }); + const getRes = await request("GET", "/api/auth/preferences", { cookie }); + const data = (await getRes.json()) as { prefs: unknown; theme: unknown }; + expect(data.prefs).toEqual(SAMPLE_PREFS); + expect(data.theme).toEqual(newTheme); + }); + + test("PUT theme=null clears the saved theme", async () => { + const cookie = await loginAs("henry@test.com"); + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS, theme: SAMPLE_THEME }, + }); + const putRes = await request("PUT", "/api/auth/preferences", { + cookie, + body: { theme: null }, + }); + expect(putRes.status).toBe(200); + + const getRes = await request("GET", "/api/auth/preferences", { cookie }); + const data = (await getRes.json()) as { prefs: unknown; theme: unknown }; + expect(data.prefs).toEqual(SAMPLE_PREFS); + expect(data.theme).toBeNull(); + }); + + test("updated_at advances on subsequent writes", async () => { + const cookie = await loginAs("ida@test.com"); + const r1 = await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS }, + }); + const t1 = ((await r1.json()) as { updated_at: string }).updated_at; + // Sleep 5ms so the second Date.now()-derived ISO string is strictly later. + // ISO 8601 with millisecond precision sorts lexicographically the same as + // chronologically, so plain string > works for the assertion. + await new Promise((r) => setTimeout(r, 5)); + const r2 = await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: { ...SAMPLE_PREFS, mapProvider: "mapbox" } }, + }); + const t2 = ((await r2.json()) as { updated_at: string }).updated_at; + expect(t2 > t1).toBe(true); + }); + + test("concurrent PUTs (prefs and theme) do not lose each other's update", async () => { + const cookie = await loginAs("concurrent@test.com"); + // Seed an empty row so both PUTs hit the ON CONFLICT branch (where the + // race used to live). Without this, one might INSERT and one UPDATE. + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: {}, theme: null }, + }); + + // Fire both PUTs without awaiting between them — each updates only its + // own field. Pre-fix this could lose one update (read-modify-write race). + const [r1, r2] = await Promise.all([ + request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS }, + }), + request("PUT", "/api/auth/preferences", { + cookie, + body: { theme: SAMPLE_THEME }, + }), + ]); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + + const get = await request("GET", "/api/auth/preferences", { cookie }); + const data = (await get.json()) as { prefs: unknown; theme: unknown }; + expect(data.prefs).toEqual(SAMPLE_PREFS); + expect(data.theme).toEqual(SAMPLE_THEME); + }); + + test("body of exactly 64KB is accepted (boundary)", async () => { + const cookie = await loginAs("boundary@test.com"); + // The route caps total body at 64 * 1024 chars — measure the wrapper's + // overhead and pad the prefs payload to land exactly at the cap. + const overhead = JSON.stringify({ prefs: { padding: "" } }).length; + const padding = "x".repeat(64 * 1024 - overhead); + const res = await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: { padding } }, + }); + expect(res.status).toBe(200); + }); + + test("two users do not see each other's preferences", async () => { + const cookieA = await loginAs("user-a@test.com", "User A"); + const cookieB = await loginAs("user-b@test.com", "User B"); + + await request("PUT", "/api/auth/preferences", { + cookie: cookieA, + body: { prefs: { owner: "A" } }, + }); + await request("PUT", "/api/auth/preferences", { + cookie: cookieB, + body: { prefs: { owner: "B" } }, + }); + + const getA = await request("GET", "/api/auth/preferences", { + cookie: cookieA, + }); + const getB = await request("GET", "/api/auth/preferences", { + cookie: cookieB, + }); + expect(((await getA.json()) as { prefs: unknown }).prefs).toEqual({ + owner: "A", + }); + expect(((await getB.json()) as { prefs: unknown }).prefs).toEqual({ + owner: "B", + }); + }); +}); + +// ── CASCADE on user delete ────────────────────────────────────────────────── + +describe("CASCADE behaviour", () => { + test("deleting the user row removes that user's preferences row", async () => { + const email = "cascade-target@test.com"; + const cookie = await loginAs(email); + await request("PUT", "/api/auth/preferences", { + cookie, + body: { prefs: SAMPLE_PREFS, theme: SAMPLE_THEME }, + }); + + // Look up the user_id and confirm a prefs row exists for it. + const userRow = await env.glidecomp_auth + .prepare('SELECT id FROM "user" WHERE email = ?') + .bind(email) + .first<{ id: string }>(); + expect(userRow).not.toBeNull(); + const userId = userRow!.id; + + const before = await env.glidecomp_auth + .prepare("SELECT user_id FROM user_preferences WHERE user_id = ?") + .bind(userId) + .first(); + expect(before).not.toBeNull(); + + // Delete the user (mimics what /api/auth/delete-account does) + await env.glidecomp_auth + .prepare('DELETE FROM "user" WHERE id = ?') + .bind(userId) + .run(); + + // Preferences row for this user should be gone via CASCADE + const after = await env.glidecomp_auth + .prepare("SELECT user_id FROM user_preferences WHERE user_id = ?") + .bind(userId) + .first(); + expect(after).toBeNull(); + }); +}); diff --git a/web/workers/auth-api/test/tsconfig.json b/web/workers/auth-api/test/tsconfig.json new file mode 100644 index 0000000..3d3dc70 --- /dev/null +++ b/web/workers/auth-api/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"] + }, + "include": ["**/*.ts", "../src/**/*.ts"] +} diff --git a/web/workers/auth-api/vitest.config.ts b/web/workers/auth-api/vitest.config.ts new file mode 100644 index 0000000..e26755a --- /dev/null +++ b/web/workers/auth-api/vitest.config.ts @@ -0,0 +1,42 @@ +import path from "node:path"; +import { + cloudflareTest, + readD1Migrations, +} from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig(async () => { + const migrations = await readD1Migrations( + path.join(__dirname, "../../db/migrations") + ); + + return { + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.toml" }, + miniflare: { + bindings: { + TEST_MIGRATIONS: migrations, + // Override prod vars so isLocalDev() returns true (enables + // dev-login + email/password) and Better Auth has the secrets + // it needs to issue/verify session cookies. + BETTER_AUTH_URL: "http://localhost:8788", + BETTER_AUTH_SECRET: + "test-secret-do-not-use-in-prod-1234567890abcdef", + GOOGLE_CLIENT_ID: "test-client-id", + GOOGLE_CLIENT_SECRET: "test-client-secret", + }, + }, + }), + ], + test: { + setupFiles: ["./test/apply-migrations.ts"], + include: ["test/**/*.test.ts"], + // Better Auth's dev-login signupEmail throws an internal "user already + // exists" rejection that escapes the route handler's try/catch on + // duplicate emails. We use unique emails per test, but suppress here + // as a safety net (matches competition-api's pattern). + dangerouslyIgnoreUnhandledErrors: true, + }, + }; +});