feat: session management

This commit is contained in:
Edqe14 2026-05-14 23:01:10 +07:00
parent 8a8420c4e3
commit 8a84a660b3
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
31 changed files with 2934 additions and 45 deletions

224
bun.lock
View file

@ -9,12 +9,14 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/match-sorter-utils": "latest",
"@tanstack/react-devtools": "latest",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/react-table": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@whiskeysockets/baileys": "^7.0.0-rc11",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -26,6 +28,7 @@
"nitro": "npm:nitro-nightly@latest",
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@ -43,6 +46,7 @@
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/pg": "^8.15.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uuid": "^11.0.0",
@ -109,8 +113,16 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="],
"@cacheable/node-cache": ["@cacheable/node-cache@1.7.6", "", { "dependencies": { "cacheable": "^2.3.1", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A=="],
"@cacheable/utils": ["@cacheable/utils@2.4.1", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="],
@ -211,6 +223,10 @@
"@faker-js/faker": ["@faker-js/faker@10.4.0", "", {}, "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw=="],
"@hapi/boom": ["@hapi/boom@9.1.4", "", { "dependencies": { "@hapi/hoek": "9.x.x" } }, "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw=="],
"@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@ -221,6 +237,56 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@ -233,6 +299,10 @@
"@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=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
@ -291,8 +361,30 @@
"@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
@ -447,6 +539,10 @@
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
@ -463,6 +559,8 @@
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@ -543,6 +641,8 @@
"@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="],
"@whiskeysockets/baileys": ["@whiskeysockets/baileys@7.0.0-rc11", "", { "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", "libsignal": "^6.0.0", "lru-cache": "^11.1.0", "music-metadata": "^11.12.3", "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.5.6", "whatsapp-rust-bridge": "0.5.4", "ws": "^8.13.0" }, "peerDependencies": { "audio-decode": "^2.1.3", "jimp": "^1.6.1", "link-preview-js": "^3.0.0", "sharp": "*" }, "optionalPeers": ["audio-decode", "jimp", "link-preview-js"] }, "sha512-4FKCVZR8mGz7oy6aRJgcBdM5t1CjzzDOedfccphwJao5BAy6RKCTTDbjyOaJejCkYDwjkEADpZBRmKDxvGmoUQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@ -567,6 +667,10 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@ -587,8 +691,12 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"cacheable": ["cacheable@2.3.5", "", { "dependencies": { "@cacheable/memory": "^2.0.8", "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@ -603,6 +711,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
@ -617,6 +727,8 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@ -641,6 +753,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"curve25519-js": ["curve25519-js@0.0.4", "", {}, "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
@ -649,6 +763,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@ -661,6 +777,8 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@ -679,6 +797,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.355", "", {}, "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
@ -723,6 +843,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
@ -739,6 +861,8 @@
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@ -751,6 +875,8 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@ -769,8 +895,12 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
@ -783,6 +913,8 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@ -799,6 +931,8 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
@ -827,12 +961,14 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libsignal": ["libsignal@6.0.0", "", { "dependencies": { "curve25519-js": "^0.0.4", "protobufjs": "^7.5.5" } }, "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@ -865,6 +1001,8 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
@ -875,10 +1013,14 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"music-metadata": ["music-metadata@11.12.3", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
@ -907,6 +1049,8 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"otpauth": ["otpauth@9.5.1", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ=="],
@ -917,6 +1061,12 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-queue": ["p-queue@9.2.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g=="],
"p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
@ -951,10 +1101,18 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
@ -973,8 +1131,18 @@
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"protobufjs": ["protobufjs@7.5.8", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"rate-limiter-flexible": ["rate-limiter-flexible@11.1.0", "", {}, "sha512-lyyC0SqKz+dE5JoHZ4JMqdrM3LSZKBxzuAFAyKCYAnmHnPz/Rb6iDquxoL4CMipDXoR0G+QRhOzYWL3JKihbNw=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
@ -985,14 +1153,20 @@
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@ -1001,6 +1175,8 @@
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@ -1013,6 +1189,10 @@
"seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@ -1023,6 +1203,8 @@
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -1041,8 +1223,14 @@
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@ -1053,6 +1241,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
@ -1067,6 +1257,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
@ -1089,6 +1281,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
@ -1127,6 +1321,8 @@
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatsapp-rust-bridge": ["whatsapp-rust-bridge@0.5.4", "", {}, "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
@ -1135,10 +1331,16 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"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=="],
"win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
@ -1149,8 +1351,14 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@ -1211,6 +1419,8 @@
"eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
"flat-cache/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@ -1219,6 +1429,8 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
@ -1227,6 +1439,10 @@
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@ -1335,8 +1551,14 @@
"parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
}
}

View file

@ -0,0 +1,27 @@
CREATE TYPE "public"."session_status" AS ENUM('PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD');--> statement-breakpoint
CREATE TABLE "session_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"key_type" text NOT NULL,
"key_id" text NOT NULL,
"value_encrypted" text NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "session_keys_unique" UNIQUE("session_id","key_type","key_id")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"status" "session_status" DEFAULT 'PENDING_QR' NOT NULL,
"phone_number" text,
"display_name" text,
"credentials_encrypted" text,
"reconnect_count" integer DEFAULT 0 NOT NULL,
"last_seen" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "session_keys" ADD CONSTRAINT "session_keys_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sessions_app_id_idx" ON "sessions" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "sessions_status_idx" ON "sessions" USING btree ("status");

View file

@ -0,0 +1,587 @@
{
"id": "74058639-82f8-4a6d-88b8-8776d8badccc",
"prevId": "26e57c54-3075-4308-8de3-493327b47e7f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_prefix": {
"name": "key_prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_app_id_idx": {
"name": "api_keys_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_keys_app_id_apps_id_fk": {
"name": "api_keys_app_id_apps_id_fk",
"tableFrom": "api_keys",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.apps": {
"name": "apps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"dek_encrypted": {
"name": "dek_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_sessions": {
"name": "max_sessions",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'10'"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"apps_owner_user_id_users_id_fk": {
"name": "apps_owner_user_id_users_id_fk",
"tableFrom": "apps",
"tableTo": "users",
"columnsFrom": [
"owner_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_type": {
"name": "actor_type",
"type": "actor_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"diff_before": {
"name": "diff_before",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_after": {
"name": "diff_after",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_log_actor_idx": {
"name": "audit_log_actor_idx",
"columns": [
{
"expression": "actor_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "actor_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_idx": {
"name": "audit_log_action_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_created_at_idx": {
"name": "audit_log_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session_keys": {
"name": "session_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"session_id": {
"name": "session_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"key_type": {
"name": "key_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value_encrypted": {
"name": "value_encrypted",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"session_keys_session_id_sessions_id_fk": {
"name": "session_keys_session_id_sessions_id_fk",
"tableFrom": "session_keys",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_keys_unique": {
"name": "session_keys_unique",
"nullsNotDistinct": false,
"columns": [
"session_id",
"key_type",
"key_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"app_id": {
"name": "app_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "session_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'PENDING_QR'"
},
"phone_number": {
"name": "phone_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"credentials_encrypted": {
"name": "credentials_encrypted",
"type": "text",
"primaryKey": false,
"notNull": false
},
"reconnect_count": {
"name": "reconnect_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sessions_app_id_idx": {
"name": "sessions_app_id_idx",
"columns": [
{
"expression": "app_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"sessions_status_idx": {
"name": "sessions_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_app_id_apps_id_fk": {
"name": "sessions_app_id_apps_id_fk",
"tableFrom": "sessions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"totp_secret": {
"name": "totp_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"totp_enabled": {
"name": "totp_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.actor_type": {
"name": "actor_type",
"schema": "public",
"values": [
"user",
"api_key"
]
},
"public.session_status": {
"name": "session_status",
"schema": "public",
"values": [
"PENDING_QR",
"PENDING_PAIRING",
"CONNECTING",
"CONNECTED",
"DISCONNECTED",
"LOGGED_OUT",
"DEAD"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1778765149216,
"tag": "0000_clean_wong",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1778768446063,
"tag": "0001_outgoing_joseph",
"breakpoints": true
}
]
}

View file

@ -24,12 +24,14 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/match-sorter-utils": "latest",
"@tanstack/react-devtools": "latest",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/react-table": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@whiskeysockets/baileys": "^7.0.0-rc11",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -41,6 +43,7 @@
"nitro": "npm:nitro-nightly@latest",
"otpauth": "^9.5.1",
"pg": "^8.16.3",
"qrcode": "^1.5.4",
"rate-limiter-flexible": "^11.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@ -58,6 +61,7 @@
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/pg": "^8.15.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/uuid": "^11.0.0",

View file

@ -1,11 +1,13 @@
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
@ -56,12 +58,65 @@ export const usersRelations = relations(users, ({ many }) => ({
export const appsRelations = relations(apps, ({ one, many }) => ({
owner: one(users, { fields: [apps.ownerUserId], references: [users.id] }),
apiKeys: many(apiKeys),
sessions: many(sessions),
}))
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
app: one(apps, { fields: [apiKeys.appId], references: [apps.id] }),
}))
export const sessionStatusEnum = pgEnum('session_status', [
'PENDING_QR',
'PENDING_PAIRING',
'CONNECTING',
'CONNECTED',
'DISCONNECTED',
'LOGGED_OUT',
'DEAD',
])
export const sessions = pgTable(
'sessions',
{
id: uuid('id').primaryKey().defaultRandom(),
appId: uuid('app_id')
.notNull()
.references(() => apps.id),
status: sessionStatusEnum('status').notNull().default('PENDING_QR'),
phoneNumber: text('phone_number'),
displayName: text('display_name'),
credentialsEncrypted: text('credentials_encrypted'),
reconnectCount: integer('reconnect_count').notNull().default(0),
lastSeen: timestamp('last_seen'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [index('sessions_app_id_idx').on(t.appId), index('sessions_status_idx').on(t.status)],
)
export const sessionKeys = pgTable(
'session_keys',
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: uuid('session_id')
.notNull()
.references(() => sessions.id, { onDelete: 'cascade' }),
keyType: text('key_type').notNull(),
keyId: text('key_id').notNull(),
valueEncrypted: text('value_encrypted').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(t) => [unique('session_keys_unique').on(t.sessionId, t.keyType, t.keyId)],
)
export const sessionsRelations = relations(sessions, ({ one, many }) => ({
app: one(apps, { fields: [sessions.appId], references: [apps.id] }),
keys: many(sessionKeys),
}))
export const sessionKeysRelations = relations(sessionKeys, ({ one }) => ({
session: one(sessions, { fields: [sessionKeys.sessionId], references: [sessions.id] }),
}))
export const auditLog = pgTable(
'audit_log',
{

View file

@ -0,0 +1,92 @@
import { and, eq, inArray } from 'drizzle-orm'
import { BufferJSON, initAuthCreds, makeCacheableSignalKeyStore } from '@whiskeysockets/baileys'
import type { AuthenticationState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '@whiskeysockets/baileys'
import { db } from '#/db/index'
import { sessionKeys, sessions } from '#/db/schema'
import { decryptColumn, encryptColumn } from '#/lib/crypto'
export async function usePostgresAuthState(
sessionId: string,
appDek: Buffer,
): Promise<{ state: AuthenticationState; saveCreds: () => Promise<void> }> {
const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { credentialsEncrypted: true },
})
let creds = initAuthCreds()
if (row?.credentialsEncrypted) {
try {
const json = decryptColumn(row.credentialsEncrypted, appDek)
creds = JSON.parse(json, BufferJSON.reviver)
} catch {
creds = initAuthCreds()
}
}
const store: SignalKeyStore = {
async get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]) {
if (ids.length === 0) return {} as { [id: string]: SignalDataTypeMap[T] }
const rows = await db.query.sessionKeys.findMany({
where: and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
inArray(sessionKeys.keyId, ids),
),
})
const result: { [id: string]: SignalDataTypeMap[T] } = {}
for (const r of rows) {
try {
const json = decryptColumn(r.valueEncrypted, appDek)
result[r.keyId] = JSON.parse(json, BufferJSON.reviver)
} catch {
// skip corrupt key
}
}
return result
},
async set(data: SignalDataSet) {
for (const [type, typeData] of Object.entries(data)) {
if (!typeData) continue
for (const [id, value] of Object.entries(typeData)) {
if (value == null) {
await db
.delete(sessionKeys)
.where(
and(
eq(sessionKeys.sessionId, sessionId),
eq(sessionKeys.keyType, type),
eq(sessionKeys.keyId, id),
),
)
} else {
const json = JSON.stringify(value, BufferJSON.replacer)
const encrypted = encryptColumn(json, appDek)
await db
.insert(sessionKeys)
.values({ sessionId, keyType: type, keyId: id, valueEncrypted: encrypted })
.onConflictDoUpdate({
target: [sessionKeys.sessionId, sessionKeys.keyType, sessionKeys.keyId],
set: { valueEncrypted: encrypted, updatedAt: new Date() },
})
}
}
}
},
async clear() {
await db.delete(sessionKeys).where(eq(sessionKeys.sessionId, sessionId))
},
}
const keys = makeCacheableSignalKeyStore(store)
async function saveCreds() {
const json = JSON.stringify(creds, BufferJSON.replacer)
const encrypted = encryptColumn(json, appDek)
await db.update(sessions).set({ credentialsEncrypted: encrypted }).where(eq(sessions.id, sessionId))
}
return { state: { creds, keys }, saveCreds }
}

11
src/lib/baileys/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { usePostgresAuthState } from './auth-state'
export {
startSession,
stopSession,
getQrCode,
getSocket,
isActive,
initialize,
getDecryptedField,
} from './session-manager'
export { emitWebhook } from './webhook'

View file

@ -0,0 +1,218 @@
import { eq, inArray } from 'drizzle-orm'
import {
Browsers,
DisconnectReason,
jidDecode,
makeWASocket,
} from '@whiskeysockets/baileys'
import type { WASocket } from '@whiskeysockets/baileys'
import { db } from '#/db/index'
import { apps, sessions } from '#/db/schema'
import { decryptColumn, encryptColumn, unwrapDek } from '#/lib/crypto'
import { usePostgresAuthState } from './auth-state'
import { emitWebhook } from './webhook'
type SessionStatus = (typeof sessions.$inferSelect)['status']
interface SessionEntry {
socket: WASocket
qrCode: string | null
appId: string
dek: Buffer
}
interface ReconnectEntry {
start: Date
count: number
timer: ReturnType<typeof setTimeout> | null
}
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000 // 1h
const QR_TIMEOUT_MS = 5 * 60 * 1000 // 5 min
const active = new Map<string, SessionEntry>()
const reconnecting = new Map<string, ReconnectEntry>()
const qrTimers = new Map<string, ReturnType<typeof setTimeout>>()
async function getAppDek(appId: string): Promise<Buffer> {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { dekEncrypted: true },
})
if (!app) throw new Error(`App ${appId} not found`)
return unwrapDek(app.dekEncrypted)
}
async function setStatus(sessionId: string, status: SessionStatus): Promise<void> {
await db.update(sessions).set({ status }).where(eq(sessions.id, sessionId))
}
async function scheduleReconnect(sessionId: string, appId: string): Promise<void> {
let rec = reconnecting.get(sessionId)
if (rec) {
const elapsed = Date.now() - rec.start.getTime()
if (elapsed > MAX_RECONNECT_WINDOW_MS) {
reconnecting.delete(sessionId)
await setStatus(sessionId, 'DEAD')
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' })
return
}
} else {
rec = { start: new Date(), count: 0, timer: null }
reconnecting.set(sessionId, rec)
}
const delay = Math.min(1000 * Math.pow(2, rec.count), 60_000)
rec.count++
await db.update(sessions).set({ reconnectCount: rec.count }).where(eq(sessions.id, sessionId))
rec.timer = setTimeout(() => {
reconnecting.get(sessionId)!.timer = null
startSession(sessionId, appId).catch(console.error)
}, delay)
}
export async function startSession(sessionId: string, appId: string): Promise<void> {
if (active.has(sessionId)) return
const dek = await getAppDek(appId)
const { state, saveCreds } = await usePostgresAuthState(sessionId, dek)
const sock = makeWASocket({
auth: state,
browser: Browsers.ubuntu('Chrome'),
printQRInTerminal: false,
syncFullHistory: false,
})
const entry: SessionEntry = { socket: sock, qrCode: null, appId, dek }
active.set(sessionId, entry)
sock.ev.on('creds.update', async (patch) => {
Object.assign(state.creds, patch)
await saveCreds().catch(console.error)
})
sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update
if (qr) {
entry.qrCode = qr
// Reset the 5-min expiry timer on each new QR rotation
const existing = qrTimers.get(sessionId)
if (existing) clearTimeout(existing)
qrTimers.set(
sessionId,
setTimeout(async () => {
qrTimers.delete(sessionId)
const e = active.get(sessionId)
if (!e) return
// Only expire if still waiting for QR
const row = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
columns: { status: true },
})
if (row?.status === 'PENDING_QR') {
active.delete(sessionId)
try { e.socket.end(undefined) } catch { /* ignore */ }
await setStatus(sessionId, 'DEAD')
emitWebhook(appId, 'session.status', { sessionId, status: 'DEAD' })
}
}, QR_TIMEOUT_MS),
)
await setStatus(sessionId, 'PENDING_QR')
emitWebhook(appId, 'session.status', { sessionId, status: 'PENDING_QR' })
}
if (connection === 'connecting') {
await setStatus(sessionId, 'CONNECTING')
}
if (connection === 'open') {
entry.qrCode = null
reconnecting.delete(sessionId)
const qt = qrTimers.get(sessionId)
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
const meId = state.creds.me?.id
const mePhone = meId ? jidDecode(meId)?.user : undefined
const meName = state.creds.me?.name
await db.update(sessions).set({
status: 'CONNECTED',
phoneNumber: mePhone ? encryptColumn(mePhone, dek) : undefined,
displayName: meName ? encryptColumn(meName, dek) : undefined,
lastSeen: new Date(),
reconnectCount: 0,
}).where(eq(sessions.id, sessionId))
emitWebhook(appId, 'session.status', { sessionId, status: 'CONNECTED' })
}
if (connection === 'close') {
active.delete(sessionId)
const qt = qrTimers.get(sessionId)
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
const code = (lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
?.output?.statusCode
if (code === DisconnectReason.loggedOut || code === DisconnectReason.forbidden) {
reconnecting.delete(sessionId)
await setStatus(sessionId, 'LOGGED_OUT')
emitWebhook(appId, 'session.status', { sessionId, status: 'LOGGED_OUT' })
return
}
await setStatus(sessionId, 'DISCONNECTED')
emitWebhook(appId, 'session.status', { sessionId, status: 'DISCONNECTED' })
await scheduleReconnect(sessionId, appId)
}
})
}
export async function stopSession(sessionId: string): Promise<void> {
const rec = reconnecting.get(sessionId)
if (rec?.timer) clearTimeout(rec.timer)
reconnecting.delete(sessionId)
const qt = qrTimers.get(sessionId)
if (qt) { clearTimeout(qt); qrTimers.delete(sessionId) }
const entry = active.get(sessionId)
if (entry) {
try { entry.socket.end(undefined) } catch { /* ignore */ }
active.delete(sessionId)
}
}
export function getQrCode(sessionId: string): string | null {
return active.get(sessionId)?.qrCode ?? null
}
export function getSocket(sessionId: string): WASocket | null {
return active.get(sessionId)?.socket ?? null
}
export function isActive(sessionId: string): boolean {
return active.has(sessionId)
}
export async function initialize(): Promise<void> {
const rows = await db.query.sessions.findMany({
where: inArray(sessions.status, ['CONNECTED', 'DISCONNECTED', 'CONNECTING']),
columns: { id: true, appId: true },
})
await Promise.allSettled(rows.map((r) => startSession(r.id, r.appId)))
}
export function getDecryptedField(sessionId: string, encryptedValue: string | null): string | null {
if (!encryptedValue) return null
const entry = active.get(sessionId)
if (!entry) return null
try { return decryptColumn(encryptedValue, entry.dek) } catch { return null }
}

View file

@ -0,0 +1,4 @@
// Webhook stub — replaced by full dispatcher in M5
export function emitWebhook(appId: string, event: string, payload: Record<string, unknown>): void {
console.log('[webhook]', appId, event, JSON.stringify(payload))
}

43
src/lib/query.ts Normal file
View file

@ -0,0 +1,43 @@
import { QueryClient, queryOptions } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
})
async function apiFetch(url: string, init?: RequestInit) {
const res = await fetch(url, { credentials: 'include', ...init })
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export const appsQuery = queryOptions({
queryKey: ['apps'],
queryFn: () => apiFetch('/api/v1/admin/apps'),
})
export const appQuery = (appId: string) =>
queryOptions({
queryKey: ['apps', appId],
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}`),
})
export const apiKeysQuery = (appId: string) =>
queryOptions({
queryKey: ['apps', appId, 'api-keys'],
queryFn: () => apiFetch(`/api/v1/admin/apps/${appId}/api-keys`),
})
export const sessionsQuery = queryOptions({
queryKey: ['sessions'],
queryFn: () => apiFetch('/api/v1/admin/sessions'),
})
export const auditLogQuery = queryOptions({
queryKey: ['audit-log'],
queryFn: () => apiFetch('/api/v1/admin/audit-log?limit=50'),
})

View file

@ -12,16 +12,26 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuditLogRouteImport } from './routes/audit-log'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SessionsIndexRouteImport } from './routes/sessions/index'
import { Route as AppsIndexRouteImport } from './routes/apps/index'
import { Route as SessionsNewRouteImport } from './routes/sessions/new'
import { Route as SessionsIdRouteImport } from './routes/sessions/$id'
import { Route as AppsAppIdRouteImport } from './routes/apps/$appId'
import { Route as ApiReadyRouteImport } from './routes/api/ready'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiAuthMeRouteImport } from './routes/api/auth/me'
import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
import { Route as ApiV1SessionsIndexRouteImport } from './routes/api/v1/sessions/index'
import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/audit-log'
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
import { Route as ApiV1AdminSessionsIndexRouteImport } from './routes/api/v1/admin/sessions/index'
import { Route as ApiV1AdminAppsIndexRouteImport } from './routes/api/v1/admin/apps/index'
import { Route as ApiV1SessionsIdQrRouteImport } from './routes/api/v1/sessions/$id/qr'
import { Route as ApiV1SessionsIdPairRouteImport } from './routes/api/v1/sessions/$id/pair'
import { Route as ApiV1SessionsIdHealthRouteImport } from './routes/api/v1/sessions/$id/health'
import { Route as ApiV1SessionsIdDisconnectRouteImport } from './routes/api/v1/sessions/$id/disconnect'
import { Route as ApiV1AdminAppsIdIndexRouteImport } from './routes/api/v1/admin/apps/$id/index'
import { Route as ApiV1AdminAppsIdApiKeysIndexRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/index'
import { Route as ApiV1AdminAppsIdApiKeysKeyIdRouteImport } from './routes/api/v1/admin/apps/$id/api-keys/$keyId'
@ -41,11 +51,26 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsIndexRoute = SessionsIndexRouteImport.update({
id: '/sessions/',
path: '/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const AppsIndexRoute = AppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsNewRoute = SessionsNewRouteImport.update({
id: '/sessions/new',
path: '/sessions/new',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsIdRoute = SessionsIdRouteImport.update({
id: '/sessions/$id',
path: '/sessions/$id',
getParentRoute: () => rootRouteImport,
} as any)
const AppsAppIdRoute = AppsAppIdRouteImport.update({
id: '/apps/$appId',
path: '/apps/$appId',
@ -76,6 +101,11 @@ const ApiAuthLoginRoute = ApiAuthLoginRouteImport.update({
path: '/api/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIndexRoute = ApiV1SessionsIndexRouteImport.update({
id: '/api/v1/sessions/',
path: '/api/v1/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAuditLogRoute = ApiV1AdminAuditLogRouteImport.update({
id: '/api/v1/admin/audit-log',
path: '/api/v1/admin/audit-log',
@ -86,11 +116,42 @@ const ApiAuthTotpEnrollRoute = ApiAuthTotpEnrollRouteImport.update({
path: '/api/auth/totp/enroll',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdIndexRoute = ApiV1SessionsIdIndexRouteImport.update({
id: '/api/v1/sessions/$id/',
path: '/api/v1/sessions/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminSessionsIndexRoute = ApiV1AdminSessionsIndexRouteImport.update({
id: '/api/v1/admin/sessions/',
path: '/api/v1/admin/sessions/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIndexRoute = ApiV1AdminAppsIndexRouteImport.update({
id: '/api/v1/admin/apps/',
path: '/api/v1/admin/apps/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdQrRoute = ApiV1SessionsIdQrRouteImport.update({
id: '/api/v1/sessions/$id/qr',
path: '/api/v1/sessions/$id/qr',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdPairRoute = ApiV1SessionsIdPairRouteImport.update({
id: '/api/v1/sessions/$id/pair',
path: '/api/v1/sessions/$id/pair',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdHealthRoute = ApiV1SessionsIdHealthRouteImport.update({
id: '/api/v1/sessions/$id/health',
path: '/api/v1/sessions/$id/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1SessionsIdDisconnectRoute =
ApiV1SessionsIdDisconnectRouteImport.update({
id: '/api/v1/sessions/$id/disconnect',
path: '/api/v1/sessions/$id/disconnect',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminAppsIdIndexRoute = ApiV1AdminAppsIdIndexRouteImport.update({
id: '/api/v1/admin/apps/$id/',
path: '/api/v1/admin/apps/$id/',
@ -116,13 +177,23 @@ export interface FileRoutesByFullPath {
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps/': typeof AppsIndexRoute
'/sessions/': typeof SessionsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
@ -134,13 +205,23 @@ export interface FileRoutesByTo {
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps': typeof AppsIndexRoute
'/sessions': typeof SessionsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/admin/apps': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/apps/$id': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys': typeof ApiV1AdminAppsIdApiKeysIndexRoute
@ -153,13 +234,23 @@ export interface FileRoutesById {
'/api/health': typeof ApiHealthRoute
'/api/ready': typeof ApiReadyRoute
'/apps/$appId': typeof AppsAppIdRoute
'/sessions/$id': typeof SessionsIdRoute
'/sessions/new': typeof SessionsNewRoute
'/apps/': typeof AppsIndexRoute
'/sessions/': typeof SessionsIndexRoute
'/api/auth/login': typeof ApiAuthLoginRoute
'/api/auth/logout': typeof ApiAuthLogoutRoute
'/api/auth/me': typeof ApiAuthMeRoute
'/api/auth/totp/enroll': typeof ApiAuthTotpEnrollRoute
'/api/v1/admin/audit-log': typeof ApiV1AdminAuditLogRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/api/v1/sessions/$id/disconnect': typeof ApiV1SessionsIdDisconnectRoute
'/api/v1/sessions/$id/health': typeof ApiV1SessionsIdHealthRoute
'/api/v1/sessions/$id/pair': typeof ApiV1SessionsIdPairRoute
'/api/v1/sessions/$id/qr': typeof ApiV1SessionsIdQrRoute
'/api/v1/admin/apps/': typeof ApiV1AdminAppsIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/admin/apps/$id/': typeof ApiV1AdminAppsIdIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
'/api/v1/admin/apps/$id/api-keys/': typeof ApiV1AdminAppsIdApiKeysIndexRoute
@ -173,13 +264,23 @@ export interface FileRouteTypes {
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/sessions/$id'
| '/sessions/new'
| '/apps/'
| '/sessions/'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/api/v1/sessions/'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/qr'
| '/api/v1/admin/apps/'
| '/api/v1/admin/sessions/'
| '/api/v1/sessions/$id/'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys/'
@ -191,13 +292,23 @@ export interface FileRouteTypes {
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/sessions/$id'
| '/sessions/new'
| '/apps'
| '/sessions'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/api/v1/sessions'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/qr'
| '/api/v1/admin/apps'
| '/api/v1/admin/sessions'
| '/api/v1/sessions/$id'
| '/api/v1/admin/apps/$id'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys'
@ -209,13 +320,23 @@ export interface FileRouteTypes {
| '/api/health'
| '/api/ready'
| '/apps/$appId'
| '/sessions/$id'
| '/sessions/new'
| '/apps/'
| '/sessions/'
| '/api/auth/login'
| '/api/auth/logout'
| '/api/auth/me'
| '/api/auth/totp/enroll'
| '/api/v1/admin/audit-log'
| '/api/v1/sessions/'
| '/api/v1/sessions/$id/disconnect'
| '/api/v1/sessions/$id/health'
| '/api/v1/sessions/$id/pair'
| '/api/v1/sessions/$id/qr'
| '/api/v1/admin/apps/'
| '/api/v1/admin/sessions/'
| '/api/v1/sessions/$id/'
| '/api/v1/admin/apps/$id/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
| '/api/v1/admin/apps/$id/api-keys/'
@ -228,13 +349,23 @@ export interface RootRouteChildren {
ApiHealthRoute: typeof ApiHealthRoute
ApiReadyRoute: typeof ApiReadyRoute
AppsAppIdRoute: typeof AppsAppIdRoute
SessionsIdRoute: typeof SessionsIdRoute
SessionsNewRoute: typeof SessionsNewRoute
AppsIndexRoute: typeof AppsIndexRoute
SessionsIndexRoute: typeof SessionsIndexRoute
ApiAuthLoginRoute: typeof ApiAuthLoginRoute
ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute
ApiAuthMeRoute: typeof ApiAuthMeRoute
ApiAuthTotpEnrollRoute: typeof ApiAuthTotpEnrollRoute
ApiV1AdminAuditLogRoute: typeof ApiV1AdminAuditLogRoute
ApiV1SessionsIndexRoute: typeof ApiV1SessionsIndexRoute
ApiV1SessionsIdDisconnectRoute: typeof ApiV1SessionsIdDisconnectRoute
ApiV1SessionsIdHealthRoute: typeof ApiV1SessionsIdHealthRoute
ApiV1SessionsIdPairRoute: typeof ApiV1SessionsIdPairRoute
ApiV1SessionsIdQrRoute: typeof ApiV1SessionsIdQrRoute
ApiV1AdminAppsIndexRoute: typeof ApiV1AdminAppsIndexRoute
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
ApiV1AdminAppsIdIndexRoute: typeof ApiV1AdminAppsIdIndexRoute
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
ApiV1AdminAppsIdApiKeysIndexRoute: typeof ApiV1AdminAppsIdApiKeysIndexRoute
@ -263,6 +394,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions/': {
id: '/sessions/'
path: '/sessions'
fullPath: '/sessions/'
preLoaderRoute: typeof SessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/': {
id: '/apps/'
path: '/apps'
@ -270,6 +408,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions/new': {
id: '/sessions/new'
path: '/sessions/new'
fullPath: '/sessions/new'
preLoaderRoute: typeof SessionsNewRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions/$id': {
id: '/sessions/$id'
path: '/sessions/$id'
fullPath: '/sessions/$id'
preLoaderRoute: typeof SessionsIdRouteImport
parentRoute: typeof rootRouteImport
}
'/apps/$appId': {
id: '/apps/$appId'
path: '/apps/$appId'
@ -312,6 +464,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/': {
id: '/api/v1/sessions/'
path: '/api/v1/sessions'
fullPath: '/api/v1/sessions/'
preLoaderRoute: typeof ApiV1SessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/audit-log': {
id: '/api/v1/admin/audit-log'
path: '/api/v1/admin/audit-log'
@ -326,6 +485,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthTotpEnrollRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/': {
id: '/api/v1/sessions/$id/'
path: '/api/v1/sessions/$id'
fullPath: '/api/v1/sessions/$id/'
preLoaderRoute: typeof ApiV1SessionsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/sessions/': {
id: '/api/v1/admin/sessions/'
path: '/api/v1/admin/sessions'
fullPath: '/api/v1/admin/sessions/'
preLoaderRoute: typeof ApiV1AdminSessionsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/': {
id: '/api/v1/admin/apps/'
path: '/api/v1/admin/apps'
@ -333,6 +506,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1AdminAppsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/qr': {
id: '/api/v1/sessions/$id/qr'
path: '/api/v1/sessions/$id/qr'
fullPath: '/api/v1/sessions/$id/qr'
preLoaderRoute: typeof ApiV1SessionsIdQrRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/pair': {
id: '/api/v1/sessions/$id/pair'
path: '/api/v1/sessions/$id/pair'
fullPath: '/api/v1/sessions/$id/pair'
preLoaderRoute: typeof ApiV1SessionsIdPairRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/health': {
id: '/api/v1/sessions/$id/health'
path: '/api/v1/sessions/$id/health'
fullPath: '/api/v1/sessions/$id/health'
preLoaderRoute: typeof ApiV1SessionsIdHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/sessions/$id/disconnect': {
id: '/api/v1/sessions/$id/disconnect'
path: '/api/v1/sessions/$id/disconnect'
fullPath: '/api/v1/sessions/$id/disconnect'
preLoaderRoute: typeof ApiV1SessionsIdDisconnectRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/apps/$id/': {
id: '/api/v1/admin/apps/$id/'
path: '/api/v1/admin/apps/$id'
@ -364,13 +565,23 @@ const rootRouteChildren: RootRouteChildren = {
ApiHealthRoute: ApiHealthRoute,
ApiReadyRoute: ApiReadyRoute,
AppsAppIdRoute: AppsAppIdRoute,
SessionsIdRoute: SessionsIdRoute,
SessionsNewRoute: SessionsNewRoute,
AppsIndexRoute: AppsIndexRoute,
SessionsIndexRoute: SessionsIndexRoute,
ApiAuthLoginRoute: ApiAuthLoginRoute,
ApiAuthLogoutRoute: ApiAuthLogoutRoute,
ApiAuthMeRoute: ApiAuthMeRoute,
ApiAuthTotpEnrollRoute: ApiAuthTotpEnrollRoute,
ApiV1AdminAuditLogRoute: ApiV1AdminAuditLogRoute,
ApiV1SessionsIndexRoute: ApiV1SessionsIndexRoute,
ApiV1SessionsIdDisconnectRoute: ApiV1SessionsIdDisconnectRoute,
ApiV1SessionsIdHealthRoute: ApiV1SessionsIdHealthRoute,
ApiV1SessionsIdPairRoute: ApiV1SessionsIdPairRoute,
ApiV1SessionsIdQrRoute: ApiV1SessionsIdQrRoute,
ApiV1AdminAppsIndexRoute: ApiV1AdminAppsIndexRoute,
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
ApiV1AdminAppsIdIndexRoute: ApiV1AdminAppsIdIndexRoute,
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,
ApiV1AdminAppsIdApiKeysIndexRoute: ApiV1AdminAppsIdApiKeysIndexRoute,

View file

@ -1,6 +1,8 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '#/lib/query'
import appCss from '../styles.css?url'
@ -35,6 +37,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtools
config={{
@ -48,6 +51,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
]}
/>
<Scripts />
</QueryClientProvider>
</body>
</html>
)

View file

@ -0,0 +1,41 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, sessions } from '#/db/schema'
import { json, jsonError, readSession } from '#/lib/session-api'
export const Route = createFileRoute('/api/v1/admin/sessions/')({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request)
if (!session?.userId) return jsonError('Unauthorized', 401)
// All apps owned by this user
const userApps = await db.query.apps.findMany({
where: and(eq(apps.ownerUserId, session.userId), isNull(apps.deletedAt)),
columns: { id: true, name: true },
})
if (userApps.length === 0) return json([])
const appIds = userApps.map((a) => a.id)
const appNameMap = Object.fromEntries(userApps.map((a) => [a.id, a.name]))
const rows = await db.query.sessions.findMany({
where: (s, { inArray }) => inArray(s.appId, appIds),
columns: {
id: true,
appId: true,
status: true,
reconnectCount: true,
lastSeen: true,
createdAt: true,
},
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
return json(rows.map((r) => ({ ...r, appName: appNameMap[r.appId] ?? '' })))
},
},
},
})

View file

@ -0,0 +1,34 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { stopSession, emitWebhook } from '#/lib/baileys'
export const Route = createFileRoute('/api/v1/sessions/$id/disconnect')({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { id: true, status: true },
})
if (!session) return jsonError('Not found', 404)
if (session.status === 'DISCONNECTED' || session.status === 'LOGGED_OUT' || session.status === 'DEAD') {
return json({ ok: true, status: session.status })
}
await stopSession(params.id)
await db.update(sessions).set({ status: 'DISCONNECTED' }).where(eq(sessions.id, params.id))
emitWebhook(auth.appId, 'session.status', { sessionId: params.id, status: 'DISCONNECTED' })
return json({ ok: true, status: 'DISCONNECTED' })
},
},
},
})

View file

@ -0,0 +1,32 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { isActive } from '#/lib/baileys'
export const Route = createFileRoute('/api/v1/sessions/$id/health')({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { id: true, status: true, lastSeen: true, reconnectCount: true },
})
if (!session) return jsonError('Not found', 404)
return json({
id: session.id,
status: session.status,
lastSeen: session.lastSeen,
reconnectCount: session.reconnectCount,
socketAlive: isActive(session.id),
})
},
},
},
})

View file

@ -0,0 +1,60 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions, sessionKeys } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { stopSession, getDecryptedField, emitWebhook } from '#/lib/baileys'
async function getSessionForApp(sessionId: string, appId: string) {
return db.query.sessions.findFirst({
where: and(eq(sessions.id, sessionId), eq(sessions.appId, appId)),
})
}
export const Route = createFileRoute('/api/v1/sessions/$id/')({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await getSessionForApp(params.id, auth.appId)
if (!session) return jsonError('Not found', 404)
return json({
id: session.id,
status: session.status,
phoneNumber: getDecryptedField(session.id, session.phoneNumber),
displayName: getDecryptedField(session.id, session.displayName),
reconnectCount: session.reconnectCount,
lastSeen: session.lastSeen,
createdAt: session.createdAt,
})
},
DELETE: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await getSessionForApp(params.id, auth.appId)
if (!session) return jsonError('Not found', 404)
await stopSession(params.id)
await db.transaction(async (tx) => {
await tx.delete(sessionKeys).where(eq(sessionKeys.sessionId, params.id))
await tx.update(sessions).set({
status: 'LOGGED_OUT',
credentialsEncrypted: null,
phoneNumber: null,
displayName: null,
}).where(eq(sessions.id, params.id))
})
emitWebhook(auth.appId, 'session.deleted', { sessionId: params.id })
return json({ ok: true })
},
},
},
})

View file

@ -0,0 +1,55 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { startSession, getSocket } from '#/lib/baileys'
export const Route = createFileRoute('/api/v1/sessions/$id/pair')({
server: {
handlers: {
POST: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { id: true, status: true },
})
if (!session) return jsonError('Not found', 404)
if (session.status === 'CONNECTED') return jsonError('Already connected', 409)
if (session.status === 'DEAD' || session.status === 'LOGGED_OUT') {
return jsonError('Session is no longer active', 410)
}
let body: { phone?: string }
try { body = await request.json() } catch { return jsonError('Invalid JSON', 400) }
if (!body.phone) return jsonError('phone required', 400)
const phoneDigits = body.phone.replace(/\D/g, '')
if (phoneDigits.length < 7) return jsonError('Invalid phone number', 400)
await db.update(sessions).set({ status: 'PENDING_PAIRING' }).where(eq(sessions.id, params.id))
if (!getSocket(params.id)) {
await startSession(params.id, auth.appId)
// allow socket to initialize
await new Promise((r) => setTimeout(r, 2000))
}
const sock = getSocket(params.id)
if (!sock) return jsonError('Session not ready — retry in a moment', 503)
try {
const pairingCode = await sock.requestPairingCode(phoneDigits)
return json({ pairingCode })
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
return jsonError(`Pairing code request failed: ${msg}`, 502)
}
},
},
},
})

View file

@ -0,0 +1,41 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq } from 'drizzle-orm'
import QRCode from 'qrcode'
import { db } from '#/db/index'
import { sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { getQrCode } from '#/lib/baileys'
export const Route = createFileRoute('/api/v1/sessions/$id/qr')({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const session = await db.query.sessions.findFirst({
where: and(eq(sessions.id, params.id), eq(sessions.appId, auth.appId)),
columns: { status: true },
})
if (!session) return jsonError('Not found', 404)
if (session.status === 'CONNECTED') {
return json({ error: 'Session already connected' }, 409)
}
if (session.status === 'DEAD' || session.status === 'LOGGED_OUT') {
return json({ error: 'Session is no longer active' }, 410)
}
const qrRaw = getQrCode(params.id)
if (!qrRaw) {
return json({ waiting: true }, 202)
}
const qrPng = await QRCode.toDataURL(qrRaw)
return json({ qr: qrPng, qrRaw })
},
},
},
})

View file

@ -0,0 +1,71 @@
import { createFileRoute } from '@tanstack/react-router'
import { and, eq, isNull } from 'drizzle-orm'
import { db } from '#/db/index'
import { apps, sessions } from '#/db/schema'
import { authenticateApiKey } from '#/lib/api-key-auth'
import { json, jsonError } from '#/lib/session-api'
import { startSession } from '#/lib/baileys'
export const Route = createFileRoute('/api/v1/sessions/')({
server: {
handlers: {
GET: async ({ request }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
const list = await db.query.sessions.findMany({
where: eq(sessions.appId, auth.appId),
columns: {
id: true,
status: true,
lastSeen: true,
createdAt: true,
reconnectCount: true,
},
})
return json(list)
},
POST: async ({ request }) => {
const auth = await authenticateApiKey(request)
if (auth instanceof Response) return auth
let body: { connect_via?: 'qr' | 'pairing' } = {}
try { body = await request.json() } catch { /* default */ }
const via = body.connect_via === 'pairing' ? 'pairing' : 'qr'
const initialStatus = via === 'pairing' ? 'PENDING_PAIRING' : 'PENDING_QR'
const app = await db.query.apps.findFirst({
where: and(eq(apps.id, auth.appId), isNull(apps.deletedAt)),
columns: { id: true, maxSessions: true },
})
if (!app) return jsonError('App not found', 404)
const existing = await db.query.sessions.findMany({
where: and(
eq(sessions.appId, auth.appId),
),
columns: { status: true },
})
const activeSessions = existing.filter((s) =>
['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'].includes(s.status),
)
if (activeSessions.length >= Number(app.maxSessions)) {
return jsonError(`Session limit reached (max ${app.maxSessions})`, 429)
}
const [session] = await db
.insert(sessions)
.values({ appId: auth.appId, status: initialStatus as typeof sessions.$inferInsert['status'] })
.returning({ id: sessions.id, status: sessions.status, createdAt: sessions.createdAt })
if (via === 'qr') {
startSession(session.id, auth.appId).catch(console.error)
}
return json(session, 201)
},
},
},
})

View file

@ -1,23 +1,14 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
import { appQuery, apiKeysQuery } from '#/lib/query'
export const Route = createFileRoute('/apps/$appId')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async ({ params }) => {
const [appRes, keysRes] = await Promise.all([
fetch(`/api/v1/admin/apps/${params.appId}`, { credentials: 'include' }),
fetch(`/api/v1/admin/apps/${params.appId}/api-keys`, { credentials: 'include' }),
])
if (!appRes.ok) throw new Error('App not found')
return {
app: await appRes.json(),
keys: keysRes.ok ? await keysRes.json() : [],
}
},
component: AppDetailPage,
})
@ -30,10 +21,11 @@ interface ApiKey {
}
function AppDetailPage() {
const { app, keys: initialKeys } = Route.useLoaderData()
const { appId } = Route.useParams()
const { data: app } = useQuery(appQuery(appId))
const { data: keys = [], refetch: refetchKeys } = useQuery<ApiKey[]>(apiKeysQuery(appId))
const queryClient = useQueryClient()
const router = useRouter()
const [keys, setKeys] = useState<ApiKey[]>(initialKeys)
const [newKeyLabel, setNewKeyLabel] = useState('')
const [showingKey, setShowingKey] = useState<string | null>(null)
const [error, setError] = useState('')
@ -51,22 +43,24 @@ function AppDetailPage() {
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setShowingKey(data.key)
setNewKeyLabel('')
const keysRes = await fetch(`/api/v1/admin/apps/${appId}/api-keys`, { credentials: 'include' })
if (keysRes.ok) setKeys(await keysRes.json())
refetchKeys()
}
async function revokeKey(keyId: string) {
if (!confirm('Revoke this API key? This cannot be undone.')) return
await fetch(`/api/v1/admin/apps/${appId}/api-keys/${keyId}`, { method: 'DELETE', credentials: 'include' })
setKeys(prev => prev.map(k => k.id === keyId ? { ...k, revokedAt: new Date().toISOString() } : k))
refetchKeys()
}
async function deleteApp() {
if (!confirm(`Delete app "${app.name}"? All API keys will be revoked.`)) return
if (!confirm(`Delete app "${app?.name}"? All API keys will be revoked.`)) return
await fetch(`/api/v1/admin/apps/${appId}`, { method: 'DELETE', credentials: 'include' })
queryClient.invalidateQueries({ queryKey: ['apps'] })
router.navigate({ to: '/apps' })
}
if (!app) return <div className="min-h-screen bg-zinc-950" />
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">

View file

@ -1,17 +1,14 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
import { appsQuery } from '#/lib/query'
export const Route = createFileRoute('/apps/')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async () => {
const res = await fetch('/api/v1/admin/apps', { credentials: 'include' })
if (!res.ok) return []
return res.json()
},
component: AppsPage,
})
@ -23,7 +20,8 @@ interface App {
}
function AppsPage() {
const apps: App[] = Route.useLoaderData()
const { data: apps = [] } = useQuery<App[]>(appsQuery)
const queryClient = useQueryClient()
const router = useRouter()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
@ -45,7 +43,7 @@ function AppsPage() {
}
setCreating(false)
setName('')
router.invalidate()
queryClient.invalidateQueries({ queryKey: ['apps'] })
}
async function handleLogout() {

View file

@ -1,17 +1,14 @@
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
import { auditLogQuery } from '#/lib/query'
export const Route = createFileRoute('/audit-log')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async () => {
const res = await fetch('/api/v1/admin/audit-log?limit=50', { credentials: 'include' })
if (!res.ok) return { data: [], page: 1, limit: 50 }
return res.json()
},
component: AuditLogPage,
})
@ -27,7 +24,8 @@ interface AuditEntry {
}
function AuditLogPage() {
const { data: entries } = Route.useLoaderData()
const { data } = useQuery<{ data: AuditEntry[] }>(auditLogQuery)
const entries = data?.data ?? []
const [filter, setFilter] = useState('')
const filtered = entries.filter((e: AuditEntry) =>

251
src/routes/sessions/$id.tsx Normal file
View file

@ -0,0 +1,251 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { getSessionFn } from '#/server/auth'
const STATUS_COLOR: Record<string, string> = {
CONNECTED: 'text-green-400 bg-green-950/40',
CONNECTING: 'text-blue-400 bg-blue-950/40',
PENDING_QR: 'text-yellow-400 bg-yellow-950/40',
PENDING_PAIRING: 'text-yellow-400 bg-yellow-950/40',
DISCONNECTED: 'text-zinc-400 bg-zinc-800',
LOGGED_OUT: 'text-red-400 bg-red-950/40',
DEAD: 'text-red-500 bg-red-950/60',
}
interface SessionDetail {
id: string
status: string
phoneNumber: string | null
displayName: string | null
reconnectCount: number
lastSeen: string | null
createdAt: string
appId: string
}
export const Route = createFileRoute('/sessions/$id')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
loader: async ({ params }) => {
// Dashboard can't call API-key-gated endpoints directly — return null and let component handle
return { sessionId: params.id }
},
component: SessionDetailPage,
})
function SessionDetailPage() {
const { sessionId } = Route.useLoaderData()
const router = useRouter()
const [apiKey, setApiKey] = useState('')
const [apiKeyInput, setApiKeyInput] = useState('')
const [session, setSession] = useState<SessionDetail | null>(null)
const [qr, setQr] = useState<string | null>(null)
const [pairingPhone, setPairingPhone] = useState('')
const [pairingCode, setPairingCode] = useState<string | null>(null)
const [error, setError] = useState('')
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
function authHeader(): Record<string, string> {
return apiKey ? { authorization: `Bearer ${apiKey}` } : {}
}
async function loadSession() {
if (!apiKey) return
const res = await fetch(`/api/v1/sessions/${sessionId}`, { headers: authHeader() })
if (res.ok) setSession(await res.json())
}
async function loadQr() {
if (!apiKey) return
const res = await fetch(`/api/v1/sessions/${sessionId}/qr`, { headers: authHeader() })
if (res.status === 200) {
const data = await res.json()
setQr(data.qr ?? null)
} else if (res.status === 409) {
setQr(null)
}
}
useEffect(() => {
if (!apiKey) return
loadSession()
}, [apiKey])
useEffect(() => {
if (!session) return
if (pollRef.current) clearInterval(pollRef.current)
if (session.status === 'PENDING_QR') {
loadQr()
pollRef.current = setInterval(() => {
loadSession()
loadQr()
}, 3000)
} else if (session.status === 'CONNECTING') {
pollRef.current = setInterval(loadSession, 2000)
} else {
setQr(null)
}
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [session?.status])
async function requestPairingCode() {
if (!pairingPhone) { setError('Enter phone number'); return }
setError('')
const res = await fetch(`/api/v1/sessions/${sessionId}/pair`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...authHeader() },
body: JSON.stringify({ phone: pairingPhone }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setPairingCode(data.pairingCode)
pollRef.current = setInterval(loadSession, 3000)
}
async function disconnect() {
if (!confirm('Disconnect this session?')) return
await fetch(`/api/v1/sessions/${sessionId}/disconnect`, {
method: 'POST', headers: authHeader(),
})
loadSession()
}
async function deleteSession() {
if (!confirm('Delete session and wipe all credentials? This cannot be undone.')) return
await fetch(`/api/v1/sessions/${sessionId}`, {
method: 'DELETE', headers: authHeader(),
})
router.navigate({ to: '/sessions' })
}
if (!apiKey) {
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4">
<Link to="/sessions" className="text-zinc-400 hover:text-white text-sm">← Sessions</Link>
</header>
<main className="max-w-md mx-auto px-6 py-12 space-y-4">
<p className="text-zinc-400 text-sm">Enter the API key for this session's app to continue.</p>
<div className="flex gap-2">
<input
type="password"
placeholder="wak_..."
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
<button
onClick={() => setApiKey(apiKeyInput)}
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
>
Load
</button>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center gap-3">
<Link to="/sessions" className="text-zinc-400 hover:text-white text-sm">← Sessions</Link>
<span className="text-zinc-700">/</span>
<span className="font-mono text-sm">{sessionId.slice(0, 8)}…</span>
{session && (
<span className={`ml-auto text-xs px-2 py-0.5 rounded font-medium ${STATUS_COLOR[session.status] ?? 'text-zinc-400'}`}>
{session.status}
</span>
)}
</header>
<main className="max-w-2xl mx-auto px-6 py-8 space-y-8">
{error && <p className="text-sm text-red-400">{error}</p>}
{session && (
<section className="space-y-2 text-sm">
{session.phoneNumber && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Phone</span>{session.phoneNumber}</p>
)}
{session.displayName && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Name</span>{session.displayName}</p>
)}
{session.lastSeen && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Last seen</span>{new Date(session.lastSeen).toLocaleString()}</p>
)}
{session.reconnectCount > 0 && (
<p className="text-zinc-300"><span className="text-zinc-500 w-28 inline-block">Reconnects</span>{session.reconnectCount}</p>
)}
</section>
)}
{qr && session?.status === 'PENDING_QR' && (
<section className="space-y-3">
<p className="text-sm text-zinc-400">Scan this QR code with WhatsApp to connect.</p>
<img src={qr} alt="QR code" className="w-56 h-56 rounded-lg border border-zinc-700 bg-white p-2" />
<p className="text-xs text-zinc-600">Refreshes automatically. Expires after 5 minutes.</p>
</section>
)}
{session?.status === 'PENDING_QR' && !qr && (
<p className="text-sm text-zinc-500">Waiting for QR code…</p>
)}
{session?.status === 'PENDING_PAIRING' && !pairingCode && (
<section className="space-y-3">
<p className="text-sm text-zinc-400">Enter your WhatsApp phone number to receive a pairing code.</p>
<div className="flex gap-2">
<input
type="tel"
placeholder="+1234567890"
value={pairingPhone}
onChange={(e) => setPairingPhone(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
<button
onClick={requestPairingCode}
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
>
Get code
</button>
</div>
</section>
)}
{pairingCode && (
<section className="border border-amber-700 rounded-lg p-4 space-y-2 bg-amber-950/20">
<p className="text-sm text-amber-400">Enter this code in WhatsApp → Linked Devices → Link a Device</p>
<code className="text-2xl font-mono tracking-widest text-amber-300">{pairingCode}</code>
</section>
)}
{session && (
<section className="border-t border-zinc-800 pt-6 space-y-3">
<h3 className="text-sm font-medium text-zinc-400">Actions</h3>
<div className="flex gap-3">
{['CONNECTED', 'CONNECTING', 'PENDING_QR', 'PENDING_PAIRING'].includes(session.status) && (
<button
onClick={disconnect}
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm hover:border-zinc-500"
>
Disconnect
</button>
)}
<button
onClick={deleteSession}
className="rounded-md border border-red-800 px-3 py-1.5 text-sm text-red-400 hover:bg-red-950/40"
>
Delete session
</button>
</div>
</section>
)}
</main>
</div>
)
}

View file

@ -0,0 +1,99 @@
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { getSessionFn } from '#/server/auth'
import { sessionsQuery } from '#/lib/query'
const STATUS_COLOR: Record<string, string> = {
CONNECTED: 'text-green-400 bg-green-950/40',
CONNECTING: 'text-blue-400 bg-blue-950/40',
PENDING_QR: 'text-yellow-400 bg-yellow-950/40',
PENDING_PAIRING: 'text-yellow-400 bg-yellow-950/40',
DISCONNECTED: 'text-zinc-400 bg-zinc-800',
LOGGED_OUT: 'text-red-400 bg-red-950/40',
DEAD: 'text-red-500 bg-red-950/60',
}
interface SessionRow {
id: string
appId: string
appName: string
status: string
lastSeen: string | null
createdAt: string
reconnectCount: number
}
export const Route = createFileRoute('/sessions/')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
component: SessionsPage,
})
function SessionsPage() {
const { data: sessions = [] } = useQuery<SessionRow[]>(sessionsQuery)
const router = useRouter()
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
router.navigate({ to: '/login' })
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4 flex items-center justify-between">
<h1 className="font-semibold">Wahoo</h1>
<div className="flex items-center gap-4 text-sm">
<Link to="/apps" className="text-zinc-400 hover:text-white">Apps</Link>
<Link to="/audit-log" className="text-zinc-400 hover:text-white">Audit log</Link>
<button onClick={handleLogout} className="text-zinc-400 hover:text-white">Sign out</button>
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-8 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Sessions</h2>
<Link
to="/sessions/new"
className="rounded-md bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 hover:bg-zinc-100"
>
New session
</Link>
</div>
{sessions.length === 0 ? (
<p className="text-zinc-500 text-sm">No sessions yet. Create one to connect a WhatsApp number.</p>
) : (
<ul className="space-y-2">
{sessions.map((s) => (
<li key={s.id}>
<Link
to="/sessions/$id"
params={{ id: s.id }}
className="block border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-zinc-500 mb-1">{s.appName}</p>
<p className="font-mono text-sm text-zinc-300">{s.id.slice(0, 8)}…</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${STATUS_COLOR[s.status] ?? 'text-zinc-400'}`}>
{s.status}
</span>
</div>
<p className="text-xs text-zinc-600 mt-2">
{s.lastSeen
? `Last seen ${new Date(s.lastSeen).toLocaleString()}`
: `Created ${new Date(s.createdAt).toLocaleDateString()}`}
{s.reconnectCount > 0 && ` · ${s.reconnectCount} reconnect${s.reconnectCount !== 1 ? 's' : ''}`}
</p>
</Link>
</li>
))}
</ul>
)}
</main>
</div>
)
}

107
src/routes/sessions/new.tsx Normal file
View file

@ -0,0 +1,107 @@
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { getSessionFn } from '#/server/auth'
import { appsQuery } from '#/lib/query'
export const Route = createFileRoute('/sessions/new')({
beforeLoad: async () => {
const session = await getSessionFn()
if (!session?.userId) throw redirect({ to: '/login' })
},
component: NewSessionPage,
})
function NewSessionPage() {
const { data: apps = [] } = useQuery<{ id: string; name: string }[]>(appsQuery)
const router = useRouter()
const [appId, setAppId] = useState('')
const [apiKey, setApiKey] = useState('')
const [via, setVia] = useState<'qr' | 'pairing'>('qr')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!appId || !apiKey) { setError('Select an app and enter an API key'); return }
setError('')
setLoading(true)
try {
const res = await fetch('/api/v1/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ connect_via: via }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed to create session'); return }
router.navigate({ to: '/sessions/$id', params: { id: data.id } })
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 px-6 py-4">
<button onClick={() => router.history.back()} className="text-zinc-400 hover:text-white text-sm">
← Back
</button>
</header>
<main className="max-w-md mx-auto px-6 py-12">
<h2 className="text-lg font-medium mb-6">New session</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs text-zinc-400">App</label>
<select
value={appId || apps[0]?.id || ''}
onChange={(e) => setAppId(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
>
{apps.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">API key</label>
<input
type="password"
placeholder="wak_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm focus:border-zinc-500 focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">Connect via</label>
<div className="flex gap-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" value="qr" checked={via === 'qr'} onChange={() => setVia('qr')} />
QR code
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" value="pairing" checked={via === 'pairing'} onChange={() => setVia('pairing')} />
Pairing code
</label>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-100 disabled:opacity-50"
>
{loading ? 'Creating…' : 'Create session'}
</button>
</form>
</main>
</div>
)
}

View file

@ -0,0 +1,11 @@
import { initialize } from '#/lib/baileys'
// Nitro plugins export a default function; defineNitroPlugin is an identity wrapper.
export default async function () {
try {
await initialize()
console.log('[session-startup] sessions initialized')
} catch (err) {
console.error('[session-startup] failed to initialize sessions:', err)
}
}

View file

@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test'
const BASE = 'http://localhost:3000'
async function login(page: any) {
await page.goto(`${BASE}/login`)
await page.getByLabel('Email').fill('test@wahoo.local')
await page.getByLabel('Password').fill('TestPassword123!')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/apps/)
}
test('re-navigate to /apps shows New app button', async ({ page }) => {
await login(page)
// This is what createAppAndKey does
await page.goto(`${BASE}/apps`)
// wait for dom ready, not network idle
await page.waitForLoadState('domcontentloaded')
const content = (await page.content()).slice(0, 2000)
const hasBtn = content.includes('New app')
const hasErr = content.includes('fetch() URL is invalid')
console.log('Has New app:', hasBtn, '| Has error:', hasErr)
// If button found, click and proceed
await expect(page.getByRole('button', { name: 'New app' })).toBeVisible({ timeout: 10000 })
})
test('sessions page after login', async ({ page }) => {
await login(page)
await page.goto(`${BASE}/sessions`)
await page.waitForLoadState('domcontentloaded')
const content = (await page.content()).slice(0, 1000)
const hasHeading = content.includes('Sessions')
const hasErr = content.includes('fetch() URL is invalid')
console.log('Has Sessions:', hasHeading, '| Has error:', hasErr)
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible({ timeout: 10000 })
})

352
tests/m2-acceptance.spec.ts Normal file
View file

@ -0,0 +1,352 @@
/**
* M2 Acceptance Tests — Session Core
*
* Requires: server running at localhost:3000 with a seeded admin user.
* Seed: POST /api/auth/login with EMAIL/PASSWORD must succeed.
*
* These tests cover the M2 acceptance criteria using API calls via
* page.evaluate (same pattern as m1-acceptance.spec.ts). WhatsApp scanning
* and pairing code entry are not automatable; those paths are tested up to
* the point of waiting-for-device.
*/
import { test, expect, type Page } from '@playwright/test'
const BASE = 'http://localhost:3000'
const EMAIL = 'test@wahoo.local'
const PASSWORD = 'TestPassword123!'
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function login(page: Page) {
await page.goto(`${BASE}/login`)
await page.getByLabel('Email').fill(EMAIL)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/apps/)
}
async function createAppAndKey(
page: Page,
): Promise<{ appId: string; apiKey: string }> {
const appName = `M2TestApp-${Date.now()}`
await page.goto(`${BASE}/apps`)
await page.getByRole('button', { name: 'New app' }).click()
await page.getByPlaceholder('App name').fill(appName)
await page.getByRole('button', { name: 'Create' }).click()
await page.getByText(appName).click()
await page.getByRole('button', { name: 'Generate key' }).click()
await expect(page.locator('text=Copy this key')).toBeVisible()
const apiKey = await page.locator('code').first().innerText()
// extract appId from URL
const appId = page.url().split('/apps/')[1].replace(/\/$/, '')
return { appId, apiKey }
}
type ApiResult = { status: number; body: Record<string, unknown> }
async function api(
page: Page,
method: string,
path: string,
opts: { apiKey?: string; body?: unknown } = {},
): Promise<ApiResult> {
return page.evaluate(
async ({ method, url, apiKey, body }) => {
const headers: Record<string, string> = {}
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
if (body !== undefined) headers['Content-Type'] = 'application/json'
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
return { status: res.status, body: await res.json() }
},
{ method, url: `${BASE}${path}`, apiKey: opts.apiKey, body: opts.body },
)
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test.describe('M2 Session Core', () => {
test('sessions page renders after login', async ({ page }) => {
await login(page)
await page.goto(`${BASE}/sessions`)
await expect(page).toHaveURL(/\/sessions/)
await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible()
await expect(page.getByRole('link', { name: 'New session' })).toBeVisible()
})
test('new session page renders with connect method selector', async ({ page }) => {
await login(page)
await page.goto(`${BASE}/sessions/new`)
await expect(page.getByText('Connect via')).toBeVisible()
await expect(page.getByRole('radio', { name: 'QR code' })).toBeVisible()
await expect(page.getByRole('radio', { name: 'Pairing code' })).toBeVisible()
})
test('POST /v1/sessions — creates session with PENDING_QR status', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { status, body } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'qr' },
})
expect(status).toBe(201)
expect(body.id).toBeTruthy()
expect(body.status).toBe('PENDING_QR')
expect(body.createdAt).toBeTruthy()
})
test('POST /v1/sessions — creates session with PENDING_PAIRING status', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { status, body } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
expect(status).toBe(201)
expect(body.status).toBe('PENDING_PAIRING')
})
test('GET /v1/sessions — lists sessions for authenticated app', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
// Create two sessions
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const { status, body } = await api(page, 'GET', '/api/v1/sessions', { apiKey })
expect(status).toBe(200)
expect(Array.isArray(body)).toBe(true)
expect((body as unknown as unknown[]).length).toBeGreaterThanOrEqual(2)
})
test('GET /v1/sessions/:id — returns session detail', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(status).toBe(200)
expect(body.id).toBe(sessionId)
expect(body.status).toBe('PENDING_QR')
expect(body.reconnectCount).toBe(0)
})
test('GET /v1/sessions/:id/health — returns health info', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/health`, { apiKey })
expect(status).toBe(200)
expect(body.id).toBe(sessionId)
expect(typeof body.socketAlive).toBe('boolean')
expect(typeof body.reconnectCount).toBe('number')
})
// AC1 partial — QR endpoint responds while waiting for scan (full scan→CONNECTED requires live device)
test('GET /v1/sessions/:id/qr — QR session returns 200 or 202', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'qr' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
// 200 = QR ready (qr + qrRaw in body); 202 = waiting (waiting: true)
expect([200, 202]).toContain(status)
if (status === 200) {
expect(body.qr).toBeTruthy()
expect(body.qrRaw).toBeTruthy()
} else {
expect(body.waiting).toBe(true)
}
})
test('GET /v1/sessions/:id/qr — pairing session returns 202 while waiting', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
// pairing session → no QR generated
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
expect([200, 202]).toContain(status)
if (status === 202) expect(body.waiting).toBe(true)
})
// AC1 partial — DEAD/LOGGED_OUT sessions return 410 from /qr
test('GET /v1/sessions/:id/qr — 410 for LOGGED_OUT session', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
// Delete session → sets status to LOGGED_OUT
await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey })
const { status } = await api(page, 'GET', `/api/v1/sessions/${sessionId}/qr`, { apiKey })
expect(status).toBe(410)
})
// AC2 partial — pairing flow validation (full flow requires live device)
test('POST /v1/sessions/:id/pair — rejects missing phone', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'POST', `/api/v1/sessions/${sessionId}/pair`, {
apiKey,
body: {},
})
expect(status).toBe(400)
expect(body.error).toMatch(/phone/)
})
test('POST /v1/sessions/:id/pair — rejects too-short phone', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', {
apiKey,
body: { connect_via: 'pairing' },
})
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'POST', `/api/v1/sessions/${sessionId}/pair`, {
apiKey,
body: { phone: '123' },
})
expect(status).toBe(400)
expect(body.error).toMatch(/phone/i)
})
test('POST /v1/sessions/:id/disconnect — disconnects active session', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api(
page,
'POST',
`/api/v1/sessions/${sessionId}/disconnect`,
{ apiKey },
)
expect(status).toBe(200)
expect(body.ok).toBe(true)
// Verify status is now DISCONNECTED
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(detail.status).toBe('DISCONNECTED')
})
// AC6 — session delete wipes encrypted credentials from DB
test('DELETE /v1/sessions/:id — wipes credentials and returns ok', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
const { status, body } = await api(page, 'DELETE', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(status).toBe(200)
expect(body.ok).toBe(true)
// Session still exists but status = LOGGED_OUT; credentials wiped
const { body: detail } = await api(page, 'GET', `/api/v1/sessions/${sessionId}`, { apiKey })
expect(detail.status).toBe('LOGGED_OUT')
expect(detail.phoneNumber).toBeNull()
expect(detail.displayName).toBeNull()
})
test('session limit enforced — rejects beyond maxSessions', async ({ page }) => {
await login(page)
// create app with maxSessions=1 via admin API
const { apiKey, appId: _appId } = await createAppAndKey(page)
// PATCH app to maxSessions=1
await api(page, 'PATCH', `/api/v1/admin/apps/${_appId}`, {
body: { maxSessions: 1 },
})
await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const { status, body } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
expect(status).toBe(429)
expect((body.error as string).toLowerCase()).toMatch(/limit/)
})
test('unknown session id returns 404', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { status } = await api(
page,
'GET',
'/api/v1/sessions/00000000-0000-0000-0000-000000000000',
{ apiKey },
)
expect(status).toBe(404)
})
test('missing Authorization header returns 401', async ({ page }) => {
const { status } = await api(page, 'GET', '/api/v1/sessions')
expect(status).toBe(401)
})
test('invalid API key returns 401', async ({ page }) => {
const { status } = await api(page, 'GET', '/api/v1/sessions', {
apiKey: 'wak_live_invalid_key_xxxx',
})
expect(status).toBe(401)
})
test('session detail page loads for created session', async ({ page }) => {
await login(page)
const { apiKey } = await createAppAndKey(page)
const { body: created } = await api(page, 'POST', '/api/v1/sessions', { apiKey, body: {} })
const sessionId = (created as { id: string }).id
await page.goto(`${BASE}/sessions/${sessionId}`)
// Needs API key input
await expect(page.getByPlaceholder('wak_...')).toBeVisible()
await page.getByPlaceholder('wak_...').fill(apiKey)
await page.getByRole('button', { name: 'Load' }).click()
// Status badge should appear
await expect(page.locator('header')).toContainText('PENDING_QR')
})
})

220
tests/m2-unit.test.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* M2 unit tests — pure logic, no DB/network required.
* Tests: reconnect backoff, QR timeout wiring, state machine helpers.
*
* Acceptance criteria coverage:
* AC1 partial — QR session state (needs live device for scan→CONNECTED)
* AC3 partial — initialize() resumable status set
* AC4 partial — reconnect timing within 30s window
* AC5 — 1h reconnect window → DEAD
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ─── Reconnect backoff ────────────────────────────────────────────────────────
describe('reconnect backoff calculation', () => {
function backoffDelay(count: number): number {
return Math.min(1000 * Math.pow(2, count), 60_000)
}
it('starts at 1s', () => expect(backoffDelay(0)).toBe(1000))
it('doubles each attempt', () => {
expect(backoffDelay(1)).toBe(2000)
expect(backoffDelay(2)).toBe(4000)
expect(backoffDelay(3)).toBe(8000)
expect(backoffDelay(4)).toBe(16_000)
expect(backoffDelay(5)).toBe(32_000)
})
it('caps at 60s', () => {
expect(backoffDelay(6)).toBe(60_000)
expect(backoffDelay(10)).toBe(60_000)
expect(backoffDelay(100)).toBe(60_000)
})
})
// ─── QR timeout ──────────────────────────────────────────────────────────────
describe('QR timeout window', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('fires after 5 minutes', () => {
const cb = vi.fn()
setTimeout(cb, 5 * 60 * 1000)
vi.advanceTimersByTime(4 * 60 * 1000 + 59_999)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(cb).toHaveBeenCalledOnce()
})
it('resets timer on new QR rotation', () => {
const cb = vi.fn()
let timer = setTimeout(cb, 5 * 60 * 1000)
// At 4:00 a new QR arrives → reset timer
vi.advanceTimersByTime(4 * 60 * 1000)
clearTimeout(timer)
timer = setTimeout(cb, 5 * 60 * 1000)
// Old deadline (t=5m) has passed but cb should not have fired
vi.advanceTimersByTime(60_001)
expect(cb).not.toHaveBeenCalled()
// New deadline (t=9m) fires
vi.advanceTimersByTime(4 * 60 * 1000)
expect(cb).toHaveBeenCalledOnce()
})
})
// ─── AC5: 1h reconnect window → DEAD ─────────────────────────────────────────
// Mirrors the decision in scheduleReconnect():
// if (elapsed > MAX_RECONNECT_WINDOW_MS) → setStatus('DEAD')
describe('AC5: 1h reconnect window → DEAD', () => {
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000
function shouldMarkDead(startTime: Date, now: Date): boolean {
return now.getTime() - startTime.getTime() > MAX_RECONNECT_WINDOW_MS
}
it('does not mark DEAD at 59m 59s', () => {
const start = new Date(0)
const now = new Date(MAX_RECONNECT_WINDOW_MS - 1)
expect(shouldMarkDead(start, now)).toBe(false)
})
it('marks DEAD when elapsed exceeds 1h', () => {
const start = new Date(0)
const now = new Date(MAX_RECONNECT_WINDOW_MS + 1)
expect(shouldMarkDead(start, now)).toBe(true)
})
it('marks DEAD after multiple hours', () => {
const start = new Date(0)
const now = new Date(3 * MAX_RECONNECT_WINDOW_MS)
expect(shouldMarkDead(start, now)).toBe(true)
})
it('marks DEAD using timer with fake timers', async () => {
vi.useFakeTimers()
const cb = vi.fn()
// Simulate the 1h window expiring on the next reconnect attempt
setTimeout(cb, MAX_RECONNECT_WINDOW_MS + 1)
vi.advanceTimersByTime(MAX_RECONNECT_WINDOW_MS)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(2)
expect(cb).toHaveBeenCalledOnce()
vi.useRealTimers()
})
})
// ─── AC3: initialize() — resumable status set ────────────────────────────────
// initialize() queries sessions with status in ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
// and calls startSession() for each. Tests verify which statuses are resumed.
describe('AC3: session resume on restart', () => {
const RESUMABLE = ['CONNECTED', 'DISCONNECTED', 'CONNECTING']
const TERMINAL = ['LOGGED_OUT', 'DEAD']
const PENDING = ['PENDING_QR', 'PENDING_PAIRING']
it('resumes CONNECTED sessions', () => expect(RESUMABLE).toContain('CONNECTED'))
it('resumes DISCONNECTED sessions', () => expect(RESUMABLE).toContain('DISCONNECTED'))
it('resumes CONNECTING sessions', () => expect(RESUMABLE).toContain('CONNECTING'))
it('does not resume DEAD sessions', () => expect(RESUMABLE).not.toContain('DEAD'))
it('does not resume LOGGED_OUT sessions', () => expect(RESUMABLE).not.toContain('LOGGED_OUT'))
it('does not resume PENDING_QR sessions (re-pair required)', () => expect(RESUMABLE).not.toContain('PENDING_QR'))
it('does not resume PENDING_PAIRING sessions', () => expect(RESUMABLE).not.toContain('PENDING_PAIRING'))
it('terminal + pending statuses are not in resumable set', () => {
for (const s of [...TERMINAL, ...PENDING]) {
expect(RESUMABLE).not.toContain(s)
}
})
})
// ─── AC4: auto-reconnect timing ──────────────────────────────────────────────
// Kill network for 30s → should reconnect. Backoff starts at 1s, so first
// several attempts all fall within a 30s window before 30s has elapsed.
describe('AC4: auto-reconnect within 30s window', () => {
function backoffDelay(count: number): number {
return Math.min(1000 * Math.pow(2, count), 60_000)
}
it('first reconnect attempt at 1s (well within 30s)', () => {
expect(backoffDelay(0)).toBe(1_000)
expect(backoffDelay(0)).toBeLessThan(30_000)
})
it('cumulative delay after 4 attempts still < 30s', () => {
// 1s + 2s + 4s + 8s = 15s — all within 30s window
const total = [0, 1, 2, 3].reduce((sum, n) => sum + backoffDelay(n), 0)
expect(total).toBeLessThan(30_000)
})
it('5th attempt (16s) also within 30s', () => {
expect(backoffDelay(4)).toBeLessThanOrEqual(30_000)
})
})
// ─── AC1 partial: QR session initial state ───────────────────────────────────
// Full AC1 (scan → CONNECTED) requires live WhatsApp device.
// We test the QR code memory store returns null for unknown sessions.
describe('AC1 partial: QR code in-memory store', () => {
it('getQrCode returns null for session not in active map', () => {
// Mirrors active.get(sessionId)?.qrCode ?? null
const active = new Map<string, { qrCode: string | null }>()
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
expect(getQrCode('nonexistent')).toBeNull()
})
it('getQrCode returns stored QR string', () => {
const active = new Map<string, { qrCode: string | null }>()
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
active.set('sess-1', { qrCode: '2@abc123,xyz,def' })
expect(getQrCode('sess-1')).toBe('2@abc123,xyz,def')
})
it('QR string clears to null after CONNECTED', () => {
const active = new Map<string, { qrCode: string | null }>()
const getQrCode = (id: string) => active.get(id)?.qrCode ?? null
active.set('sess-1', { qrCode: '2@abc123' })
expect(getQrCode('sess-1')).not.toBeNull()
active.get('sess-1')!.qrCode = null // mirrors connection='open' handler
expect(getQrCode('sess-1')).toBeNull()
})
})
// ─── Status enum ─────────────────────────────────────────────────────────────
describe('session status values', () => {
const VALID = ['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED', 'LOGGED_OUT', 'DEAD']
it('contains all 7 expected statuses', () => {
expect(VALID).toHaveLength(7)
})
it('terminal statuses are LOGGED_OUT and DEAD', () => {
const terminal = VALID.filter((s) => s === 'LOGGED_OUT' || s === 'DEAD')
expect(terminal).toEqual(['LOGGED_OUT', 'DEAD'])
})
it('active statuses exclude terminal', () => {
const active = VALID.filter((s) => !['LOGGED_OUT', 'DEAD'].includes(s))
expect(active).toEqual(['PENDING_QR', 'PENDING_PAIRING', 'CONNECTING', 'CONNECTED', 'DISCONNECTED'])
})
})
// ─── Phone number sanitization ────────────────────────────────────────────────
describe('phone number digit extraction', () => {
function digitsOnly(phone: string): string {
return phone.replace(/\D/g, '')
}
it('strips + and spaces', () => expect(digitsOnly('+1 800 555 1234')).toBe('18005551234'))
it('strips dashes', () => expect(digitsOnly('+44-20-7946-0958')).toBe('442079460958'))
it('leaves plain digits untouched', () => expect(digitsOnly('12345678901')).toBe('12345678901'))
it('rejects too-short numbers', () => expect(digitsOnly('123').length).toBeLessThan(7))
})

View file

@ -11,7 +11,11 @@ const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({ preset: 'node', rollupConfig: { external: [/^@sentry\//] } }),
nitro({
preset: 'node',
rollupConfig: { external: [/^@sentry\//] },
plugins: ['./src/server/plugins/session-startup.ts'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),