diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93cc892..2a8fb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run format:check @@ -24,8 +22,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run lint:check @@ -34,8 +30,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run test:run @@ -44,7 +38,5 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run typecheck diff --git a/README.md b/README.md index a5b9c59..859aa1c 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ Example names combine the authentication flow and storage, with a transport suff | Flow | TanStack Start | Next.js | Bun | Convex | | --- | --- | --- | --- | --- | -| `otp` | [memory](examples/tanstack-start-react/otp-memory), postgres (planned) | [memory](examples/nextjs/otp-memory) | [memory-cookie](examples/bun-react/otp-memory-cookie), [memory-header](examples/bun-react/otp-memory-header) | [Convex](examples/convex-react/otp) | +| `otp` | postgres (planned) | [memory](examples/nextjs/otp-memory) | [memory-cookie](examples/bun-react/otp-memory-cookie), [memory-header](examples/bun-react/otp-memory-header) | [Convex](examples/convex-react/otp) | | `passkey` | [memory](examples/tanstack-start-react/passkey-memory), postgres (planned) | — | — | — | | `otp-passkey` | [memory](examples/tanstack-start-react/otp-passkey-memory), postgres (planned) | — | — | — | | `otp-passkey-strict` | [memory](examples/tanstack-start-react/otp-passkey-strict-memory), postgres (planned) | — | — | — | | `passkey-otp` | [memory](examples/tanstack-start-react/passkey-otp-memory), postgres (planned) | — | — | — | +| `kitchen-sink` | [memory](examples/tanstack-start-react/kitchen-sink) | — | — | — | Memory storage is for demos. Its state is local to each server instance and is lost on restart. diff --git a/bun.lock b/bun.lock index a0b993e..f2d5227 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "typescript": "^6.0.3", - "vitest": "^5.0.2", + "vitest": "^5.0.3", }, }, "examples/bun-react/otp-memory-cookie": { @@ -47,6 +47,16 @@ "@types/react-dom": "^19.3.0", }, }, + "examples/bun/otp": { + "name": "@repo/example-bun-spike-otp", + "dependencies": { + "@repo/spike": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^6.0.3", + }, + }, "examples/convex-react/otp": { "name": "@repo/example-convex-otp", "dependencies": { @@ -64,7 +74,7 @@ "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1", + "vite": "^8.3.2", }, }, "examples/nextjs/otp-memory": { @@ -73,14 +83,14 @@ "dependencies": { "@repo/shared-react": "workspace:*", "authax": "workspace:*", - "next": "^16.3.6", + "next": "^16.3.8", "react": "^19.3.0", "react-dom": "^19.3.0", "zod": "^4.6.5", }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "babel-plugin-react-compiler": "^1.0.0", @@ -109,26 +119,26 @@ "typescript": "^6.0.3", }, }, - "examples/tanstack-start-react/otp-memory": { - "name": "@repo/example-tanstack-otp", + "examples/tanstack-start-react/kitchen-sink": { + "name": "@repo/example-tanstack-kitchen-sink", "dependencies": { "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "zod": "^4.6.5", }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1", + "vite": "^8.3.2", }, }, "examples/tanstack-start-react/otp-passkey-memory": { @@ -137,8 +147,8 @@ "@repo/shared-react": "workspace:*", "@repo/shared-webauthn": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "authax": "workspace:*", "react": "^19.3.0", "react-dom": "^19.3.0", @@ -146,12 +156,12 @@ "zod": "^4.6.5", }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1", + "vite": "^8.3.2", }, }, "examples/tanstack-start-react/otp-passkey-strict-memory": { @@ -160,8 +170,8 @@ "@repo/shared-react": "workspace:*", "@repo/shared-webauthn": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "authax": "workspace:*", "react": "^19.3.0", "react-dom": "^19.3.0", @@ -169,58 +179,56 @@ "zod": "^4.6.5", }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1", + "vite": "^8.3.2", }, }, "examples/tanstack-start-react/passkey-memory": { "name": "@repo/example-tanstack-passkey", "dependencies": { "@repo/shared-react": "workspace:*", - "@repo/shared-webauthn": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "zod": "^4.6.5", }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1", + "vite": "^8.3.2", }, }, "examples/tanstack-start-react/passkey-otp-memory": { "name": "@repo/example-tanstack-passkey-otp", "dependencies": { "@repo/shared-react": "workspace:*", - "@repo/shared-webauthn": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "zod": "^4.6.5", }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1", + "vite": "^8.3.2", }, }, "packages/authax": { @@ -228,20 +236,23 @@ "version": "0.0.1", "devDependencies": { "@eslint/js": "^10.0.1", - "@tanstack/react-start": "^1.168.58", - "eslint": "^10.11.0", - "neon-testing": "^3.0.1", - "typescript-eslint": "^8.70.1", + "@tanstack/react-start": "^1.168.60", + "eslint": "^10.12.0", + "neon-testing": "^3.0.2", + "typescript-eslint": "^8.71.0", }, "peerDependencies": { "@tanstack/react-start": "^1.161.1", - "next": "^16.3.6", + "next": "^16.3.8", }, "optionalPeers": [ "@tanstack/react-start", "next", ], }, + "packages/spike": { + "name": "@repo/spike", + }, }, "trustedDependencies": [ "sharp", @@ -367,57 +378,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@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=="], @@ -435,23 +446,23 @@ "@neondatabase/api-client": ["@neondatabase/api-client@2.7.3", "", { "dependencies": { "axios": "^1.13.5" } }, "sha512-hqJmcd8qeCNjcsSNlz77peGIwB9SLj8NC6kK6AjPRyb4bUvXB+TCmffx5893/0oRHYjqpua0nBLa5ql8z6acmw=="], - "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="], + "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg=="], "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], @@ -485,17 +496,19 @@ "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], - "@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], "@repo/example-bun-otp": ["@repo/example-bun-otp@workspace:examples/bun-react/otp-memory-cookie"], "@repo/example-bun-otp-header": ["@repo/example-bun-otp-header@workspace:examples/bun-react/otp-memory-header"], + "@repo/example-bun-spike-otp": ["@repo/example-bun-spike-otp@workspace:examples/bun/otp"], + "@repo/example-convex-otp": ["@repo/example-convex-otp@workspace:examples/convex-react/otp"], "@repo/example-nextjs-otp": ["@repo/example-nextjs-otp@workspace:examples/nextjs/otp-memory"], - "@repo/example-tanstack-otp": ["@repo/example-tanstack-otp@workspace:examples/tanstack-start-react/otp-memory"], + "@repo/example-tanstack-kitchen-sink": ["@repo/example-tanstack-kitchen-sink@workspace:examples/tanstack-start-react/kitchen-sink"], "@repo/example-tanstack-otp-passkey": ["@repo/example-tanstack-otp-passkey@workspace:examples/tanstack-start-react/otp-passkey-memory"], @@ -509,35 +522,37 @@ "@repo/shared-webauthn": ["@repo/shared-webauthn@workspace:examples/shared/webauthn"], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], + "@repo/spike": ["@repo/spike@workspace:packages/spike"], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.11", "", { "os": "linux", "cpu": "arm" }, "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -577,37 +592,37 @@ "@tanstack/history": ["@tanstack/history@1.162.4", "", {}, "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ=="], - "@tanstack/react-router": ["@tanstack/react-router@1.170.39", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.0", "@tanstack/router-core": "1.171.32", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wLZi/hXW/kTeLlMIvI2VB8Oj38GLz8YkHmTM14lLScAzx79UZEwBHWMj6JZw/imuyuhrcmbe8KTFsFBHzJUftg=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], - "@tanstack/react-start": ["@tanstack/react-start@1.168.58", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/react-start-client": "1.168.37", "@tanstack/react-start-rsc": "0.1.57", "@tanstack/react-start-server": "1.167.44", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.32", "@tanstack/start-plugin-core": "1.171.47", "@tanstack/start-server-core": "1.169.37", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-PYIUFOsGp98QvqbW6GDkRqjAZNYJ1c+rA0pGtEwb6oDQeECnrbLij1NbGLPed/nVnROyBNwvq/+rAK3Hb3j9eg=="], + "@tanstack/react-start": ["@tanstack/react-start@1.168.60", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/react-start-client": "1.168.39", "@tanstack/react-start-rsc": "0.1.59", "@tanstack/react-start-server": "1.167.46", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-server-core": "1.169.39", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-WMOM0Xlh2lPbVlbjYM6uOxOpiaWRPvODm/dwkLsGKq5tjyHOHZmKckJgwlrZl7OQUqFlOZHYj8bKUTgoeg8cUQ=="], - "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.37", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/router-core": "1.171.32", "@tanstack/start-client-core": "1.170.32" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-koHPF9H/a94Akn33nSUKPEeRIs/JpZvR3ekYxKnUQshl6N24R5kTr7PVkT+qcPxqD+OAu2sKjdBqIXvKLVQjzQ=="], + "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.39", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Uby0uo37JlQgUKHU0snDwH9nYiEeAjm9+7pUOFgycU/UszEILC2SO2DSCoNnCMuHKj9z+4URKzBYIP2exT9+wg=="], - "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.57", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/router-core": "1.171.32", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.32", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.47", "@tanstack/start-storage-context": "1.167.34", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-NIGJ8gktn6XDqDsUtW7onYWNLAO85ygpKXnfYdQZE3lQIFZLTzeI9Qcm67w1h4vRuxhsUNUSleMyhBqsmF8tLw=="], + "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.59", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-storage-context": "1.167.36", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-M2Lqxkk5C1zQncQs5paqX+/OLdOht51pg6D84YfLMrNsjhU/nzb6wd92MIja0mi5i23+L+fxLLjQYR2DObjBlw=="], - "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.44", "", { "dependencies": { "@tanstack/react-router": "1.170.39", "@tanstack/router-core": "1.171.32", "@tanstack/start-server-core": "1.169.37" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-eIdyRoq+rTNpCRx9xYJQaGHQmIUpTR80BGSmRy7pk/BbbdfXmyEgB570LDA/vAHySM1gh6PSTKfwGHBJ9FeqtQ=="], + "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.46", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-server-core": "1.169.39" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-K7z8xBOEdp8u5CZCxWrbS0tFtk5XZGMdhssLeZlRZKlLDbX7BKLdMtJ7th5sR6QVt9MTUAg3LcoqvJMPSFF+lg=="], - "@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.171.32", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-X86Jqk3vB2KJcUfS6oLi7B7yxbaa59QEhZRPPP1fRx3k+Jw/Fu1UYVBcRtdwK/OccQnrlQdVoKvNO3QXmyEBOw=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.167.38", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.32", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-lkqgFleDgfkW+QONVMKBs+ZGDUF0v9R9FkplS/GZvKDFpfnu+tZxmGlV2pryU1TDxyuSyjh56AtIla3NMrlScw=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.167.40", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-WkEueqEvsBBudzO0fx1nWfOmRNW8OAvBoVABTGls9bAWzjdC+KDGHvjCNrvn88ofs1F6gqL8aWnzKCbxW3dCyQ=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.40", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.32", "@tanstack/router-generator": "1.167.38", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.38", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-ifiXjjR4uivxwRDhgYAcfyrcu+Mwx+vlG0QjjQ7N5C5sKmzpt5UtsL21TNVvZDSZl2Vae8DcL84Z02fOs8ZZMQ=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.42", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.41", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-vAR5WXJh/ErseEWQftDhsJcr2c++ucRQ6FbqN3ygMO44+ZxxRfW3sIpZmddHROVSmBk8jc6MrwjTPNjVhd62Vw=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.162.3", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.4", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA=="], - "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.32", "", { "dependencies": { "@tanstack/router-core": "1.171.32", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.34", "seroval": "^1.6.2" } }, "sha512-kawgeg3Ej/JvGeEN7txFdVN9kXOST9wl7yPojOGESiomwiubHKRdYUS6sBskW91j44H+HirQUppP4+Ha7kjb4A=="], + "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.34", "", { "dependencies": { "@tanstack/router-core": "1.171.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.36", "seroval": "^1.6.7" } }, "sha512-LfwZSbau2MLLZ/uPAoO+NC9fK3J66c0wLhnNQqIa+Goqw7bXPjkHwlsjY7evlUz1AnyhhQ1Qab95v+VmP0TZ9A=="], "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="], - "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.47", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.32", "@tanstack/router-generator": "1.167.38", "@tanstack/router-plugin": "1.168.40", "@tanstack/router-utils": "1.162.3", "@tanstack/start-server-core": "1.169.37", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.2", "source-map": "^0.7.6", "srvx": "^0.11.22", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-yDduux0silP+81gZM+fY7OVebC8RcU8ud6v9E3+8fzn6wA0RnuFHbVC/UkN86z1pAaXbon3Ps8rqCq6S9mv8TA=="], + "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.49", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-plugin": "1.168.42", "@tanstack/router-utils": "1.162.3", "@tanstack/start-server-core": "1.169.39", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.7", "source-map": "^0.7.6", "srvx": "^0.11.22", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-HPOb7LnMc71k38cd5uvMybPPUU/Q/56KvSqQzSWz7cps1ZDFXaxUM6EFaFHLCdgTWqGexNGDdRE+sUVPVBM4Xg=="], - "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.37", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/router-core": "1.171.32", "@tanstack/start-client-core": "1.170.32", "@tanstack/start-storage-context": "1.167.34", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.2" } }, "sha512-CjYMXg2XISMEJt9UVR4lbMRsnRyWPrdyuF8iuJ4gxTc1sVNRciwMVpMPVGQG2KuIma3ewlNxw8+Tpx2tnS3zRA=="], + "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.39", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-storage-context": "1.167.36", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.7" } }, "sha512-pqqkxhXY46nuFSYt6TsMGyABiplUzHFHndKAlRRGC0fWDrJCH1koWxVMoxySa51ZETV79f/z73b5UZ1jfIkXwQ=="], - "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.34", "", { "dependencies": { "@tanstack/router-core": "1.171.32" } }, "sha512-cRPE0kjt1CnOIhepmNOvRFwCGhDAnCRohPD3HmzqJJThPkixLBRuLDvIYp4hRPtM1TJ9hiKbYl24IxAhjZO5TA=="], + "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.36", "", { "dependencies": { "@tanstack/router-core": "1.171.34" } }, "sha512-x+xCXK2GtFzY6NbXqAg8YpU6cwAjDfMeYSNzxfGzntpjbwBRHHrXeC/kYUnBBQCV7wK4kJ8vBBQGP0OyYTasUg=="], - "@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="], + "@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], @@ -623,37 +638,37 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/type-utils": "8.70.1", "@typescript-eslint/utils": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.71.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/type-utils": "8.71.0", "@typescript-eslint/utils": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.71.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.1", "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.71.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.71.0", "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1" } }, "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0" } }, "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.71.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.70.1", "", {}, "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.71.0", "", {}, "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.1", "@typescript-eslint/tsconfig-utils": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.71.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.71.0", "@typescript-eslint/tsconfig-utils": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.1", "@typescript-eslint/types": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.71.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.1", "", { "dependencies": { "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.71.0", "", { "dependencies": { "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], - "@vitest/mocker": ["@vitest/mocker@5.0.2", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA=="], + "@vitest/mocker": ["@vitest/mocker@5.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg=="], - "@vitest/spy": ["@vitest/spy@5.0.2", "", {}, "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg=="], + "@vitest/spy": ["@vitest/spy@5.0.3", "", {}, "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg=="], "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -681,11 +696,11 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.26", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="], "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - "browserslist": ["browserslist@4.29.1", "", { "dependencies": { "baseline-browser-mapping": "^2.11.25", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.438", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw=="], + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], @@ -697,9 +712,9 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001812", "", {}, "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng=="], + "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -729,9 +744,9 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.439", "", {}, "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.444", "", {}, "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ=="], - "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -749,7 +764,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.11.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ=="], + "eslint": ["eslint@10.12.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -769,7 +784,7 @@ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -789,7 +804,7 @@ "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], - "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="], "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], @@ -897,9 +912,9 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "neon-testing": ["neon-testing@3.0.1", "", { "dependencies": { "@neondatabase/api-client": "^2.7.3" }, "peerDependencies": { "@neondatabase/serverless": "^1", "vitest": "^3 || ^4" }, "optionalPeers": ["@neondatabase/serverless", "vitest"] }, "sha512-ZYFO6pWwBwVkaWCGH64f5MMnPRS84sJnqT6IUyvRj1VwVElDAZxMNpVhefhnSi91tlZYez0HFSg9uGu1Q3xIHg=="], + "neon-testing": ["neon-testing@3.0.2", "", { "dependencies": { "@neondatabase/api-client": "^2.7.3" }, "peerDependencies": { "@neondatabase/serverless": "^1", "vitest": "^3 || ^4" }, "optionalPeers": ["@neondatabase/serverless", "vitest"] }, "sha512-KQi2wyAZTB0QeiWdAjspsJjTuzpI0UodRy6XzCXQDa2c/5fZ4tWVj5JnnefZTHAoBMKwC9nyGP3YABN6gyP8/g=="], - "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="], + "next": ["next@16.3.8", "", { "dependencies": { "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.8", "@next/swc-darwin-x64": "16.3.8", "@next/swc-linux-arm64-gnu": "16.3.8", "@next/swc-linux-arm64-musl": "16.3.8", "@next/swc-linux-x64-gnu": "16.3.8", "@next/swc-linux-x64-musl": "16.3.8", "@next/swc-win32-arm64-msvc": "16.3.8", "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw=="], "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], @@ -941,7 +956,7 @@ "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], - "rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], @@ -949,11 +964,11 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "seroval": ["seroval@1.6.7", "", {}, "sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ=="], + "seroval": ["seroval@1.6.8", "", {}, "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg=="], - "seroval-plugins": ["seroval-plugins@1.6.7", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw=="], + "seroval-plugins": ["seroval-plugins@1.6.8", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g=="], - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -961,11 +976,11 @@ "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=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -973,9 +988,9 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="], + "tinybench": ["tinybench@6.2.0", "", {}, "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q=="], - "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -987,7 +1002,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.70.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", "@typescript-eslint/typescript-estree": "8.70.1", "@typescript-eslint/utils": "8.70.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA=="], + "typescript-eslint": ["typescript-eslint@8.71.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", "@typescript-eslint/typescript-estree": "8.71.0", "@typescript-eslint/utils": "8.71.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -1001,17 +1016,17 @@ "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], - "vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "vitest": ["vitest@5.0.2", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.2", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.2", "@vitest/browser-preview": "5.0.2", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.2", "@vitest/coverage-v8": "5.0.2", "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg=="], + "vitest": ["vitest@5.0.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.3", "@vitest/browser-preview": "5.0.3", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.3", "@vitest/coverage-v8": "5.0.3", "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="], + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -1049,7 +1064,7 @@ "@tanstack/router-generator/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.10", "", {}, "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="], "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], diff --git a/examples/bun-react/otp-memory-cookie/src/index.css b/examples/bun-react/otp-memory-cookie/src/index.css index 017b778..3306d99 100644 --- a/examples/bun-react/otp-memory-cookie/src/index.css +++ b/examples/bun-react/otp-memory-cookie/src/index.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/bun-react/otp-memory-header/src/index.css b/examples/bun-react/otp-memory-header/src/index.css index 017b778..3306d99 100644 --- a/examples/bun-react/otp-memory-header/src/index.css +++ b/examples/bun-react/otp-memory-header/src/index.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/otp-memory/LICENSE.md b/examples/bun/otp/LICENSE.md similarity index 100% rename from examples/tanstack-start-react/otp-memory/LICENSE.md rename to examples/bun/otp/LICENSE.md diff --git a/examples/tanstack-start-react/otp-memory/README.md b/examples/bun/otp/README.md similarity index 80% rename from examples/tanstack-start-react/otp-memory/README.md rename to examples/bun/otp/README.md index 48385aa..1a1ab65 100644 --- a/examples/tanstack-start-react/otp-memory/README.md +++ b/examples/bun/otp/README.md @@ -1,4 +1,4 @@ -# otp-memory +# otp ## License diff --git a/examples/bun/otp/package.json b/examples/bun/otp/package.json new file mode 100644 index 0000000..a05b7cc --- /dev/null +++ b/examples/bun/otp/package.json @@ -0,0 +1,17 @@ +{ + "name": "@repo/example-bun-spike-otp", + "private": true, + "license": "MIT-0", + "type": "module", + "scripts": { + "dev": "bun --hot src/server.ts", + "typecheck": "tsc" + }, + "dependencies": { + "@repo/spike": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.4.2", + "typescript": "^6.0.3" + } +} diff --git a/examples/bun/otp/src/auth.ts b/examples/bun/otp/src/auth.ts new file mode 100644 index 0000000..90c794e --- /dev/null +++ b/examples/bun/otp/src/auth.ts @@ -0,0 +1,38 @@ +import { makeOpaqueSessionManager, makeOTP } from "@repo/spike"; +import { db } from "./db"; + +/** How long someone stays signed in, in seconds */ +export const sessionTtl = 30 * 24 * 60 * 60; + +export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await db.sessions.insert({ + token, + userId: row.userId, + expiresAt: row.expiresAt, + }); + }, + get: async (token) => { + const row = await db.sessions.get(token); + + return row ? { userId: row.userId, expiresAt: row.expiresAt } : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, +}); + +/** Signs someone in or up */ +export const signInOtp = makeOTP({ + purpose: "sign-in", + store: async (row) => { + await db.otps.insert(row); + }, + take: async (ticket) => db.otps.delete(ticket), + send: async ({ to, otp }) => { + console.log(`[OTP] Sign in, ${to}: ${otp}`); + }, + ttl: 10 * 60, + attempts: 3, +}); diff --git a/examples/bun/otp/src/db.ts b/examples/bun/otp/src/db.ts new file mode 100644 index 0000000..252dc1e --- /dev/null +++ b/examples/bun/otp/src/db.ts @@ -0,0 +1,26 @@ +/** + * In-memory database + * + * Simple in-memory stores for demonstration purposes. In a real app these + * would be replaced with database queries. + */ +import { makeMemoryTable } from "@repo/spike/demo"; + +export const db = { + users: makeMemoryTable<{ + userId: string; + email: string; + }>("userId"), + sessions: makeMemoryTable<{ + token: string; + userId: string; + expiresAt: number; + }>("token"), + otps: makeMemoryTable<{ + ticket: string; + identifier: string; + otp: string; + expiresAt: number; + attemptsLeft: number; + }>("ticket"), +}; diff --git a/examples/bun/otp/src/index.html b/examples/bun/otp/src/index.html new file mode 100644 index 0000000..9d263b3 --- /dev/null +++ b/examples/bun/otp/src/index.html @@ -0,0 +1,35 @@ + + + + + + AuthAX - Bun OTP example + + + +
+

AuthAX

+ + + + +
+ + diff --git a/examples/bun/otp/src/page.ts b/examples/bun/otp/src/page.ts new file mode 100644 index 0000000..5e0df60 --- /dev/null +++ b/examples/bun/otp/src/page.ts @@ -0,0 +1,62 @@ +const viewerText = document.getElementById("viewer") as HTMLParagraphElement; +const emailForm = document.getElementById("email-form") as HTMLFormElement; +const otpForm = document.getElementById("otp-form") as HTMLFormElement; +const errorText = document.getElementById("error") as HTMLParagraphElement; + +// Held between sending the OTP and verifying it +let ticket = ""; + +async function showViewer() { + const response = await fetch("/api/viewer"); + const viewer: { email: string } | null = await response.json(); + + if (viewer) { + viewerText.textContent = `Signed in as ${viewer.email}`; + viewerText.hidden = false; + emailForm.hidden = true; + otpForm.hidden = true; + } else { + emailForm.hidden = false; + } +} + +emailForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + const response = await fetch("/api/otp/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: new FormData(emailForm).get("email") }), + }); + + if (!response.ok) { + errorText.textContent = "Could not send a one-time password"; + return; + } + + const body: { ticket: string } = await response.json(); + ticket = body.ticket; + errorText.textContent = ""; + emailForm.hidden = true; + otpForm.hidden = false; +}); + +otpForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + const response = await fetch("/api/otp/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ticket, otp: new FormData(otpForm).get("otp") }), + }); + + if (!response.ok) { + errorText.textContent = "Wrong or expired one-time password"; + return; + } + + errorText.textContent = ""; + await showViewer(); +}); + +await showViewer(); diff --git a/examples/bun/otp/src/server.ts b/examples/bun/otp/src/server.ts new file mode 100644 index 0000000..dbfc3eb --- /dev/null +++ b/examples/bun/otp/src/server.ts @@ -0,0 +1,108 @@ +import { db } from "./db"; +import { sessionManager, sessionTtl, signInOtp } from "./auth"; +import page from "./index.html"; + +/** + * Every origin the page is served from. Not read from the request, since + * behind a proxy the request's URL holds the internal address. + */ +const origins = ["http://localhost:3108"]; + +const server = Bun.serve({ + port: 3108, + development: process.env.NODE_ENV !== "production", + routes: { + "/": page, + + /** Sends an OTP to the email address and answers the ticket */ + "/api/otp/send": { + POST: async (req) => { + // A page on another site can post here too + const origin = req.headers.get("origin"); + + if (origin === null || !origins.includes(origin)) { + return new Response(null, { status: 403 }); + } + + const body: unknown = await req.json(); + + if ( + typeof body !== "object" || + body === null || + !("email" in body) || + typeof body.email !== "string" + ) { + return new Response(null, { status: 400 }); + } + + const ticket = await signInOtp.send(body.email); + + return Response.json({ ticket }); + }, + }, + + /** Checks the OTP, finds or creates the user, and signs them in */ + "/api/otp/verify": { + POST: async (req) => { + // A page on another site could sign the visitor in to its own account + const origin = req.headers.get("origin"); + + if (origin === null || !origins.includes(origin)) { + return new Response(null, { status: 403 }); + } + + const body: unknown = await req.json(); + + if ( + typeof body !== "object" || + body === null || + !("ticket" in body) || + !("otp" in body) || + typeof body.ticket !== "string" || + typeof body.otp !== "string" + ) { + return new Response(null, { status: 400 }); + } + + const verified = await signInOtp.verify({ + ticket: body.ticket, + otp: body.otp, + }); + + if (!verified.success) { + return Response.json({ error: verified.error }, { status: 400 }); + } + + const { row: user } = await db.users.findOrInsert( + { email: verified.data.identifier }, + { userId: crypto.randomUUID(), email: verified.data.identifier }, + ); + + const token = await sessionManager.make({ userId: user.userId }); + + req.cookies.set("session", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: sessionTtl, + }); + + return Response.json({ email: user.email }); + }, + }, + + /** Who is signed in, null when nobody is */ + "/api/viewer": { + GET: async (req) => { + const token = req.cookies.get("session"); + const session = token ? await sessionManager.get(token) : null; + const user = session ? await db.users.get(session.userId) : null; + + return Response.json(user ? { email: user.email } : null); + }, + }, + }, +}); + +console.log(`Listening on ${server.url}`); diff --git a/examples/bun/otp/tsconfig.json b/examples/bun/otp/tsconfig.json new file mode 100644 index 0000000..7d48ea0 --- /dev/null +++ b/examples/bun/otp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "types": ["bun"], + + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/examples/convex-react/otp/package.json b/examples/convex-react/otp/package.json index 7743124..87251c0 100644 --- a/examples/convex-react/otp/package.json +++ b/examples/convex-react/otp/package.json @@ -25,6 +25,6 @@ "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.2" } } diff --git a/examples/convex-react/otp/src/index.css b/examples/convex-react/otp/src/index.css index 017b778..3306d99 100644 --- a/examples/convex-react/otp/src/index.css +++ b/examples/convex-react/otp/src/index.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/nextjs/otp-memory/app/globals.css b/examples/nextjs/otp-memory/app/globals.css index 017b778..3306d99 100644 --- a/examples/nextjs/otp-memory/app/globals.css +++ b/examples/nextjs/otp-memory/app/globals.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/nextjs/otp-memory/package.json b/examples/nextjs/otp-memory/package.json index 6c53648..593fe8b 100644 --- a/examples/nextjs/otp-memory/package.json +++ b/examples/nextjs/otp-memory/package.json @@ -12,14 +12,14 @@ "dependencies": { "authax": "workspace:*", "@repo/shared-react": "workspace:*", - "next": "^16.3.6", + "next": "^16.3.8", "react": "^19.3.0", "react-dom": "^19.3.0", "zod": "^4.6.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "babel-plugin-react-compiler": "^1.0.0", diff --git a/examples/shared/react/src/styles.css b/examples/shared/react/src/styles.css index 32b0d79..a084e3a 100644 --- a/examples/shared/react/src/styles.css +++ b/examples/shared/react/src/styles.css @@ -1 +1,8 @@ @source "."; + +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} diff --git a/examples/tanstack-start-react/otp-memory/.gitignore b/examples/tanstack-start-react/kitchen-sink/.gitignore similarity index 100% rename from examples/tanstack-start-react/otp-memory/.gitignore rename to examples/tanstack-start-react/kitchen-sink/.gitignore diff --git a/examples/tanstack-start-react/kitchen-sink/LICENSE.md b/examples/tanstack-start-react/kitchen-sink/LICENSE.md new file mode 100644 index 0000000..f39badb --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/LICENSE.md @@ -0,0 +1,18 @@ +MIT No Attribution + +Copyright (c) 2025-present Mikael Lirbank and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/tanstack-start-react/kitchen-sink/README.md b/examples/tanstack-start-react/kitchen-sink/README.md new file mode 100644 index 0000000..ae05f4c --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/README.md @@ -0,0 +1,5 @@ +# kitchen-sink + +## License + +MIT-0, see [LICENSE.md](./LICENSE.md). diff --git a/examples/tanstack-start-react/otp-memory/package.json b/examples/tanstack-start-react/kitchen-sink/package.json similarity index 71% rename from examples/tanstack-start-react/otp-memory/package.json rename to examples/tanstack-start-react/kitchen-sink/package.json index 395c7f6..1cdbf2a 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/kitchen-sink/package.json @@ -1,5 +1,5 @@ { - "name": "@repo/example-tanstack-otp", + "name": "@repo/example-tanstack-kitchen-sink", "private": true, "license": "MIT-0", "type": "module", @@ -10,22 +10,22 @@ "typecheck": "tsc" }, "dependencies": { - "authax": "workspace:*", "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "zod": "^4.6.5" }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.2" } } diff --git a/examples/tanstack-start-react/kitchen-sink/src/auth-rpc.ts b/examples/tanstack-start-react/kitchen-sink/src/auth-rpc.ts new file mode 100644 index 0000000..034920c --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/src/auth-rpc.ts @@ -0,0 +1,122 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { db } from "./db"; +import { confirmOtp, sessionManager } from "./auth"; +import { sessionCookie } from "./session-cookie"; + +/** The session behind the request's cookie, null when there is none */ +async function getIdentity() { + const token = sessionCookie.get(); + + return token === null ? null : sessionManager.get(token); +} + +/** + * Request OTP schema + */ +export const requestOtpSchema = z.object({ + identifier: z.email(), +}); + +/** + * Verify OTP schema + */ +export const verifyOtpSchema = z.object({ + ticket: z.string(), + otp: z.string().length(6), +}); + +/** + * Send OTP to a new email server function + * + * Requires an active session. Returns the ticket the client sends back with + * the OTP. + */ +export const requestChangeEmail = createServerFn({ method: "POST" }) + .validator(requestOtpSchema) + .handler(async ({ data }) => { + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + return { + success: true as const, + ticket: await confirmOtp.send(data.identifier), + }; + }); + +/** + * Change email + * + * Verifies OTP for the new email, then swaps it on the authenticated user. + * Requires an active session — the OTP proves ownership of the new address. + */ +export const changeEmail = createServerFn({ method: "POST" }) + .validator(verifyOtpSchema) + .handler(async ({ data }) => { + const identity = await getIdentity(); + if (!identity) return { success: false }; + + const verified = await confirmOtp.verify(data); + if (!verified.success) return { success: false }; + + const user = await db.users.update(identity.userId, { + email: verified.data.identifier, + }); + if (!user) return { success: false }; + + return { success: true, viewer: user }; + }); + +/** + * Delete account + * + * Deletes the user and every session of theirs. Requires an active session. + */ +export const deleteAccountSF = createServerFn({ method: "POST" }).handler( + async () => { + const identity = await getIdentity(); + if (!identity) return { success: false }; + + await db.sessions.deleteWhere({ userId: identity.userId }); + await db.users.delete(identity.userId); + sessionCookie.clear(); + + return { success: true }; + }, +); + +/** + * Server function: Sign out + * + * Ends the current session and clears the session cookie. + */ +export const signOut = createServerFn({ method: "POST" }).handler(async () => { + const token = sessionCookie.get(); + + if (token !== null) await sessionManager.end(token); + sessionCookie.clear(); +}); + +/** + * Server function: Sign out all devices + * + * Deletes every session for the current user and clears the session cookie. + */ +export const signOutAll = createServerFn({ method: "POST" }).handler( + async () => { + const identity = await getIdentity(); + if (identity) await db.sessions.deleteWhere({ userId: identity.userId }); + sessionCookie.clear(); + }, +); + +/** + * Server function: Get viewer + * + * Returns the current user if authenticated, or null otherwise. + */ +export const getViewer = createServerFn().handler(async () => { + const identity = await getIdentity(); + + return identity ? db.users.get(identity.userId) : null; +}); diff --git a/examples/tanstack-start-react/kitchen-sink/src/auth.ts b/examples/tanstack-start-react/kitchen-sink/src/auth.ts new file mode 100644 index 0000000..76e1e3e --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/src/auth.ts @@ -0,0 +1,80 @@ +import { + makeOpaqueSessionManager, + makeOtpAuthFlow, + makeOTP, + recommendedOtpConfig, +} from "@repo/spike"; +import { analytics } from "@repo/spike/demo"; +import { db } from "./db"; + +/** How long someone stays signed in, in seconds. The cookie lives as long. */ +export const sessionTtl = 30 * 24 * 60 * 60; + +export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await db.sessions.insert({ + id: token, + userId: row.userId, + expiresAt: row.expiresAt, + }); + }, + get: async (token) => { + const row = await db.sessions.get(token); + + return row ? { userId: row.userId, expiresAt: row.expiresAt } : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, +}); + +/** Confirms a new email address for someone who is signed in */ +export const confirmOtp = makeOTP({ + purpose: "confirm-email", + store: async (row) => { + await db.otps.insert(row); + }, + take: async (ticket) => db.otps.delete(ticket), + send: async ({ to, otp }) => { + console.log(`[OTP] Confirm new email, ${to}: ${otp}`); + }, + ttl: 10 * 60, + attempts: 3, +}); + +/** Signs someone in or up by OTP, mounted in routes/api/auth/$.ts */ +export const otpAuthFlow = makeOtpAuthFlow({ + ...recommendedOtpConfig, + storeOtp: async (row) => { + await db.otps.insert(row); + }, + takeOtp: async (ticket) => db.otps.delete(ticket), + sendOtp: async ({ to, otp }) => { + console.log(`[OTP] Sign in, ${to}: ${otp}`); + }, + sessionManager, + resolveUser: async (identifier) => { + const { row: user, isNew } = await db.users.findOrInsert( + { email: identifier }, + { userId: crypto.randomUUID(), email: identifier }, + ); + + return { success: true, user, isNew }; + }, + hooks: { + onSignIn: ({ user, isNew }) => { + analytics.track(isNew ? "sign_up" : "sign_in", user.userId); + }, + }, +}); + +/** Every origin the app's pages are served from */ +export const appOrigins = ["http://localhost:3100"]; + +/** The session cookie the auth routes set */ +export const sessionCookieConfig = { + name: "session", + maxAge: sessionTtl, + secure: process.env.NODE_ENV === "production", +}; diff --git a/examples/tanstack-start-react/kitchen-sink/src/db.ts b/examples/tanstack-start-react/kitchen-sink/src/db.ts new file mode 100644 index 0000000..3f63e2e --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/src/db.ts @@ -0,0 +1,26 @@ +/** + * In-memory database + * + * Simple in-memory stores for demonstration purposes. In a real app these + * would be replaced with database queries. + */ +import { makeMemoryTable } from "@repo/spike/demo"; + +export const db = { + users: makeMemoryTable<{ + userId: string; + email: string; + }>("userId"), + sessions: makeMemoryTable<{ + id: string; + userId: string; + expiresAt: number; + }>("id"), + otps: makeMemoryTable<{ + ticket: string; + identifier: string; + otp: string; + expiresAt: number; + attemptsLeft: number; + }>("ticket"), +}; diff --git a/examples/tanstack-start-react/otp-memory/src/routeTree.gen.ts b/examples/tanstack-start-react/kitchen-sink/src/routeTree.gen.ts similarity index 69% rename from examples/tanstack-start-react/otp-memory/src/routeTree.gen.ts rename to examples/tanstack-start-react/kitchen-sink/src/routeTree.gen.ts index dceedff..363d6fd 100644 --- a/examples/tanstack-start-react/otp-memory/src/routeTree.gen.ts +++ b/examples/tanstack-start-react/kitchen-sink/src/routeTree.gen.ts @@ -10,33 +10,43 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ + id: '/api/auth/$', + path: '/api/auth/$', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/api/auth/$' + id: '__root__' | '/' | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ApiAuthSplatRoute: typeof ApiAuthSplatRoute } declare module '@tanstack/react-router' { @@ -48,11 +58,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/api/auth/$': { + id: '/api/auth/$' + path: '/api/auth/$' + fullPath: '/api/auth/$' + preLoaderRoute: typeof ApiAuthSplatRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ApiAuthSplatRoute: ApiAuthSplatRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/examples/tanstack-start-react/otp-memory/src/router.tsx b/examples/tanstack-start-react/kitchen-sink/src/router.tsx similarity index 100% rename from examples/tanstack-start-react/otp-memory/src/router.tsx rename to examples/tanstack-start-react/kitchen-sink/src/router.tsx diff --git a/examples/tanstack-start-react/otp-memory/src/routes/__root.tsx b/examples/tanstack-start-react/kitchen-sink/src/routes/__root.tsx similarity index 90% rename from examples/tanstack-start-react/otp-memory/src/routes/__root.tsx rename to examples/tanstack-start-react/kitchen-sink/src/routes/__root.tsx index 98af3b2..d4cdd61 100644 --- a/examples/tanstack-start-react/otp-memory/src/routes/__root.tsx +++ b/examples/tanstack-start-react/kitchen-sink/src/routes/__root.tsx @@ -6,7 +6,7 @@ export const Route = createRootRoute({ meta: [ { charSet: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }, - { title: "AuthAX - TanStack Start OTP demo" }, + { title: "AuthAX - TanStack Start kitchen sink example" }, ], links: [{ rel: "stylesheet", href: appCss }], }), diff --git a/examples/tanstack-start-react/kitchen-sink/src/routes/api/auth/$.ts b/examples/tanstack-start-react/kitchen-sink/src/routes/api/auth/$.ts new file mode 100644 index 0000000..ea11a9c --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/src/routes/api/auth/$.ts @@ -0,0 +1,16 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { serve } from "@repo/spike"; +import { appOrigins, otpAuthFlow, sessionCookieConfig } from "../../../auth"; + +export const Route = createFileRoute("/api/auth/$")({ + server: { + handlers: { + ANY: ({ request }) => + serve( + request, + { ...otpAuthFlow.routes }, + { cookie: sessionCookieConfig, origins: appOrigins }, + ), + }, + }, +}); diff --git a/examples/tanstack-start-react/otp-memory/src/routes/index.tsx b/examples/tanstack-start-react/kitchen-sink/src/routes/index.tsx similarity index 77% rename from examples/tanstack-start-react/otp-memory/src/routes/index.tsx rename to examples/tanstack-start-react/kitchen-sink/src/routes/index.tsx index e5f5f54..019c358 100644 --- a/examples/tanstack-start-react/otp-memory/src/routes/index.tsx +++ b/examples/tanstack-start-react/kitchen-sink/src/routes/index.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { makeAuthClient } from "@repo/spike"; import { - requestOtp, - verifyOtp, + requestChangeEmail, changeEmail, + deleteAccountSF, signOut, signOutAll, getViewer, @@ -27,9 +28,12 @@ export const Route = createFileRoute("/")({ type Viewer = { userId: string; email: string }; +const authax = makeAuthClient("/api/auth"); + function AuthFlow(props: { onSignedIn: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -39,8 +43,9 @@ function AuthFlow(props: { onSignedIn: () => void }) { as="form" onSubmit={async (e) => { e.preventDefault(); - const result = await requestOtp({ data: { identifier: email } }); + const result = await authax.otpSend(email); if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -65,9 +70,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { as="form" onSubmit={async (e) => { e.preventDefault(); - const result = await verifyOtp({ - data: { identifier: email, otp }, - }); + const result = await authax.otpVerify({ ticket, otp }); if (result.success) { props.onSignedIn(); } else { @@ -82,9 +85,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { @@ -92,9 +93,15 @@ function AuthFlow(props: { onSignedIn: () => void }) { variant="secondary" type="button" onClick={async () => { - await requestOtp({ data: { identifier: email } }); - setOtp(""); - setError(null); + const result = await authax.otpSend(email); + + if (result.success) { + setTicket(result.ticket); + setOtp(""); + setError(null); + } else { + setError("Failed to send one-time password"); + } }} > Send a new one-time password @@ -106,6 +113,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -115,8 +123,12 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { as="form" onSubmit={async (e) => { e.preventDefault(); - const result = await requestOtp({ data: { identifier: email } }); + const result = await requestChangeEmail({ + data: { identifier: email }, + }); + if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -148,7 +160,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { onSubmit={async (e) => { e.preventDefault(); const result = await changeEmail({ - data: { identifier: email, otp }, + data: { ticket, otp }, }); if (result.success) { props.onDone(); @@ -164,9 +176,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { @@ -174,9 +184,17 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { variant="secondary" type="button" onClick={async () => { - await requestOtp({ data: { identifier: email } }); - setOtp(""); - setError(null); + const result = await requestChangeEmail({ + data: { identifier: email }, + }); + + if (result.success) { + setTicket(result.ticket); + setOtp(""); + setError(null); + } else { + setError("Failed to send one-time password"); + } }} > Send a new one-time password @@ -230,6 +248,15 @@ function Authenticated(props: { > Sign out all devices + ); } @@ -239,7 +266,7 @@ function App() { const router = useRouter(); return ( - + {viewer ? ( getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; diff --git a/examples/tanstack-start-react/kitchen-sink/src/styles.css b/examples/tanstack-start-react/kitchen-sink/src/styles.css new file mode 100644 index 0000000..3306d99 --- /dev/null +++ b/examples/tanstack-start-react/kitchen-sink/src/styles.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "@repo/shared-react/styles.css"; diff --git a/examples/tanstack-start-react/otp-memory/tsconfig.json b/examples/tanstack-start-react/kitchen-sink/tsconfig.json similarity index 100% rename from examples/tanstack-start-react/otp-memory/tsconfig.json rename to examples/tanstack-start-react/kitchen-sink/tsconfig.json diff --git a/examples/tanstack-start-react/otp-memory/vite.config.ts b/examples/tanstack-start-react/kitchen-sink/vite.config.ts similarity index 100% rename from examples/tanstack-start-react/otp-memory/vite.config.ts rename to examples/tanstack-start-react/kitchen-sink/vite.config.ts diff --git a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts deleted file mode 100644 index 826a1e2..0000000 --- a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { db } from "./db"; -import { auth, emailOtp } from "./auth"; -import { sessionCookie } from "./session-cookie"; - -/** - * Request OTP schema - */ -export const requestOtpSchema = z.object({ - identifier: z.email(), -}); - -/** - * Verify OTP schema - */ -export const verifyOtpSchema = z.object({ - identifier: z.email(), - otp: z.string().length(6), -}); - -/** - * Send OTP to identifier server function - */ -export const requestOtp = createServerFn({ method: "POST" }) - .validator(requestOtpSchema) - .handler(({ data }) => auth.strategies.email.request(data)); - -/** - * Verify OTP server function - * - * Authenticates with the OTP, which upserts the user and establishes a - * session. Returns isNew to distinguish sign-up from sign-in (for analytics, - * onboarding, etc.). - */ -export const verifyOtp = createServerFn({ method: "POST" }) - .validator(verifyOtpSchema) - .handler(async ({ data }) => { - const result = await auth.strategies.email.authenticate(data); - - if (!result.success) return { success: false }; - - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); - - return { success: true, isNew: result.data.user.isNew }; - }); - -/** - * Change email - * - * Verifies OTP for the new email, then swaps it on the authenticated user. - * Requires an active session — the OTP proves ownership of the new address. - */ -export const changeEmail = createServerFn({ method: "POST" }) - .validator(verifyOtpSchema) - .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); - if (!identity) return { success: false }; - - const verified = await emailOtp.verify(data.identifier, data.otp); - if (!verified) return { success: false }; - - const user = db.users.updateEmail(identity.userId, data.identifier); - if (!user) return { success: false }; - - return { success: true, viewer: user }; - }); - -/** - * Server function: Sign out - * - * Ends the current session and clears the session cookie. - */ -export const signOut = createServerFn({ method: "POST" }).handler(async () => { - await auth.session.end(sessionCookie.get()); - sessionCookie.clear(); -}); - -/** - * Server function: Sign out all devices - * - * Deletes every session for the current user and clears the session cookie. - */ -export const signOutAll = createServerFn({ method: "POST" }).handler( - async () => { - const identity = await auth.session.get(sessionCookie.get()); - if (identity) db.sessions.deleteAllForUser(identity.userId); - sessionCookie.clear(); - }, -); - -/** - * Server function: Get viewer - * - * Returns the current user if authenticated, or null otherwise. - */ -export const getViewer = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); - - return identity ? (db.users.get(identity.userId) ?? null) : null; -}); diff --git a/examples/tanstack-start-react/otp-memory/src/auth.ts b/examples/tanstack-start-react/otp-memory/src/auth.ts deleted file mode 100644 index 05f9ba1..0000000 --- a/examples/tanstack-start-react/otp-memory/src/auth.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { makeAuth, makeOpaqueSession, makeOtp, makeOtpStrategy } from "authax"; -import { db } from "./db"; - -const session = makeOpaqueSession({ - storage: db.sessions, - ttl: 30 * 24 * 60 * 60 * 1000, -}); - -export const emailOtp = makeOtp({ - storage: db.otps, - delivery: { - send: async (identifier, otp) => { - console.log(`[OTP] ${identifier}: ${otp}`); - }, - }, - ttl: 10 * 60 * 1000, - attempts: 3, -}); - -export const auth = makeAuth(session, (kernel) => ({ - email: makeOtpStrategy(kernel, { - request: async ({ identifier }) => { - await emailOtp.request(identifier); - return { success: true }; - }, - authenticate: async ({ identifier, otp }) => { - if (!(await emailOtp.verify(identifier, otp))) { - return { success: false, error: "invalid_otp" }; - } - - return { success: true, data: db.users.upsert(identifier) }; - }, - }), -})); diff --git a/examples/tanstack-start-react/otp-memory/src/db.ts b/examples/tanstack-start-react/otp-memory/src/db.ts deleted file mode 100644 index 5411432..0000000 --- a/examples/tanstack-start-react/otp-memory/src/db.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * In-memory database - * - * Simple in-memory stores for demonstration purposes. In a real app these - * would be replaced with database queries. - */ -import type { OtpRecord, SessionRecord } from "authax"; - -const users = new Map(); -let userIdCounter = 0; - -const sessions = new Map(); -const otps = new Map(); - -export const db = { - users: { - upsert: (email: string) => { - const exists = Array.from(users.values()).find((u) => u.email === email); - - if (exists) { - return { userId: exists.userId, isNew: false }; - } - - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId, email }); - - return { userId, isNew: true }; - }, - - get: (userId: string) => users.get(userId), - - updateEmail: (userId: string, email: string) => { - const user = users.get(userId); - if (!user) return undefined; - user.email = email; - return user; - }, - }, - - sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); - }, - - get: async (sessionId: string) => sessions.get(sessionId) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, - - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } - } - }, - }, - - otps: { - store: async (record: OtpRecord) => { - otps.set(record.identifier, record); - }, - - take: async (identifier: string) => { - const record = otps.get(identifier) ?? null; - otps.delete(identifier); - return record; - }, - }, -}; diff --git a/examples/tanstack-start-react/otp-memory/src/styles.css b/examples/tanstack-start-react/otp-memory/src/styles.css deleted file mode 100644 index 017b778..0000000 --- a/examples/tanstack-start-react/otp-memory/src/styles.css +++ /dev/null @@ -1,10 +0,0 @@ -@import "tailwindcss"; -@import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-memory/package.json b/examples/tanstack-start-react/otp-passkey-memory/package.json index 410df1c..d39c705 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-memory/package.json @@ -13,8 +13,8 @@ "authax": "workspace:*", "@repo/shared-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", @@ -22,11 +22,11 @@ "@repo/shared-webauthn": "workspace:*" }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.2" } } diff --git a/examples/tanstack-start-react/otp-passkey-memory/src/styles.css b/examples/tanstack-start-react/otp-passkey-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-passkey-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-memory/src/use-passkey.ts b/examples/tanstack-start-react/otp-passkey-memory/src/use-passkey.ts index dbb95f6..a8659b1 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/src/use-passkey.ts +++ b/examples/tanstack-start-react/otp-passkey-memory/src/use-passkey.ts @@ -82,7 +82,7 @@ export function usePasskeyAuthentication( const start = await options.start(); if (!start.success) { - setError("Failed to start sign-in"); + setError("Failed to start authentication"); setLoading(false); return; } @@ -90,7 +90,7 @@ export function usePasskeyAuthentication( const credential = await getPasskey(start.options); if (!credential) { - setError("Passkey sign-in was cancelled"); + setError("Passkey authentication was cancelled"); setLoading(false); return; } @@ -100,11 +100,11 @@ export function usePasskeyAuthentication( if (result.success) { options.onSuccess(); } else { - setError("Failed to sign in"); + setError("Failed to authenticate with passkey"); } } catch (cause) { - console.error("Passkey sign-in failed:", cause); - setError("Passkey sign-in failed"); + console.error("Passkey authentication failed:", cause); + setError("Passkey authentication failed"); } setLoading(false); diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json index 1388c80..fa630f8 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json @@ -13,8 +13,8 @@ "authax": "workspace:*", "@repo/shared-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", @@ -22,11 +22,11 @@ "@repo/shared-webauthn": "workspace:*" }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.2" } } diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css b/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/src/use-passkey.ts b/examples/tanstack-start-react/otp-passkey-strict-memory/src/use-passkey.ts index dbb95f6..a8659b1 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/src/use-passkey.ts +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/src/use-passkey.ts @@ -82,7 +82,7 @@ export function usePasskeyAuthentication( const start = await options.start(); if (!start.success) { - setError("Failed to start sign-in"); + setError("Failed to start authentication"); setLoading(false); return; } @@ -90,7 +90,7 @@ export function usePasskeyAuthentication( const credential = await getPasskey(start.options); if (!credential) { - setError("Passkey sign-in was cancelled"); + setError("Passkey authentication was cancelled"); setLoading(false); return; } @@ -100,11 +100,11 @@ export function usePasskeyAuthentication( if (result.success) { options.onSuccess(); } else { - setError("Failed to sign in"); + setError("Failed to authenticate with passkey"); } } catch (cause) { - console.error("Passkey sign-in failed:", cause); - setError("Passkey sign-in failed"); + console.error("Passkey authentication failed:", cause); + setError("Passkey authentication failed"); } setLoading(false); diff --git a/examples/tanstack-start-react/passkey-memory/package.json b/examples/tanstack-start-react/passkey-memory/package.json index 9ffa4db..03dbd04 100644 --- a/examples/tanstack-start-react/passkey-memory/package.json +++ b/examples/tanstack-start-react/passkey-memory/package.json @@ -10,23 +10,22 @@ "typecheck": "tsc" }, "dependencies": { - "authax": "workspace:*", "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", - "zod": "^4.6.5", - "@repo/shared-webauthn": "workspace:*" + "zod": "^4.6.5" }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.2" } } diff --git a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts index b87b3bc..368ed29 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts @@ -1,13 +1,35 @@ import { createServerFn } from "@tanstack/react-start"; -import { - passkeyAuthenticationCredentialSchema, - passkeyRegistrationCredentialSchema, -} from "@repo/shared-webauthn"; +import { analytics } from "@repo/spike/demo"; import { z } from "zod"; import { db } from "./db"; -import { auth } from "./auth"; +import { passkey, sessionManager } from "./auth"; import { sessionCookie } from "./session-cookie"; +/** The session behind the request's cookie, null when there is none */ +async function getIdentity() { + const token = sessionCookie.get(); + + return token === null ? null : sessionManager.get(token); +} + +/** What the browser sends after it created a passkey */ +const registrationCredentialSchema = z.object({ + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + }), +}); + +/** What the browser sends after it signed in with a passkey */ +const authenticationCredentialSchema = z.object({ + id: z.string(), + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + }), +}); + /** * Server function: Start passkey registration * @@ -15,13 +37,13 @@ import { sessionCookie } from "./session-cookie"; * application user is provisioned only after the ceremony verifies. */ export const startRegistration = createServerFn({ method: "POST" }).handler( - async () => { - const result = await auth.strategies.passkeys.createRegistrationOptions(); - - if (!result.success) return { success: false as const }; - - return { success: true as const, options: result.data }; - }, + async () => ({ + success: true as const, + options: await passkey.beginRegistration({ + handle: crypto.randomUUID(), + name: "New user", + }), + }), ); /** @@ -31,15 +53,24 @@ export const startRegistration = createServerFn({ method: "POST" }).handler( * establishes a session. */ export const verifyRegistration = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyRegistration({ - credential: data.credential, - }); + const result = await passkey.finishRegistration(data.credential); + + if (!result.success) { + console.log("[passkey] refused:", result.error); - if (!result.success) return { success: false as const }; + return { success: false as const }; + } - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + const userId = result.data.userHandle; + const { isNew } = await db.users.findOrInsert({ userId }, { userId }); + + // A ceremony that was started to add a passkey signs nobody up + if (!isNew) return { success: false as const }; + + sessionCookie.set(await sessionManager.make({ userId })); + analytics.track("sign_up", userId); return { success: true as const }; }); @@ -52,14 +83,16 @@ export const verifyRegistration = createServerFn({ method: "POST" }) */ export const startAddPasskey = createServerFn({ method: "POST" }).handler( async () => { - const result = - await auth.strategies.passkeys.createAdditionalRegistrationOptions( - sessionCookie.get(), - ); - - if (!result.success) return { success: false as const }; + const identity = await getIdentity(); + if (!identity) return { success: false as const }; - return { success: true as const, options: result.data }; + return { + success: true as const, + options: await passkey.beginRegistration({ + handle: identity.userId, + name: identity.userId, + }), + }; }, ); @@ -70,14 +103,26 @@ export const startAddPasskey = createServerFn({ method: "POST" }).handler( * for the current user. No session is established. */ export const verifyAddPasskey = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAdditionalRegistration( - sessionCookie.get(), - { credential: data.credential }, - ); + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + const result = await passkey.finishRegistration(data.credential); + + if (!result.success) { + console.log("[passkey] refused:", result.error); + + return { success: false as const }; + } + + // The ceremony has to be one this user started. The passkey is already + // stored when the answer comes back, so it is removed again. + if (result.data.userHandle !== identity.userId) { + await db.credentials.delete(result.data.credentialId); - if (!result.success) return { success: false as const }; + return { success: false as const }; + } return { success: true as const }; }); @@ -89,17 +134,10 @@ export const verifyAddPasskey = createServerFn({ method: "POST" }) */ export const startAuthentication = createServerFn({ method: "POST", -}).handler(async () => { - const result = await auth.strategies.passkeys.createAuthenticationOptions(); - return { success: true as const, options: result.data }; -}); - -/** - * Verify passkey authentication schema - */ -const verifyAuthenticationSchema = z.object({ - credential: passkeyAuthenticationCredentialSchema, -}); +}).handler(async () => ({ + success: true as const, + options: await passkey.beginAuthentication(), +})); /** * Verify passkey authentication @@ -108,15 +146,20 @@ const verifyAuthenticationSchema = z.object({ * establishes a session. */ export const verifyAuthentication = createServerFn({ method: "POST" }) - .validator(verifyAuthenticationSchema) + .validator(z.object({ credential: authenticationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAuthentication({ - credential: data.credential, - }); + const result = await passkey.finishAuthentication(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + return { success: false as const }; + } + + sessionCookie.set( + await sessionManager.make({ userId: result.data.userHandle }), + ); + analytics.track("sign_in", result.data.userHandle); return { success: true as const }; }); @@ -127,12 +170,12 @@ export const verifyAuthentication = createServerFn({ method: "POST" }) * Returns stored credential metadata for the authenticated user. */ export const listPasskeys = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { passkeys: [] }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); return { - passkeys: passkeys.map((p) => ({ id: p.credentialId })), + passkeys: passkeys.map((p) => ({ id: p.id })), }; }); @@ -144,16 +187,68 @@ export const listPasskeys = createServerFn().handler(async () => { export const removePasskey = createServerFn({ method: "POST" }) .validator(z.object({ credentialId: z.string() })) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { success: false as const }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); if (passkeys.length <= 1) return { success: false as const }; - const owns = passkeys.some((p) => p.credentialId === data.credentialId); + const owns = passkeys.some((p) => p.id === data.credentialId); if (!owns) return { success: false as const }; - db.credentials.delete(data.credentialId); + await db.credentials.delete(data.credentialId); + return { success: true as const }; + }); + +/** + * Start deleting the account server function + * + * Requires an active session. Returns WebAuthn authentication options, so the + * person confirms with a passkey. + */ +export const requestDeleteAccountSF = createServerFn({ + method: "POST", +}).handler(async () => { + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + return { + success: true as const, + options: await passkey.beginAuthentication(), + }; +}); + +/** + * Delete account + * + * Verifies the passkey assertion, then deletes the user, their passkeys, and + * every session of theirs. Requires an active session. + */ +export const deleteAccountSF = createServerFn({ method: "POST" }) + .validator(z.object({ credential: authenticationCredentialSchema })) + .handler(async ({ data }) => { + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + const result = await passkey.finishAuthentication(data.credential); + + if (!result.success) { + console.log("[passkey] refused:", result.error); + + return { success: false as const }; + } + + // Verification succeeds for any stored passkey, so the passkey has to + // belong to the user in the session + if (result.data.userHandle !== identity.userId) { + return { success: false as const }; + } + + await db.credentials.deleteWhere({ userId: identity.userId }); + await db.sessions.deleteWhere({ userId: identity.userId }); + await db.users.delete(identity.userId); + sessionCookie.clear(); + return { success: true as const }; }); @@ -163,7 +258,9 @@ export const removePasskey = createServerFn({ method: "POST" }) * Ends the current session and clears the session cookie. */ export const signOut = createServerFn({ method: "POST" }).handler(async () => { - await auth.session.end(sessionCookie.get()); + const token = sessionCookie.get(); + + if (token !== null) await sessionManager.end(token); sessionCookie.clear(); }); @@ -174,8 +271,8 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => { */ export const signOutAll = createServerFn({ method: "POST" }).handler( async () => { - const identity = await auth.session.get(sessionCookie.get()); - if (identity) db.sessions.deleteAllForUser(identity.userId); + const identity = await getIdentity(); + if (identity) await db.sessions.deleteWhere({ userId: identity.userId }); sessionCookie.clear(); }, ); @@ -186,7 +283,7 @@ export const signOutAll = createServerFn({ method: "POST" }).handler( * Returns the current user if authenticated, or null otherwise. */ export const getViewer = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); - return identity ? (db.users.get(identity.userId) ?? null) : null; + return identity ? db.users.get(identity.userId) : null; }); diff --git a/examples/tanstack-start-react/passkey-memory/src/auth.ts b/examples/tanstack-start-react/passkey-memory/src/auth.ts index 78bd7d2..de9539b 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth.ts @@ -1,30 +1,61 @@ -import { - makeAuth, - makeOpaqueSession, - makePasskeyEngine, - makePasskeyStrategy, -} from "authax"; +import { makeOpaqueSessionManager, makePasskey } from "@repo/spike"; import { db } from "./db"; -const session = makeOpaqueSession({ - storage: db.sessions, - ttl: 30 * 24 * 60 * 60 * 1000, -}); +/** How long someone stays signed in, in seconds. The cookie lives as long. */ +export const sessionTtl = 30 * 24 * 60 * 60; + +export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await db.sessions.insert({ + id: token, + userId: row.userId, + expiresAt: row.expiresAt, + }); + }, + get: async (token) => { + const row = await db.sessions.get(token); -const passkey = makePasskeyEngine({ - storage: db.credentials, - challenge: { storage: db.challenges, ttl: 5 * 60 * 1000 }, - webAuthn: { - rpId: "localhost", - rpName: "Auth Passkey Demo", - allowedOrigins: ["http://localhost:3107"], - }, - displayName: async (context) => - context.intent === "add" ? context.userId : "New user", - signUp: async () => db.users.create().userId, - debug: true, + return row ? { userId: row.userId, expiresAt: row.expiresAt } : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, }); -export const auth = makeAuth(session, (kernel) => ({ - passkeys: makePasskeyStrategy(kernel, passkey), -})); +export const passkey = makePasskey({ + rpId: "localhost", + rpName: "Auth Passkey Demo", + origins: ["http://localhost:3107"], + ttl: 5 * 60, + storeChallenge: async (challenge, row) => { + await db.challenges.insert({ + id: challenge, + userId: row.handle, + expiresAt: row.expiresAt, + }); + }, + takeChallenge: async (challenge) => { + const row = await db.challenges.delete(challenge); + + return row ? { handle: row.userId, expiresAt: row.expiresAt } : null; + }, + storeCredential: async (credentialId, row) => { + await db.credentials.insert({ + id: credentialId, + userId: row.handle, + publicKey: row.publicKey, + counter: row.counter, + }); + }, + getCredential: async (credentialId) => { + const row = await db.credentials.get(credentialId); + + return row + ? { handle: row.userId, publicKey: row.publicKey, counter: row.counter } + : null; + }, + setCounter: async (credentialId, counter) => { + await db.credentials.update(credentialId, { counter }); + }, +}); diff --git a/examples/tanstack-start-react/passkey-memory/src/db.ts b/examples/tanstack-start-react/passkey-memory/src/db.ts index 5043359..644297c 100644 --- a/examples/tanstack-start-react/passkey-memory/src/db.ts +++ b/examples/tanstack-start-react/passkey-memory/src/db.ts @@ -4,74 +4,30 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ -import type { ChallengeRecord, CredentialRecord, SessionRecord } from "authax"; - -const users = new Map(); -let userIdCounter = 0; - -const sessions = new Map(); -const credentials = new Map(); -const challenges = new Map(); +import { makeMemoryTable } from "@repo/spike/demo"; export const db = { - users: { - create: () => { - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId }); - return { userId }; - }, - get: (userId: string) => users.get(userId), - }, - - sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); - }, - - get: async (sessionId: string) => sessions.get(sessionId) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, - - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } - } - }, - }, - - credentials: { - store: async (record: CredentialRecord) => { - credentials.set(record.credentialId, record); - }, - - get: async (credentialId: string) => credentials.get(credentialId) ?? null, - - list: async (userId: string) => - Array.from(credentials.values()).filter((c) => c.userId === userId), - - setCounter: async (credentialId: string, counter: number) => { - const record = credentials.get(credentialId); - if (record) credentials.set(credentialId, { ...record, counter }); - }, - - delete: (credentialId: string) => { - credentials.delete(credentialId); - }, - }, - - challenges: { - store: async (record: ChallengeRecord) => { - challenges.set(record.challenge, record); - }, - - take: async (challenge: string) => { - const record = challenges.get(challenge) ?? null; - challenges.delete(challenge); - return record; - }, - }, + users: makeMemoryTable<{ + userId: string; + }>("userId"), + + sessions: makeMemoryTable<{ + id: string; + userId: string; + expiresAt: number; + }>("id"), + + credentials: makeMemoryTable<{ + id: string; + userId: string; + publicKey: string; + counter: number; + }>("id"), + + challenges: makeMemoryTable<{ + id: string; + /** Who a registration is for. Null when the challenge is for signing in. */ + userId: string | null; + expiresAt: number; + }>("id"), }; diff --git a/examples/tanstack-start-react/passkey-memory/src/routes/index.tsx b/examples/tanstack-start-react/passkey-memory/src/routes/index.tsx index 08eb811..05ed253 100644 --- a/examples/tanstack-start-react/passkey-memory/src/routes/index.tsx +++ b/examples/tanstack-start-react/passkey-memory/src/routes/index.tsx @@ -8,6 +8,8 @@ import { verifyAddPasskey, listPasskeys, removePasskey, + requestDeleteAccountSF, + deleteAccountSF, signOut, signOutAll, getViewer, @@ -86,6 +88,12 @@ function Authenticated(props: { onSuccess: () => props.onChanged(), }); + const deleteAccount = usePasskeyAuthentication({ + start: () => requestDeleteAccountSF(), + verify: (credential) => deleteAccountSF({ data: { credential } }), + onSuccess: () => props.onChanged(), + }); + const handleRemovePasskey = async (credentialId: string) => { const result = await removePasskey({ data: { credentialId } }); if (result.success) { @@ -125,6 +133,17 @@ function Authenticated(props: { > Sign out all devices + + + {deleteAccount.error !== null ? ( +
{deleteAccount.error}
+ ) : null} ); } diff --git a/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts b/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts index 60eb30c..4f99d5a 100644 --- a/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts +++ b/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts @@ -1,4 +1,5 @@ import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { sessionTtl } from "./auth"; const name = "session"; @@ -12,7 +13,7 @@ const options = { /** Moves the session token between the request and the auth API */ export const sessionCookie = { get: () => getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; diff --git a/examples/tanstack-start-react/passkey-memory/src/styles.css b/examples/tanstack-start-react/passkey-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/passkey-memory/src/styles.css +++ b/examples/tanstack-start-react/passkey-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/passkey-memory/src/use-passkey.ts b/examples/tanstack-start-react/passkey-memory/src/use-passkey.ts index dbb95f6..a8659b1 100644 --- a/examples/tanstack-start-react/passkey-memory/src/use-passkey.ts +++ b/examples/tanstack-start-react/passkey-memory/src/use-passkey.ts @@ -82,7 +82,7 @@ export function usePasskeyAuthentication( const start = await options.start(); if (!start.success) { - setError("Failed to start sign-in"); + setError("Failed to start authentication"); setLoading(false); return; } @@ -90,7 +90,7 @@ export function usePasskeyAuthentication( const credential = await getPasskey(start.options); if (!credential) { - setError("Passkey sign-in was cancelled"); + setError("Passkey authentication was cancelled"); setLoading(false); return; } @@ -100,11 +100,11 @@ export function usePasskeyAuthentication( if (result.success) { options.onSuccess(); } else { - setError("Failed to sign in"); + setError("Failed to authenticate with passkey"); } } catch (cause) { - console.error("Passkey sign-in failed:", cause); - setError("Passkey sign-in failed"); + console.error("Passkey authentication failed:", cause); + setError("Passkey authentication failed"); } setLoading(false); diff --git a/examples/tanstack-start-react/passkey-otp-memory/package.json b/examples/tanstack-start-react/passkey-otp-memory/package.json index f0ae329..a2863dd 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/package.json +++ b/examples/tanstack-start-react/passkey-otp-memory/package.json @@ -10,23 +10,22 @@ "typecheck": "tsc" }, "dependencies": { - "authax": "workspace:*", "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-router": "^1.170.39", - "@tanstack/react-start": "^1.168.58", + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", - "zod": "^4.6.5", - "@repo/shared-webauthn": "workspace:*" + "zod": "^4.6.5" }, "devDependencies": { - "@types/node": "^26.6.3", + "@types/node": "^26.6.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.2" } } diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/auth-rpc.ts b/examples/tanstack-start-react/passkey-otp-memory/src/auth-rpc.ts index a9935dd..d25ffb3 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/passkey-otp-memory/src/auth-rpc.ts @@ -1,13 +1,35 @@ import { createServerFn } from "@tanstack/react-start"; -import { - passkeyAuthenticationCredentialSchema, - passkeyRegistrationCredentialSchema, -} from "@repo/shared-webauthn"; +import { analytics } from "@repo/spike/demo"; import { z } from "zod"; import { db } from "./db"; -import { auth, emailOtp } from "./auth"; +import { addEmailOtp, passkey, sessionManager } from "./auth"; import { sessionCookie } from "./session-cookie"; +/** The session behind the request's cookie, null when there is none */ +async function getIdentity() { + const token = sessionCookie.get(); + + return token === null ? null : sessionManager.get(token); +} + +/** What the browser sends after it created a passkey */ +const registrationCredentialSchema = z.object({ + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + }), +}); + +/** What the browser sends after it signed in with a passkey */ +const authenticationCredentialSchema = z.object({ + id: z.string(), + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + }), +}); + /** * Request OTP schema */ @@ -19,7 +41,7 @@ export const requestOtpSchema = z.object({ * Verify OTP schema */ export const verifyOtpSchema = z.object({ - identifier: z.email(), + ticket: z.string(), otp: z.string().length(6), }); @@ -30,13 +52,13 @@ export const verifyOtpSchema = z.object({ * application user is provisioned only after the ceremony verifies. */ export const startRegistration = createServerFn({ method: "POST" }).handler( - async () => { - const result = await auth.strategies.passkeys.createRegistrationOptions(); - - if (!result.success) return { success: false as const }; - - return { success: true as const, options: result.data }; - }, + async () => ({ + success: true as const, + options: await passkey.beginRegistration({ + handle: crypto.randomUUID(), + name: "New user", + }), + }), ); /** @@ -46,15 +68,24 @@ export const startRegistration = createServerFn({ method: "POST" }).handler( * establishes a session. */ export const verifyRegistration = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyRegistration({ - credential: data.credential, - }); + const result = await passkey.finishRegistration(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + return { success: false as const }; + } + + const userId = result.data.userHandle; + const { isNew } = await db.users.findOrInsert({ userId }, { userId }); + + // A ceremony that was started to add a passkey signs nobody up + if (!isNew) return { success: false as const }; + + sessionCookie.set(await sessionManager.make({ userId })); + analytics.track("sign_up", userId); return { success: true as const }; }); @@ -67,14 +98,18 @@ export const verifyRegistration = createServerFn({ method: "POST" }) */ export const startAddPasskey = createServerFn({ method: "POST" }).handler( async () => { - const result = - await auth.strategies.passkeys.createAdditionalRegistrationOptions( - sessionCookie.get(), - ); + const identity = await getIdentity(); + if (!identity) return { success: false as const }; - if (!result.success) return { success: false as const }; + const user = await db.users.get(identity.userId); - return { success: true as const, options: result.data }; + return { + success: true as const, + options: await passkey.beginRegistration({ + handle: identity.userId, + name: user?.email ?? identity.userId, + }), + }; }, ); @@ -85,14 +120,26 @@ export const startAddPasskey = createServerFn({ method: "POST" }).handler( * for the current user. No session is established. */ export const verifyAddPasskey = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAdditionalRegistration( - sessionCookie.get(), - { credential: data.credential }, - ); + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + const result = await passkey.finishRegistration(data.credential); + + if (!result.success) { + console.log("[passkey] refused:", result.error); - if (!result.success) return { success: false as const }; + return { success: false as const }; + } + + // The ceremony has to be one this user started. The passkey is already + // stored when the answer comes back, so it is removed again. + if (result.data.userHandle !== identity.userId) { + await db.credentials.delete(result.data.credentialId); + + return { success: false as const }; + } return { success: true as const }; }); @@ -104,10 +151,10 @@ export const verifyAddPasskey = createServerFn({ method: "POST" }) */ export const startAuthentication = createServerFn({ method: "POST", -}).handler(async () => { - const result = await auth.strategies.passkeys.createAuthenticationOptions(); - return { success: true as const, options: result.data }; -}); +}).handler(async () => ({ + success: true as const, + options: await passkey.beginAuthentication(), +})); /** * Verify passkey authentication @@ -116,15 +163,20 @@ export const startAuthentication = createServerFn({ * establishes a session. */ export const verifyAuthentication = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyAuthenticationCredentialSchema })) + .validator(z.object({ credential: authenticationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAuthentication({ - credential: data.credential, - }); + const result = await passkey.finishAuthentication(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + return { success: false as const }; + } + + sessionCookie.set( + await sessionManager.make({ userId: result.data.userHandle }), + ); + analytics.track("sign_in", result.data.userHandle); return { success: true as const }; }); @@ -135,12 +187,12 @@ export const verifyAuthentication = createServerFn({ method: "POST" }) * Returns stored credential metadata for the authenticated user. */ export const listPasskeys = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { passkeys: [] }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); return { - passkeys: passkeys.map((p) => ({ id: p.credentialId })), + passkeys: passkeys.map((p) => ({ id: p.id })), }; }); @@ -152,32 +204,35 @@ export const listPasskeys = createServerFn().handler(async () => { export const removePasskey = createServerFn({ method: "POST" }) .validator(z.object({ credentialId: z.string() })) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { success: false as const }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); if (passkeys.length <= 1) return { success: false as const }; - const owns = passkeys.some((p) => p.credentialId === data.credentialId); + const owns = passkeys.some((p) => p.id === data.credentialId); if (!owns) return { success: false as const }; - db.credentials.delete(data.credentialId); + await db.credentials.delete(data.credentialId); return { success: true as const }; }); /** * Server function: Request OTP for adding email * - * Requires an active session. Sends OTP to the provided email address. + * Requires an active session. Sends OTP to the provided email address and + * returns the ticket the client sends back with the OTP. */ export const requestOtp = createServerFn({ method: "POST" }) .validator(requestOtpSchema) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); - if (!identity) return { success: false }; + const identity = await getIdentity(); + if (!identity) return { success: false as const }; - await emailOtp.request(data.identifier); - return { success: true }; + return { + success: true as const, + ticket: await addEmailOtp.send(data.identifier), + }; }); /** @@ -189,13 +244,16 @@ export const requestOtp = createServerFn({ method: "POST" }) export const addEmail = createServerFn({ method: "POST" }) .validator(verifyOtpSchema) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { success: false }; - const verified = await emailOtp.verify(data.identifier, data.otp); - if (!verified) return { success: false }; + const verified = await addEmailOtp.verify(data); + if (!verified.success) return { success: false }; + + const user = await db.users.update(identity.userId, { + email: verified.data.identifier, + }); - const user = db.users.setEmail(identity.userId, data.identifier); if (!user) return { success: false }; return { success: true, viewer: user }; @@ -207,7 +265,9 @@ export const addEmail = createServerFn({ method: "POST" }) * Ends the current session and clears the session cookie. */ export const signOut = createServerFn({ method: "POST" }).handler(async () => { - await auth.session.end(sessionCookie.get()); + const token = sessionCookie.get(); + + if (token !== null) await sessionManager.end(token); sessionCookie.clear(); }); @@ -218,8 +278,8 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => { */ export const signOutAll = createServerFn({ method: "POST" }).handler( async () => { - const identity = await auth.session.get(sessionCookie.get()); - if (identity) db.sessions.deleteAllForUser(identity.userId); + const identity = await getIdentity(); + if (identity) await db.sessions.deleteWhere({ userId: identity.userId }); sessionCookie.clear(); }, ); @@ -230,7 +290,7 @@ export const signOutAll = createServerFn({ method: "POST" }).handler( * Returns the current user if authenticated, or null otherwise. */ export const getViewer = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); - return identity ? (db.users.get(identity.userId) ?? null) : null; + return identity ? db.users.get(identity.userId) : null; }); diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/auth.ts b/examples/tanstack-start-react/passkey-otp-memory/src/auth.ts index c9b5dae..f026fee 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/auth.ts +++ b/examples/tanstack-start-react/passkey-otp-memory/src/auth.ts @@ -1,48 +1,78 @@ -import { - makeAuth, - makeOpaqueSession, - makeOtp, - makePasskeyEngine, - makePasskeyStrategy, -} from "authax"; +import { makeOpaqueSessionManager, makeOTP, makePasskey } from "@repo/spike"; import { db } from "./db"; -const session = makeOpaqueSession({ - storage: db.sessions, - ttl: 30 * 24 * 60 * 60 * 1000, +/** How long someone stays signed in, in seconds. The cookie lives as long. */ +export const sessionTtl = 30 * 24 * 60 * 60; + +export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await db.sessions.insert({ + id: token, + userId: row.userId, + expiresAt: row.expiresAt, + }); + }, + get: async (token) => { + const row = await db.sessions.get(token); + + return row ? { userId: row.userId, expiresAt: row.expiresAt } : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, }); /** - * OTP as a pure proof primitive: it verifies address ownership for adding an - * email, and is never an authentication strategy in this flow. + * Confirms an email address for someone who is signed in. Nobody signs in + * with it in this example. */ -export const emailOtp = makeOtp({ - storage: db.otps, - delivery: { - send: async (identifier, otp) => { - console.log(`[OTP] ${identifier}: ${otp}`); - }, - }, - ttl: 10 * 60 * 1000, +export const addEmailOtp = makeOTP({ + purpose: "add-email", + store: async (row) => { + await db.otps.insert(row); + }, + take: async (ticket) => db.otps.delete(ticket), + send: async ({ to, otp }) => { + console.log(`[OTP] Add email, ${to}: ${otp}`); + }, + ttl: 10 * 60, attempts: 3, }); -const passkey = makePasskeyEngine({ - storage: db.credentials, - challenge: { storage: db.challenges, ttl: 5 * 60 * 1000 }, - webAuthn: { - rpId: "localhost", - rpName: "Auth Passkey → OTP Demo", - allowedOrigins: ["http://localhost:3104"], - }, - displayName: async (context) => - context.intent === "add" - ? (db.users.get(context.userId)?.email ?? context.userId) - : "New user", - signUp: async () => db.users.create().userId, - debug: true, -}); +export const passkey = makePasskey({ + rpId: "localhost", + rpName: "Auth Passkey → OTP Demo", + origins: ["http://localhost:3104"], + ttl: 5 * 60, + storeChallenge: async (challenge, row) => { + await db.challenges.insert({ + id: challenge, + userId: row.handle, + expiresAt: row.expiresAt, + }); + }, + takeChallenge: async (challenge) => { + const row = await db.challenges.delete(challenge); + + return row ? { handle: row.userId, expiresAt: row.expiresAt } : null; + }, + storeCredential: async (credentialId, row) => { + await db.credentials.insert({ + id: credentialId, + userId: row.handle, + publicKey: row.publicKey, + counter: row.counter, + }); + }, + getCredential: async (credentialId) => { + const row = await db.credentials.get(credentialId); -export const auth = makeAuth(session, (kernel) => ({ - passkeys: makePasskeyStrategy(kernel, passkey), -})); + return row + ? { handle: row.userId, publicKey: row.publicKey, counter: row.counter } + : null; + }, + setCounter: async (credentialId, counter) => { + await db.credentials.update(credentialId, { counter }); + }, +}); diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/db.ts b/examples/tanstack-start-react/passkey-otp-memory/src/db.ts index 4b4a627..fc77b7c 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/db.ts +++ b/examples/tanstack-start-react/passkey-otp-memory/src/db.ts @@ -4,100 +4,39 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ -import type { - ChallengeRecord, - CredentialRecord, - OtpRecord, - SessionRecord, -} from "authax"; - -const users = new Map(); -let userIdCounter = 0; - -const sessions = new Map(); -const otps = new Map(); -const credentials = new Map(); -const challenges = new Map(); +import { makeMemoryTable } from "@repo/spike/demo"; export const db = { - users: { - create: () => { - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId }); - return { userId }; - }, - - get: (userId: string) => users.get(userId), - - setEmail: (userId: string, email: string) => { - const user = users.get(userId); - if (!user) return undefined; - user.email = email; - return user; - }, - }, - - sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); - }, - - get: async (sessionId: string) => sessions.get(sessionId) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, - - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } - } - }, - }, - - otps: { - store: async (record: OtpRecord) => { - otps.set(record.identifier, record); - }, - - take: async (identifier: string) => { - const record = otps.get(identifier) ?? null; - otps.delete(identifier); - return record; - }, - }, - - credentials: { - store: async (record: CredentialRecord) => { - credentials.set(record.credentialId, record); - }, - - get: async (credentialId: string) => credentials.get(credentialId) ?? null, - - list: async (userId: string) => - Array.from(credentials.values()).filter((c) => c.userId === userId), - - setCounter: async (credentialId: string, counter: number) => { - const record = credentials.get(credentialId); - if (record) credentials.set(credentialId, { ...record, counter }); - }, - - delete: (credentialId: string) => { - credentials.delete(credentialId); - }, - }, - - challenges: { - store: async (record: ChallengeRecord) => { - challenges.set(record.challenge, record); - }, - - take: async (challenge: string) => { - const record = challenges.get(challenge) ?? null; - challenges.delete(challenge); - return record; - }, - }, + users: makeMemoryTable<{ + userId: string; + email?: string; + }>("userId"), + + sessions: makeMemoryTable<{ + id: string; + userId: string; + expiresAt: number; + }>("id"), + + otps: makeMemoryTable<{ + ticket: string; + identifier: string; + otp: string; + expiresAt: number; + attemptsLeft: number; + }>("ticket"), + + credentials: makeMemoryTable<{ + id: string; + userId: string; + publicKey: string; + counter: number; + }>("id"), + + challenges: makeMemoryTable<{ + id: string; + /** Who a registration is for. Null when the challenge is for signing in. */ + userId: string | null; + expiresAt: number; + }>("id"), }; diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/routes/index.tsx b/examples/tanstack-start-react/passkey-otp-memory/src/routes/index.tsx index 15c5ea7..a36b792 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/routes/index.tsx +++ b/examples/tanstack-start-react/passkey-otp-memory/src/routes/index.tsx @@ -86,6 +86,7 @@ function UnauthenticatedView(props: { onSignedIn: () => void }) { function AddEmailFlow(props: { onSuccess: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -97,6 +98,7 @@ function AddEmailFlow(props: { onSuccess: () => void }) { e.preventDefault(); const result = await requestOtp({ data: { identifier: email } }); if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -125,7 +127,7 @@ function AddEmailFlow(props: { onSuccess: () => void }) { onSubmit={async (e) => { e.preventDefault(); const result = await addEmail({ - data: { identifier: email, otp }, + data: { ticket, otp }, }); if (result.success) { props.onSuccess(); @@ -141,9 +143,7 @@ function AddEmailFlow(props: { onSuccess: () => void }) { diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/session-cookie.ts b/examples/tanstack-start-react/passkey-otp-memory/src/session-cookie.ts index 60eb30c..4f99d5a 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/session-cookie.ts +++ b/examples/tanstack-start-react/passkey-otp-memory/src/session-cookie.ts @@ -1,4 +1,5 @@ import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { sessionTtl } from "./auth"; const name = "session"; @@ -12,7 +13,7 @@ const options = { /** Moves the session token between the request and the auth API */ export const sessionCookie = { get: () => getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/styles.css b/examples/tanstack-start-react/passkey-otp-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/styles.css +++ b/examples/tanstack-start-react/passkey-otp-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/use-passkey.ts b/examples/tanstack-start-react/passkey-otp-memory/src/use-passkey.ts index dbb95f6..a8659b1 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/use-passkey.ts +++ b/examples/tanstack-start-react/passkey-otp-memory/src/use-passkey.ts @@ -82,7 +82,7 @@ export function usePasskeyAuthentication( const start = await options.start(); if (!start.success) { - setError("Failed to start sign-in"); + setError("Failed to start authentication"); setLoading(false); return; } @@ -90,7 +90,7 @@ export function usePasskeyAuthentication( const credential = await getPasskey(start.options); if (!credential) { - setError("Passkey sign-in was cancelled"); + setError("Passkey authentication was cancelled"); setLoading(false); return; } @@ -100,11 +100,11 @@ export function usePasskeyAuthentication( if (result.success) { options.onSuccess(); } else { - setError("Failed to sign in"); + setError("Failed to authenticate with passkey"); } } catch (cause) { - console.error("Passkey sign-in failed:", cause); - setError("Passkey sign-in failed"); + console.error("Passkey authentication failed:", cause); + setError("Passkey authentication failed"); } setLoading(false); diff --git a/package.json b/package.json index 09abcb3..32b70f6 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@repo/monorepo", "private": true, "type": "module", + "packageManager": "bun@1.4.2", "workspaces": [ "packages/*", "examples/*/*" @@ -14,7 +15,7 @@ "test:run": "vitest run", "test:watch": "vitest", "typecheck": "bun run --filter '*' typecheck", - "check": "bun run format:check && bun run lint:check && bun run test:run && bun run typecheck", + "check": "bun install --frozen-lockfile && bun run format:check && bun run lint:check && bun run test:run && bun run typecheck", "outdated": "bun outdated --recursive", "update": "bun update --recursive", "update:latest": "bun update --latest --recursive", @@ -25,6 +26,6 @@ "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "typescript": "^6.0.3", - "vitest": "^5.0.2" + "vitest": "^5.0.3" } } diff --git a/packages/authax/package.json b/packages/authax/package.json index 1f56c21..c6b1f3e 100644 --- a/packages/authax/package.json +++ b/packages/authax/package.json @@ -21,7 +21,7 @@ }, "peerDependencies": { "@tanstack/react-start": "^1.161.1", - "next": "^16.3.6" + "next": "^16.3.8" }, "peerDependenciesMeta": { "@tanstack/react-start": { @@ -33,9 +33,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@tanstack/react-start": "^1.168.58", - "eslint": "^10.11.0", - "neon-testing": "^3.0.1", - "typescript-eslint": "^8.70.1" + "@tanstack/react-start": "^1.168.60", + "eslint": "^10.12.0", + "neon-testing": "^3.0.2", + "typescript-eslint": "^8.71.0" } } diff --git a/packages/spike/README.md b/packages/spike/README.md new file mode 100644 index 0000000..bcfda14 --- /dev/null +++ b/packages/spike/README.md @@ -0,0 +1,44 @@ +# Spike + +This library consists of three main entities. + +- Session managers +- Authentication strategies +- Proofs + +This is how they relate. + +- A strategy authenticates and issues a proof of what was proven. +- A session manager consumes the proof and issues a session. +- The app hands the proof from one to the other and decides what the session holds. + +Strategies and session managers are completely independent. They communicate using proofs. + +## Decisions + +- An OTP is verified against the request that asked for it, not the identifier. The requesting client holds a random ticket from `send`, and the OTP alone is useless without it. Signing in on another device is a magic link's job. +- The library generates every string it hands a client. OTP tickets, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. +- A different trade-off is a different factory. An OTP verified against the identifier, with nothing for the client to hold, would sit beside the ticket one rather than change it. +- The library is not extensible. It ships the strategies and session managers, and an app wires them and writes none of its own. `issueProof` and `consumeProof` are not exported, so an agent cannot shortcut sign-in through the library and have it look like proper use. Auth is what everyone gets wrong, agents most of all, so the scrutiny belongs in one place. A new strategy or session manager is a pull request. + +## OTP lookup options + +The identifier is the email address or phone number the OTP is sent to. + +| | Ticket | Identifier, one pending | Identifier, many pending | +| --- | --- | --- | --- | +| Verify looks up by | the ticket | the identifier | the identifier and the OTP together | +| Client holds between send and verify | the ticket | nothing | nothing | +| Typing the OTP on another device | does not work | works | works | +| A second request for the same identifier | stands beside the first | replaces the first | stands beside the first | +| A guess is tested against | one OTP | one OTP | every pending OTP for that identifier | +| Wrong attempts are counted on | the request row | the identifier row | a separate counter per identifier | +| Someone sees the OTP mid sign-in | useless without the ticket | usable with the identifier | usable with the identifier | +| A stranger requests an OTP for your identifier | yours is untouched | yours stops working | one more OTP that would work | + +Ticket is the only option where an OTP is bound to the client that asked and where a stranger's request changes nothing for you. Identifier with one pending is the simplest for the app and the weakest against interference. Identifier with many pending has the costs of both and should not be built. + +## Revisit + +- Opening the library to bespoke strategies and session managers, by exporting `issueProof` and `consumeProof` or a `makeStrategy` factory around them. Either hands the shortcut back, so only on real demand. +- Type contracts. For now the types are inferred from the functions, which shows what is needed and what is not. Once the shapes settle, a new strategy or session manager starts from its contract type, as in `const makeThing: Contract = (args) => {}`. diff --git a/packages/spike/package.json b/packages/spike/package.json new file mode 100644 index 0000000..7e517b0 --- /dev/null +++ b/packages/spike/package.json @@ -0,0 +1,12 @@ +{ + "name": "@repo/spike", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./demo": "./src/demo/index.ts" + }, + "scripts": { + "typecheck": "tsc" + } +} diff --git a/packages/spike/spike-authenticator.ts b/packages/spike/spike-authenticator.ts new file mode 100644 index 0000000..0def0af --- /dev/null +++ b/packages/spike/spike-authenticator.ts @@ -0,0 +1,161 @@ +import { base64urlEncode, sha256 } from "./src/webauthn/crypto"; + +/** + * A software authenticator for the playground. It stands in for the browser + * and the device, and answers ceremony options the way they would. The first + * passkey it made is the one it signs in with. + */ +export function makeAuthenticator(origin: string) { + const passkeys: { + credentialId: Uint8Array; + privateKey: CryptoKey; + uses: number; + }[] = []; + + return { + /** What navigator.credentials.create returns, as JSON */ + create: async (options: PublicKeyCredentialCreationOptionsJSON) => { + const keys = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const jwk = await crypto.subtle.exportKey("jwk", keys.publicKey); + const credentialId = crypto.getRandomValues(new Uint8Array(16)); + + passkeys.push({ credentialId, privateKey: keys.privateKey, uses: 0 }); + + const authenticatorData = [ + ...(await authenticatorDataHead(options.rp.id ?? "", 0x45, 0)), + ...new Uint8Array(16), + credentialId.length >> 8, + credentialId.length & 0xff, + ...credentialId, + ...map([ + [integer(1), integer(2)], + [integer(3), integer(-7)], + [integer(-1), integer(1)], + [integer(-2), bytes(decode(jwk.x ?? ""))], + [integer(-3), bytes(decode(jwk.y ?? ""))], + ]), + ]; + + return { + response: { + clientDataJSON: clientData("webauthn.create", options.challenge), + attestationObject: base64urlEncode( + new Uint8Array( + map([ + [text("fmt"), text("none")], + [text("attStmt"), map([])], + [text("authData"), bytes(new Uint8Array(authenticatorData))], + ]), + ), + ), + }, + }; + }, + + /** What navigator.credentials.get returns, as JSON */ + get: async (options: PublicKeyCredentialRequestOptionsJSON) => { + const [passkey] = passkeys; + + if (passkey === undefined) throw new Error("no passkey on this device"); + + passkey.uses += 1; + + const clientDataJSON = clientData("webauthn.get", options.challenge); + const authenticatorData = new Uint8Array( + await authenticatorDataHead(options.rpId ?? "", 0x05, passkey.uses), + ); + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + passkey.privateKey, + new Uint8Array([ + ...authenticatorData, + ...(await sha256(decode(clientDataJSON))), + ]), + ); + + return { + id: base64urlEncode(passkey.credentialId), + response: { + clientDataJSON, + authenticatorData: base64urlEncode(authenticatorData), + signature: base64urlEncode(der(new Uint8Array(signature))), + }, + }; + }, + }; + + function clientData(type: string, challenge: string) { + return base64urlEncode( + JSON.stringify({ type, challenge, origin, crossOrigin: false }), + ); + } +} + +/** The hash of the relying party id, the flags, and the counter */ +async function authenticatorDataHead( + rpId: string, + flags: number, + counter: number, +) { + return [ + ...(await sha256(new TextEncoder().encode(rpId))), + flags, + counter >>> 24, + (counter >> 16) & 0xff, + (counter >> 8) & 0xff, + counter & 0xff, + ]; +} + +function decode(base64url: string) { + const binary = atob(base64url.replaceAll("-", "+").replaceAll("_", "/")); + + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +/** Web Crypto signs as r and s side by side, WebAuthn sends them as DER */ +function der(signature: Uint8Array) { + const part = (value: Uint8Array) => { + const trimmed = [...value]; + + while (trimmed.length > 1 && trimmed[0] === 0) trimmed.shift(); + + const positive = (trimmed[0] ?? 0) & 0x80 ? [0, ...trimmed] : trimmed; + + return [0x02, positive.length, ...positive]; + }; + + const r = part(signature.subarray(0, 32)); + const s = part(signature.subarray(32)); + + return new Uint8Array([0x30, r.length + s.length, ...r, ...s]); +} + +// The few CBOR encoders the two responses need + +function head(major: number, length: number) { + if (length < 24) return [(major << 5) | length]; + if (length < 256) return [(major << 5) | 24, length]; + + return [(major << 5) | 25, length >> 8, length & 0xff]; +} + +function integer(value: number) { + return value >= 0 ? head(0, value) : head(1, -1 - value); +} + +function bytes(value: Uint8Array) { + return [...head(2, value.length), ...value]; +} + +function text(value: string) { + return [...head(3, value.length), ...new TextEncoder().encode(value)]; +} + +function map(entries: [number[], number[]][]) { + return [...head(5, entries.length), ...entries.flat(2)]; +} diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts new file mode 100644 index 0000000..3de4ad9 --- /dev/null +++ b/packages/spike/spike-no-kernel.ts @@ -0,0 +1,231 @@ +import { + makeOpaqueSessionManager, + makeOTP, + makePasskey, + makeSignedSessionManager, +} from "./src/index"; +import { makeMemoryTable } from "./src/demo/index"; +import { makeAuthenticator } from "./spike-authenticator"; + +/** + * App + */ + +const sessionsTable = makeMemoryTable<{ + token: string; + userId: string; + expiresAt: number; +}>("token"); +const otpsTable = makeMemoryTable<{ + ticket: string; + identifier: string; + otp: string; + expiresAt: number; + attemptsLeft: number; +}>("ticket"); +const challengesTable = makeMemoryTable<{ + challenge: string; + handle: string | null; + expiresAt: number; +}>("challenge"); +const credentialsTable = makeMemoryTable<{ + credentialId: string; + handle: string; + publicKey: string; + counter: number; +}>("credentialId"); + +// Stands in for the browser and the device +const authenticator = makeAuthenticator("http://localhost:3000"); + +// Captures what would have been delivered, for the demo +const delivered = new Map(); + +const opaque = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await sessionsTable.insert({ token, ...row }); + }, + get: (token) => sessionsTable.get(token), + delete: async (token) => { + await sessionsTable.delete(token); + }, + ttl: 30 * 24 * 60 * 60, +}); + +const emailOtp = makeOTP({ + purpose: "sign-in-email", + store: async (row) => { + await otpsTable.insert(row); + }, + take: (ticket) => otpsTable.delete(ticket), + send: async ({ to, otp }) => { + console.log("email to:", to, "otp:", otp); + delivered.set(to, otp); + }, + ttl: 10 * 60, + attempts: 3, +}); + +const smsOtp = makeOTP({ + purpose: "sign-in-sms", + store: async (row) => { + await otpsTable.insert(row); + }, + take: (ticket) => otpsTable.delete(ticket), + send: async ({ to, otp }) => { + console.log("sms to:", to, "otp:", otp); + delivered.set(to, otp); + }, + ttl: 10 * 60, + attempts: 3, +}); + +const passkey = makePasskey({ + rpId: "localhost", + rpName: "Spike", + origins: ["http://localhost:3000"], + ttl: 5 * 60, + storeChallenge: async (challenge, row) => { + await challengesTable.insert({ challenge, ...row }); + }, + takeChallenge: (challenge) => challengesTable.delete(challenge), + storeCredential: async (credentialId, row) => { + await credentialsTable.insert({ credentialId, ...row }); + }, + getCredential: (credentialId) => credentialsTable.get(credentialId), + setCounter: async (credentialId, counter) => { + await credentialsTable.update(credentialId, { counter }); + }, +}); + +// +// Playground +// + +console.log("-".repeat(80)); + +// Email. 1. request, 2. verify, 3. session +const emailTicket = await emailOtp.send("ripley@example.com"); + +const emailProof = await emailOtp.verify({ + ticket: emailTicket, + otp: delivered.get("ripley@example.com") ?? "", +}); +if (!emailProof.success) throw new Error(emailProof.error); + +const emailSessionId = await opaque.make({ + userId: `user-for-${emailProof.data.identifier}`, +}); +console.log("SESSION", await opaque.get(emailSessionId)); + +// SMS. Same three steps, other instance +const smsTicket = await smsOtp.send("+15555550100"); + +const smsProof = await smsOtp.verify({ + ticket: smsTicket, + otp: delivered.get("+15555550100") ?? "", +}); +if (!smsProof.success) throw new Error(smsProof.error); + +const smsSessionId = await opaque.make({ + userId: `user-for-${smsProof.data.identifier}`, +}); +console.log("SESSION", await opaque.get(smsSessionId)); + +// Passkey sign-up. 1. app creates the user, 2. begin with its id as the +// handle, 3. browser creates a credential, 4. finish, 5. session +const ripley = "user-ripley"; +const signUp = await passkey.beginRegistration({ + handle: ripley, + name: "ripley@example.com", +}); +console.log("OPTIONS", signUp); +const registered = await passkey.finishRegistration( + await authenticator.create(signUp), +); +if (!registered.success) throw new Error(registered.error); + +const signUpSessionId = await opaque.make({ + userId: registered.data.userHandle, +}); +console.log("SESSION", await opaque.get(signUpSessionId)); + +// Passkey sign-in. 1. begin, 2. browser signs, 3. finish, 4. session +const signIn = await passkey.beginAuthentication(); +const authenticated = await passkey.finishAuthentication( + await authenticator.get(signIn), +); +if (!authenticated.success) throw new Error(authenticated.error); + +const signInSessionId = await opaque.make({ + userId: authenticated.data.userHandle, +}); +console.log("SESSION", await opaque.get(signInSessionId)); + +// Add a passkey while signed in. The session says who, its user id is the +// handle, no new session +const current = await opaque.get(signInSessionId); +if (current === null) throw new Error("not signed in"); + +const add = await passkey.beginRegistration({ + handle: current.userId, + name: "ripley@example.com", +}); +const added = await passkey.finishRegistration(await authenticator.create(add)); +if (!added.success) throw new Error(added.error); +console.log("ADDED", added.data); + +// Signed session manager. Same three steps, no table. The token carries the +// session +const signed = makeSignedSessionManager<{ userId: string }>({ + secret: "spike-secret", + ttl: 60 * 60, +}); + +const signedTicket = await emailOtp.send("ripley@example.com"); +const signedProof = await emailOtp.verify({ + ticket: signedTicket, + otp: delivered.get("ripley@example.com") ?? "", +}); +if (!signedProof.success) throw new Error(signedProof.error); + +const token = await signed.make({ + userId: `user-for-${signedProof.data.identifier}`, +}); +console.log("TOKEN", token); +console.log("SIGNED SESSION", await signed.get(token)); +console.log("TAMPERED", await signed.get(`${token.slice(0, -2)}xx`)); + +// Failures come back with a reason +const wrongTicket = await emailOtp.send("ripley@example.com"); +console.log( + "WRONG", + await emailOtp.verify({ ticket: wrongTicket, otp: "nope" }), +); +console.log( + "USED", + await emailOtp.verify({ ticket: emailTicket, otp: "nope" }), +); + +// A device with a passkey the app never stored +const stranger = makeAuthenticator("http://localhost:3000"); +await stranger.create( + await passkey.beginRegistration({ handle: "nobody", name: "nobody" }), +); +console.log( + "STRANGER", + await passkey.finishAuthentication( + await stranger.get(await passkey.beginAuthentication()), + ), +); + +// The right passkey, presented from another site +const elsewhere = makeAuthenticator("http://evil.example"); +console.log( + "ELSEWHERE", + await passkey.finishRegistration( + await elsewhere.create( + await passkey.beginRegistration({ handle: ripley, name: "ripley" }), + ), + ), +); diff --git a/packages/spike/src/demo/analytics.ts b/packages/spike/src/demo/analytics.ts new file mode 100644 index 0000000..fa2c764 --- /dev/null +++ b/packages/spike/src/demo/analytics.ts @@ -0,0 +1,6 @@ +/** For demos only. Stands in for an analytics service and only logs. */ +export const analytics = { + track: (event: "sign_up" | "sign_in", userId: string) => { + console.log(`[analytics] ${event}: ${userId}`); + }, +}; diff --git a/packages/spike/src/demo/index.ts b/packages/spike/src/demo/index.ts new file mode 100644 index 0000000..267a4f9 --- /dev/null +++ b/packages/spike/src/demo/index.ts @@ -0,0 +1,2 @@ +export { analytics } from "./analytics"; +export { makeMemoryTable } from "./memory-table"; diff --git a/packages/spike/src/demo/memory-table.ts b/packages/spike/src/demo/memory-table.ts new file mode 100644 index 0000000..cde00f4 --- /dev/null +++ b/packages/spike/src/demo/memory-table.ts @@ -0,0 +1,93 @@ +/** The fields of a row that hold a string, the only ones that can be its key */ +type StringField = { + [Field in keyof Row]: Row[Field] extends string ? Field : never; +}[keyof Row]; + +/** + * For demos only. Rows live in memory and are lost on restart. Every function + * is async, so the code that calls it reads as it would against a database. + * key names the field that holds the key of a row. + */ +export function makeMemoryTable( + key: StringField & keyof Row, +) { + const rows = new Map(); + + const insert = (row: Row) => { + const id = String(row[key]); + + if (rows.has(id)) throw new Error("a row with this key already exists"); + + rows.set(id, { ...row }); + }; + + const where = (match: Partial) => + [...rows.values()] + .filter((row) => + Object.entries(match).every( + ([field, value]) => row[field as keyof Row] === value, + ), + ) + .map((row) => ({ ...row })); + + return { + /** Stores a new row under its key. Throws when the key is taken. */ + insert: async (row: Row) => insert(row), + + /** The row for a key, null when there is none */ + get: async (id: string) => { + const row = rows.get(id); + + return row === undefined ? null : { ...row }; + }, + + /** + * Changes fields of the row for a key and returns the row, null when + * there is none. The key itself cannot change. + */ + update: async (id: string, fields: Partial) => { + const row = rows.get(id); + + if (row === undefined) return null; + if (key in fields) throw new Error("the key of a row cannot change"); + + rows.set(id, { ...row, ...fields }); + + return { ...row, ...fields }; + }, + + /** Removes the row for a key and returns it, null when there is none */ + delete: async (id: string) => { + const row = rows.get(id) ?? null; + rows.delete(id); + + return row; + }, + + /** Removes every row whose fields equal the ones given and returns them */ + deleteWhere: async (match: Partial) => { + const removed = where(match); + + for (const row of removed) rows.delete(String(row[key])); + + return removed; + }, + + /** Every row whose fields equal the ones given */ + where: async (match: Partial) => where(match), + + /** + * The first row whose fields equal the ones in match. When there is none, + * row is inserted and returned. isNew tells which of the two happened. + */ + findOrInsert: async (match: Partial, row: Row) => { + const [found] = where(match); + + if (found !== undefined) return { row: found, isNew: false }; + + insert(row); + + return { row: { ...row }, isNew: true }; + }, + }; +} diff --git a/packages/spike/src/flows/otp-auth-flow.ts b/packages/spike/src/flows/otp-auth-flow.ts new file mode 100644 index 0000000..d3e1c6c --- /dev/null +++ b/packages/spike/src/flows/otp-auth-flow.ts @@ -0,0 +1,152 @@ +import { makeOTP, type OtpRow } from "../strategies/otp"; +import { routeNames } from "../transports/route-names"; +import { isRecord } from "../webauthn/lib"; + +/** What a handler answers. A transport turns it into a response. */ +export type HandlerResult = { + status: number; + /** Sent to the browser as JSON */ + body: Body; + /** A new session token for the cookie. Null leaves the cookie alone. */ + setSession: string | null; +}; + +/** What the otp-send route answers */ +export type OtpSendBody = + { success: true; ticket: string } | { success: false }; + +/** What the otp-verify route answers */ +export type OtpVerifyBody = + { success: true; isNew: boolean } | { success: false; error: string }; + +/** Ten minutes and three guesses, to spread into makeOtpAuthFlow */ +export const recommendedOtpConfig = { otpTtl: 10 * 60, otpAttempts: 3 }; + +/** + * Sign in or sign up by OTP, in two calls. otp-send sends an OTP and returns + * the ticket. otp-verify checks the OTP, resolves the user, and makes the + * session, in that order. A ticket made by an OTP with another purpose is + * refused. + */ +export function makeOtpAuthFlow(args: { + /** + * Stores a new OTP row. Also called after a wrong guess, to put the row + * back with one attempt less. + */ + storeOtp: (row: OtpRow) => Promise; + /** + * Removes the row for a ticket and returns it, in one step. Null when there + * is none. It must remove the row. A row that stays lets the same OTP be + * used again until it expires. + */ + takeOtp: (ticket: string) => Promise; + /** Delivers the OTP to an email address or a phone number */ + sendOtp: (message: { to: string; otp: string }) => Promise; + /** Lifetime of an OTP in seconds */ + otpTtl: number; + /** How many guesses one ticket allows */ + otpAttempts: number; + sessionManager: { make: (session: { userId: string }) => Promise }; + /** + * Turns the verified email address or phone number into the user to sign + * in. Find the user, create one, or refuse, as the app decides. A refusal + * comes after the OTP is checked, so only the owner of the address learns + * the reason. + */ + resolveUser: ( + identifier: string, + ) => Promise< + | { success: true; user: User; isNew: boolean } + | { success: false; error: string } + >; + /** A hook must not throw. What happens when one does is not decided. */ + hooks?: { + /** Runs once the OTP is sent */ + onSend?: (sent: { identifier: string }) => Promise | void; + /** Runs once the session is made, such as for analytics */ + onSignIn?: (signedIn: { + user: User; + isNew: boolean; + }) => Promise | void; + }; +}) { + const otp = makeOTP({ + purpose: "otp-auth-flow", + store: args.storeOtp, + take: args.takeOtp, + send: args.sendOtp, + ttl: args.otpTtl, + attempts: args.otpAttempts, + }); + + const send = async (input: unknown): Promise> => { + if (!isRecord(input) || typeof input.identifier !== "string") { + return { status: 400, body: { success: false }, setSession: null }; + } + + const ticket = await otp.send(input.identifier); + + await args.hooks?.onSend?.({ identifier: input.identifier }); + + return { status: 200, body: { success: true, ticket }, setSession: null }; + }; + + const verify = async ( + input: unknown, + ): Promise> => { + if ( + !isRecord(input) || + typeof input.ticket !== "string" || + typeof input.otp !== "string" + ) { + return { + status: 400, + body: { success: false, error: "invalid_input" }, + setSession: null, + }; + } + + const verified = await otp.verify({ + ticket: input.ticket, + otp: input.otp, + }); + + if (!verified.success) { + return { + status: 200, + body: { success: false, error: verified.error }, + setSession: null, + }; + } + + const resolved = await args.resolveUser(verified.data.identifier); + + if (!resolved.success) { + return { + status: 200, + body: { success: false, error: resolved.error }, + setSession: null, + }; + } + + const token = await args.sessionManager.make({ + userId: resolved.user.userId, + }); + + await args.hooks?.onSignIn?.({ + user: resolved.user, + isNew: resolved.isNew, + }); + + return { + status: 200, + body: { success: true, isNew: resolved.isNew }, + setSession: token, + }; + }; + + return { + /** Mount these with serve, under the names the client calls */ + routes: { [routeNames.otpSend]: send, [routeNames.otpVerify]: verify }, + }; +} diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts new file mode 100644 index 0000000..8689036 --- /dev/null +++ b/packages/spike/src/index.ts @@ -0,0 +1,7 @@ +export { makeOtpAuthFlow, recommendedOtpConfig } from "./flows/otp-auth-flow"; +export { makeOpaqueSessionManager } from "./sessions/opaque"; +export { makeSignedSessionManager } from "./sessions/signed"; +export { makeOTP } from "./strategies/otp"; +export { makePasskey } from "./strategies/passkey"; +export { serve } from "./transports/rest"; +export { makeAuthClient } from "./transports/rest-client"; diff --git a/packages/spike/src/index.typecheck.ts b/packages/spike/src/index.typecheck.ts new file mode 100644 index 0000000..39fc0aa --- /dev/null +++ b/packages/spike/src/index.typecheck.ts @@ -0,0 +1,19 @@ +/** + * The export guards written during the spike, kept so they are not lost. + * Typechecked, never run. Each expected error proves a name is absent from + * the public entry. + */ + +// @ts-expect-error only the library issues proofs +import { issueProof } from "./index"; +// @ts-expect-error only the library consumes proofs +import { consumeProof } from "./index"; +// @ts-expect-error failures are made by strategies, the app only reads them +import { fail } from "./index"; +// @ts-expect-error an app never has to name a proof +import type { Proof } from "./index"; +// @ts-expect-error an app never has to name a failure +import type { Failure } from "./index"; + +export const absent = [issueProof, consumeProof, fail]; +export type Absent = [Proof, Failure]; diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts new file mode 100644 index 0000000..7044bd2 --- /dev/null +++ b/packages/spike/src/proof.ts @@ -0,0 +1,32 @@ +// Not used while the spike runs without a proof. Kept as a reference for when +// a proof comes back. + +/** Marks the type so a proof cannot be written by hand. Never exported. */ +const brand: unique symbol = Symbol("proof"); + +/** + * A receipt for what a strategy proved. A strategy issues one and a session + * manager checks it. The mark is a symbol only this module holds, so an + * object that only looks like a proof is refused. A proof is an object in the + * server's memory and never leaves the process that issued it. What the app + * does with a proof, and how many times, is the app's decision. + */ +export type Proof = { readonly proven: T; readonly [brand]: true }; + +/** A strategy ends by calling this. Nothing else makes a proof. */ +export function issueProof(proven: T): Proof { + return Object.freeze({ + proven: Object.freeze({ ...proven }), + [brand]: true as const, + }); +} + +/** + * A session manager starts by calling this. Throws for an object that does + * not carry the mark. + */ +export function consumeProof(proof: Proof): void { + if (!(brand in proof)) { + throw new Error("not a proof"); + } +} diff --git a/packages/spike/src/proof.typecheck.ts b/packages/spike/src/proof.typecheck.ts new file mode 100644 index 0000000..2f7628b --- /dev/null +++ b/packages/spike/src/proof.typecheck.ts @@ -0,0 +1,26 @@ +/** + * The compile guards written during the spike, kept so they are not lost. + * Typechecked, never run. + */ +import type { Proof } from "./proof"; + +// @ts-expect-error a proof cannot be written by hand +const written: Proof<{ identifier: string }> = { + proven: { identifier: "victim@example.com" }, +}; +void written; + +// Does not hold while sessionManager.make takes no proof. Kept as a reference +// for when a proof comes back. +// +// import type { makeOpaqueSessionManager } from "./sessions/opaque"; +// +// declare const sessionManager: ReturnType< +// typeof makeOpaqueSessionManager<{ userId: string }> +// >; +// +// void sessionManager.make( +// // @ts-expect-error a session cannot be made without a proof +// { proven: { identifier: "victim@example.com" } }, +// { userId: "victim" }, +// ); diff --git a/packages/spike/src/result.ts b/packages/spike/src/result.ts new file mode 100644 index 0000000..c12959b --- /dev/null +++ b/packages/spike/src/result.ts @@ -0,0 +1,28 @@ +/** + * Every reason a strategy can fail with. A reason is added here before + * anything can fail with it. + */ +type Reason = + | "unknown_ticket" + | "expired_otp" + | "wrong_otp" + | "unknown_challenge" + | "expired_challenge" + | "unknown_credential" + | "invalid_credential"; + +/** What a strategy returns. Narrow on success, then read data or error. */ +export type Result = Success | Failure; + +type Success = { success: true; data: T }; + +/** An expected failure the app branches on. The error names what did not hold. */ +type Failure = { success: false; error: E }; + +export function succeed(data: T): Success { + return { success: true, data }; +} + +export function fail(reason: E): Failure { + return { success: false, error: reason }; +} diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts new file mode 100644 index 0000000..3355b23 --- /dev/null +++ b/packages/spike/src/sessions/opaque.ts @@ -0,0 +1,49 @@ +/** + * The token is a random string the row is stored under. The row is the + * session with expiresAt added, so a session has no expiresAt of its own. + */ +export function makeOpaqueSessionManager(args: { + /** Stores a session row under the token. expiresAt is in seconds since the epoch. */ + store: (token: string, row: Session & { expiresAt: number }) => Promise; + /** Reads the session row for a token, null when there is none */ + get: (token: string) => Promise<(Session & { expiresAt: number }) | null>; + /** Removes the session row for a token. Does nothing when there is none. */ + delete: (token: string) => Promise; + /** Lifetime of a session in seconds */ + ttl: number; +}) { + return { + /** + * Returns the token the session is stored under + * + * A route that sets the token as the session cookie must first check + * that the request's Origin is one of the app's own. Otherwise a page on + * another site can sign the visitor in to the attacker's account. + */ + make: async (session: Session) => { + const token = crypto.randomUUID(); + + await args.store(token, { + ...session, + expiresAt: Math.floor(Date.now() / 1000) + args.ttl, + }); + + return token; + }, + + /** Null when there is no session for the token or it has expired */ + get: async (token: string): Promise => { + const row = await args.get(token); + + if (row === null) return null; + if (row.expiresAt <= Math.floor(Date.now() / 1000)) return null; + + return row; + }, + + /** Ends the session. The token stops working at once. */ + end: async (token: string) => { + await args.delete(token); + }, + }; +} diff --git a/packages/spike/src/sessions/signed.ts b/packages/spike/src/sessions/signed.ts new file mode 100644 index 0000000..df1e0eb --- /dev/null +++ b/packages/spike/src/sessions/signed.ts @@ -0,0 +1,91 @@ +/** + * Stateless. The session travels inside the token, signed so it cannot be + * altered. Nothing is stored, so there is nothing to end. A token is valid + * until it expires, which is why ttl is not optional here. + */ +export function makeSignedSessionManager(args: { + /** HMAC secret. Anyone holding it can mint a session. */ + secret: string; + /** Lifetime of a token in seconds */ + ttl: number; +}) { + const key = crypto.subtle.importKey( + "raw", + new TextEncoder().encode(args.secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + + return { + /** + * Returns the signed token that carries the session + * + * A route that sets the token as the session cookie must first check + * that the request's Origin is one of the app's own. Otherwise a page on + * another site can sign the visitor in to the attacker's account. + */ + make: async (session: Session) => { + const payload = encode( + JSON.stringify({ + session, + exp: Math.floor(Date.now() / 1000) + args.ttl, + }), + ); + const signature = await crypto.subtle.sign( + "HMAC", + await key, + bytes(payload), + ); + + return `${payload}.${encode(signature)}`; + }, + + get: async (token: string) => { + const [payload, signature, ...rest] = token.split("."); + if (payload === undefined || signature === undefined || rest.length > 0) { + return null; + } + + const valid = await crypto.subtle.verify( + "HMAC", + await key, + decode(signature), + bytes(payload), + ); + if (!valid) return null; + + // Safe to trust the shape, the signature proves this process wrote it + const { session, exp } = JSON.parse(text(decode(payload))) as { + session: Session; + exp: number; + }; + if (exp <= Math.floor(Date.now() / 1000)) return null; + + return session; + }, + }; +} + +function bytes(value: string) { + return new TextEncoder().encode(value); +} + +function text(value: Uint8Array) { + return new TextDecoder().decode(value); +} + +function encode(value: string | ArrayBuffer) { + const raw = typeof value === "string" ? bytes(value) : new Uint8Array(value); + + return btoa(String.fromCharCode(...raw)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +function decode(value: string) { + const padded = value.replaceAll("-", "+").replaceAll("_", "/"); + + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +} diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts new file mode 100644 index 0000000..c332ea6 --- /dev/null +++ b/packages/spike/src/strategies/otp.ts @@ -0,0 +1,123 @@ +import { fail, succeed, type Result } from "../result"; + +/** What the app stores for one otp request */ +export type OtpRow = { + /** The key of the row. The client holds it until it verifies. */ + ticket: string; + /** The identifier the otp is being sent to (email address or phone number) */ + identifier: string; + /** The one-time password itself */ + otp: string; + /** When the otp stops working, in seconds since the epoch */ + expiresAt: number; + /** How many guesses the ticket still allows */ + attemptsLeft: number; +}; + +export function makeOTP(args: { + /** + * What this instance is for, such as "confirm-email". It goes into every + * ticket, and verify refuses a ticket made for another purpose, so an otp + * sent for one thing can never be used for another. + */ + purpose: string; + /** + * Stores a new otp row. Also called after a wrong guess, to put the row + * back with one attempt less. + */ + store: (row: OtpRow) => Promise; + /** + * Removes the row for a ticket and returns it, in one step. Null when there + * is none. It must remove the row. A row that stays lets the same otp be + * used again until it expires, and lets guesses made at the same time get + * past the limit. + */ + take: (ticket: string) => Promise; + /** Delivers the otp to an email address or a phone number */ + send: (message: { to: string; otp: string }) => Promise; + /** Lifetime of an otp in seconds */ + ttl: number; + /** How many guesses one ticket allows */ + attempts: number; +}) { + return { + /** Returns the ticket the requesting client holds until it verifies */ + send: async (identifier: string) => { + const ticket = `${args.purpose}:${crypto.randomUUID()}`; + const otp = sixDigits(); + + await args.store({ + ticket, + identifier, + otp, + expiresAt: Math.floor(Date.now() / 1000) + args.ttl, + attemptsLeft: args.attempts, + }); + await args.send({ to: identifier, otp }); + + return ticket; + }, + + /** + * Checks the otp and gives the identifier it was sent to + * + * A route that signs someone in with the result must first check that + * the request's Origin is one of the app's own. Otherwise a page on + * another site can sign the visitor in to the attacker's account. + */ + verify: async ({ + ticket, + otp, + }: { + ticket: string; + otp: string; + }): Promise< + Result< + { identifier: string }, + "unknown_ticket" | "expired_otp" | "wrong_otp" + > + > => { + // The random part has no colon, so the purpose is everything before the + // last one. Comparing the whole of it keeps "email" from accepting a + // ticket made for "email:change". + const separator = ticket.lastIndexOf(":"); + + if (separator === -1 || ticket.slice(0, separator) !== args.purpose) { + return fail("unknown_ticket"); + } + + // The row is out of the table while it is checked, so guesses made at + // the same time cannot get past the limit + const row = await args.take(ticket); + + if (row === null) return fail("unknown_ticket"); + if (row.expiresAt <= Math.floor(Date.now() / 1000)) + return fail("expired_otp"); + + if (row.otp !== otp) { + if (row.attemptsLeft > 1) { + await args.store({ ...row, attemptsLeft: row.attemptsLeft - 1 }); + } + + return fail("wrong_otp"); + } + + return succeed({ identifier: row.identifier }); + }, + }; +} + +/** Six random digits, every value as likely as any other */ +function sixDigits() { + const bytes = new Uint8Array(4); + const view = new DataView(bytes.buffer); + + // 4 294 000 000 is the largest multiple of a million that fits in 32 bits. + // A draw at or above it is thrown away, or the low values would come up + // more often. + do { + crypto.getRandomValues(bytes); + } while (view.getUint32(0) >= 4_294_000_000); + + return String(view.getUint32(0) % 1_000_000).padStart(6, "0"); +} diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts new file mode 100644 index 0000000..9858296 --- /dev/null +++ b/packages/spike/src/strategies/passkey.ts @@ -0,0 +1,206 @@ +import { fail, succeed, type Result } from "../result"; +import type { + PasskeyAuthenticationCredential, + PasskeyRegistrationCredential, +} from "../webauthn/contracts"; +import { + base64urlDecode, + base64urlEncode, + randomBase64url, +} from "../webauthn/crypto"; +import { invariant } from "../webauthn/lib"; +import { + parseClientData, + verifyAuthenticationCredential, + verifyRegistrationCredential, +} from "../webauthn/webauthn"; + +/** What the app stores for one challenge */ +type ChallengeRow = { + /** Who a registration is for. Null when the challenge is for authentication. */ + handle: string | null; + /** When the challenge stops working, in seconds since the epoch */ + expiresAt: number; +}; + +/** What the app stores for one passkey */ +type CredentialRow = { + /** Who the passkey belongs to */ + handle: string; + publicKey: string; + /** How many times the authenticator says the passkey was used */ + counter: number; +}; + +/** + * The library stores the credential with the handle the app gave it and hands + * the handle back on authentication. What the handle means is the app's + * business. + */ +export function makePasskey(args: { + /** The relying party id, the domain passkeys are bound to */ + rpId: string; + /** The relying party name, shown by the authenticator */ + rpName: string; + /** Every origin a ceremony may run from, as scheme, host, and port */ + origins: string[]; + /** Lifetime of a challenge in seconds */ + ttl: number; + /** Stores a challenge row under the challenge */ + storeChallenge: (challenge: string, row: ChallengeRow) => Promise; + /** Removes the row for a challenge and returns it, atomically. Null when there is none. */ + takeChallenge: (challenge: string) => Promise; + /** Stores a credential row under the id the authenticator chose */ + storeCredential: (credentialId: string, row: CredentialRow) => Promise; + /** Reads the credential row for an id, null when there is none */ + getCredential: (credentialId: string) => Promise; + /** Writes the counter of a credential after the passkey was used */ + setCounter: (credentialId: string, counter: number) => Promise; +}) { + const policy = { rpId: args.rpId, allowedOrigins: args.origins }; + + // Spike only. Says in the server log why the verification refused a + // passkey, until the reasons are designed. + const refused = (error: unknown) => { + console.error("[passkey] refused:", error); + + return null; + }; + + const makeChallenge = async (handle: string | null) => { + const challenge = randomBase64url(32); + + await args.storeChallenge(challenge, { + handle, + expiresAt: Math.floor(Date.now() / 1000) + args.ttl, + }); + + return challenge; + }; + + return { + /** + * Returns the options the browser creates a passkey from. handle is the + * app's stable id for the person, the authenticator keeps it with the + * credential. name is what the authenticator shows, an email or a + * username. + */ + beginRegistration: async ({ + handle, + name, + }: { + handle: string; + name: string; + }): Promise => ({ + challenge: await makeChallenge(handle), + rp: { id: args.rpId, name: args.rpName }, + user: { id: base64urlEncode(handle), name, displayName: name }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + attestation: "none", + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }), + + /** Takes what the browser made from the options, and stores the passkey */ + finishRegistration: async ( + credential: PasskeyRegistrationCredential, + ): Promise< + Result< + { credentialId: string; userHandle: string }, + "unknown_challenge" | "expired_challenge" | "invalid_credential" + > + > => { + const clientData = parseClientData(credential.response.clientDataJSON); + + if (clientData === null) return fail("invalid_credential"); + + const row = await args.takeChallenge(clientData.challenge); + + if (row === null || row.handle === null) return fail("unknown_challenge"); + if (row.expiresAt <= Math.floor(Date.now() / 1000)) + return fail("expired_challenge"); + + const verified = await verifyRegistrationCredential( + credential, + clientData.challenge, + policy, + ).catch(refused); + + if (verified === null) return fail("invalid_credential"); + + await args.storeCredential(verified.credentialId, { + handle: row.handle, + publicKey: base64urlEncode(verified.publicKey), + counter: verified.counter, + }); + + return succeed({ + credentialId: verified.credentialId, + userHandle: row.handle, + }); + }, + + /** Returns the options the browser signs in with */ + beginAuthentication: + async (): Promise => ({ + challenge: await makeChallenge(null), + rpId: args.rpId, + userVerification: "preferred", + }), + + /** + * Takes what the browser made from the options + * + * A route that signs someone in with the result must first check that + * the request's Origin is one of the app's own. Otherwise a page on + * another site can sign the visitor in to the attacker's account. + */ + finishAuthentication: async ( + credential: PasskeyAuthenticationCredential, + ): Promise< + Result< + { credentialId: string; userHandle: string }, + | "unknown_challenge" + | "expired_challenge" + | "unknown_credential" + | "invalid_credential" + > + > => { + const clientData = parseClientData(credential.response.clientDataJSON); + + if (clientData === null) return fail("invalid_credential"); + + const row = await args.takeChallenge(clientData.challenge); + + if (row === null || row.handle !== null) return fail("unknown_challenge"); + if (row.expiresAt <= Math.floor(Date.now() / 1000)) + return fail("expired_challenge"); + + const stored = await args.getCredential(credential.id); + + if (stored === null) return fail("unknown_credential"); + + const publicKey = base64urlDecode(stored.publicKey); + + invariant(publicKey, "the library stored the public key as base64url"); + + const verified = await verifyAuthenticationCredential( + credential, + { publicKey, counter: stored.counter }, + clientData.challenge, + policy, + ).catch(refused); + + if (verified === null) return fail("invalid_credential"); + + await args.setCounter(credential.id, verified.counter); + + return succeed({ + credentialId: credential.id, + userHandle: stored.handle, + }); + }, + }; +} diff --git a/packages/spike/src/transports/rest-client.ts b/packages/spike/src/transports/rest-client.ts new file mode 100644 index 0000000..51d8702 --- /dev/null +++ b/packages/spike/src/transports/rest-client.ts @@ -0,0 +1,28 @@ +import type { OtpSendBody, OtpVerifyBody } from "../flows/otp-auth-flow"; +import { routeNames } from "./route-names"; + +/** + * For the browser. Calls the auth handler mounted at basePath, such as + * "/api/auth", with one method per route. + */ +export function makeAuthClient(basePath: string) { + const post = async (route: string, body: unknown) => { + const response = await fetch(`${basePath}/${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + return response.json(); + }; + + return { + otpSend: async (identifier: string): Promise => + post(routeNames.otpSend, { identifier }), + + otpVerify: async (input: { + ticket: string; + otp: string; + }): Promise => post(routeNames.otpVerify, input), + }; +} diff --git a/packages/spike/src/transports/rest.ts b/packages/spike/src/transports/rest.ts new file mode 100644 index 0000000..d0c5123 --- /dev/null +++ b/packages/spike/src/transports/rest.ts @@ -0,0 +1,60 @@ +import type { HandlerResult } from "../flows/otp-auth-flow"; + +/** + * Answers one request to the auth routes. The route is the last part of the + * path, such as otp-send in /api/auth/otp-send. Only POST from one of the + * app's origins is accepted. Sets the session cookie when the handler made a + * session. + */ +export async function serve( + request: Request, + routes: Record Promise>>, + config: { + cookie: { name: string; maxAge: number; secure: boolean }; + /** + * Every origin the app's pages are served from, as scheme, host, and + * port. Not read from the request, since behind a proxy the request's URL + * holds the internal address. + */ + origins: string[]; + }, +): Promise { + if (request.method !== "POST") return new Response(null, { status: 405 }); + + const origin = request.headers.get("origin"); + + // A page on another site can post here and sign the visitor in to the + // attacker's account + if (origin === null || !config.origins.includes(origin)) { + return new Response(null, { status: 403 }); + } + + const url = new URL(request.url); + + const name = url.pathname.split("/").at(-1) ?? ""; + const handler = Object.hasOwn(routes, name) ? routes[name] : undefined; + + if (handler === undefined) return new Response(null, { status: 404 }); + + const result = await handler(await request.json()); + const headers = new Headers({ "content-type": "application/json" }); + + if (result.setSession !== null) { + const attributes = [ + `${config.cookie.name}=${result.setSession}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${config.cookie.maxAge}`, + ]; + + if (config.cookie.secure) attributes.push("Secure"); + + headers.append("set-cookie", attributes.join("; ")); + } + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers, + }); +} diff --git a/packages/spike/src/transports/route-names.ts b/packages/spike/src/transports/route-names.ts new file mode 100644 index 0000000..8c05d89 --- /dev/null +++ b/packages/spike/src/transports/route-names.ts @@ -0,0 +1,8 @@ +/** + * The route names the handlers answer and the client calls. Kept apart from + * both, so the browser bundle does not pull in server code. + */ +export const routeNames = { + otpSend: "otp-send", + otpVerify: "otp-verify", +} as const; diff --git a/packages/spike/src/webauthn/cbor.ts b/packages/spike/src/webauthn/cbor.ts new file mode 100644 index 0000000..04e9fe5 --- /dev/null +++ b/packages/spike/src/webauthn/cbor.ts @@ -0,0 +1,102 @@ +/** + * Minimal CBOR decoder for WebAuthn + * + * Only decodes the subset used by WebAuthn: + * - Major type 0: unsigned integer + * - Major type 1: negative integer + * - Major type 2: byte string + * - Major type 3: text string + * - Major type 4: array + * - Major type 5: map + * + * All reads are bounded by the input. + */ + +export type CborValue = + number | Uint8Array | string | CborValue[] | Map; + +export function decodeCbor(data: Uint8Array): CborValue { + let offset = 0; + + function read(n: number): Uint8Array { + if (offset + n > data.length) { + throw new Error("CBOR: input exhausted"); + } + const slice = data.subarray(offset, offset + n); + offset += n; + return slice; + } + + function readUint8(): number { + const byte = data[offset]; + if (byte === undefined) { + throw new Error("CBOR: input exhausted"); + } + offset += 1; + return byte; + } + + function readLength(additionalInfo: number): number { + if (additionalInfo < 24) return additionalInfo; + if (additionalInfo === 24) return readUint8(); + if (additionalInfo === 25) { + const bytes = read(2); + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(0); + } + if (additionalInfo === 26) { + const bytes = read(4); + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(0); + } + throw new Error("CBOR: unsupported length encoding"); + } + + function decode(): CborValue { + const initial = readUint8(); + const majorType = initial >> 5; + const additionalInfo = initial & 0x1f; + + switch (majorType) { + case 0: // unsigned integer + return readLength(additionalInfo); + case 1: // negative integer + return -1 - readLength(additionalInfo); + case 2: // byte string + return new Uint8Array(read(readLength(additionalInfo))); + case 3: { + // text string + const bytes = read(readLength(additionalInfo)); + return new TextDecoder().decode(bytes); + } + case 4: { + // array + const length = readLength(additionalInfo); + const arr: CborValue[] = []; + for (let i = 0; i < length; i++) arr.push(decode()); + return arr; + } + case 5: { + // map + const length = readLength(additionalInfo); + const map = new Map(); + for (let i = 0; i < length; i++) { + const key = decode(); + const value = decode(); + map.set(key, value); + } + return map; + } + default: + throw new Error(`CBOR: unsupported major type ${majorType.toString()}`); + } + } + + return decode(); +} diff --git a/packages/spike/src/webauthn/contracts.ts b/packages/spike/src/webauthn/contracts.ts new file mode 100644 index 0000000..e8837dc --- /dev/null +++ b/packages/spike/src/webauthn/contracts.ts @@ -0,0 +1,23 @@ +/** + * Passkey ceremony inputs, picked from the standard WebAuthn JSON types: + * exactly the fields verification consumes. Everything outside the signed bytes + * is unauthenticated and not accepted. The output of + * PublicKeyCredential.toJSON() satisfies these shapes. + */ +export type PasskeyRegistrationCredential = { + response: Pick< + AuthenticatorAttestationResponseJSON, + "clientDataJSON" | "attestationObject" + >; +}; + +/** See PasskeyRegistrationCredential. id locates the stored credential; the signature check binds it. */ +export type PasskeyAuthenticationCredential = Pick< + AuthenticationResponseJSON, + "id" +> & { + response: Pick< + AuthenticatorAssertionResponseJSON, + "clientDataJSON" | "authenticatorData" | "signature" + >; +}; diff --git a/packages/spike/src/webauthn/crypto.ts b/packages/spike/src/webauthn/crypto.ts new file mode 100644 index 0000000..5296ce4 --- /dev/null +++ b/packages/spike/src/webauthn/crypto.ts @@ -0,0 +1,134 @@ +import { invariant } from "./lib"; + +/** + * Crypto primitives using Web Crypto API + * + * All functions work in browsers, Node.js 18+, Bun, and Deno. + */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Encode bytes or a UTF-8 string as unpadded base64url */ +export function base64urlEncode(data: Uint8Array | string): string { + const bytes = typeof data === "string" ? encoder.encode(data) : data; + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** Decode base64url string to bytes */ +export function base64urlDecode(str: string): Uint8Array | null { + try { + const padded = str.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } catch { + return null; + } +} + +/** Convert base64url string to ArrayBuffer */ +export function base64urlToBuffer(base64url: string): ArrayBuffer { + const bytes = base64urlDecode(base64url); + + invariant( + bytes, + "WebAuthn options from the server are well-formed base64url", + ); + + // Create a fresh ArrayBuffer (not SharedArrayBuffer) for WebAuthn API compatibility + return new Uint8Array(bytes).buffer; +} + +/** Convert ArrayBuffer to base64url string */ +export function bufferToBase64url(buffer: ArrayBuffer): string { + return base64urlEncode(new Uint8Array(buffer)); +} + +/** Compute SHA-256 hash of data */ +export async function sha256(data: Uint8Array): Promise { + // Create a fresh ArrayBuffer to satisfy TypeScript's BufferSource type + const buffer = new Uint8Array(data).buffer; + const hash = await crypto.subtle.digest("SHA-256", buffer); + return new Uint8Array(hash); +} + +/** Import a secret string as an HMAC-SHA256 key */ +async function importHmacKey( + secret: string, + usages: KeyUsage[], +): Promise { + try { + return await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + usages, + ); + } catch { + return null; + } +} + +/** Sign a payload string with HMAC-SHA256, return base64url signature */ +export async function hmacSign( + payload: string, + secret: string, +): Promise { + const key = await importHmacKey(secret, ["sign"]); + if (!key) return null; + + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(payload), + ); + + return base64urlEncode(new Uint8Array(signature)); +} + +/** Verify an HMAC-SHA256 signature (constant-time comparison) */ +export async function hmacVerify( + payload: string, + signature: string, + secret: string, +): Promise { + const sigBytes = base64urlDecode(signature); + if (!sigBytes) return false; + + const key = await importHmacKey(secret, ["verify"]); + if (!key) return false; + + // Create a new ArrayBuffer to satisfy TypeScript's BufferSource type + const sigBuffer = new Uint8Array(sigBytes).buffer; + return crypto.subtle.verify("HMAC", key, sigBuffer, encoder.encode(payload)); +} + +/** Encode a JSON payload to base64url */ +// TODO: Rename to jsonToBase64Url +export function encodePayload(payload: object): string { + return base64urlEncode(JSON.stringify(payload)); +} + +/** Decode a base64url string to JSON payload */ +// TODO: Rename to base64UrlToJson +export function decodePayload(encoded: string): unknown { + const bytes = base64urlDecode(encoded); + if (!bytes) return null; + + try { + const parsed: unknown = JSON.parse(decoder.decode(bytes)); + return parsed; + } catch { + return null; + } +} + +/** Unguessable base64url token from byteCount random bytes */ +export function randomBase64url(byteCount: number): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(byteCount))); +} diff --git a/packages/spike/src/webauthn/lib.ts b/packages/spike/src/webauthn/lib.ts new file mode 100644 index 0000000..922a767 --- /dev/null +++ b/packages/spike/src/webauthn/lib.ts @@ -0,0 +1,24 @@ +/** + * Invariant assertion + * + * A _type assertion_ tells the compiler to believe a condition with no check. + * An _invariant assertion_ checks the condition at runtime and throws if it + * fails. Both narrow the type. + * + * This project bans type assertions and uses invariant assertions instead. + * + * A failed invariant is a bug, not bad input. + */ +export function invariant( + /** The condition that must hold. */ + condition: unknown, + /** Why the condition cannot fail. */ + message: string, +): asserts condition { + if (!condition) throw new Error(`Invariant violation: ${message}`); +} + +/** True for any non-null object. Narrows it so keys can be read. */ +export function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} diff --git a/packages/spike/src/webauthn/webauthn.ts b/packages/spike/src/webauthn/webauthn.ts new file mode 100644 index 0000000..4d7d7fe --- /dev/null +++ b/packages/spike/src/webauthn/webauthn.ts @@ -0,0 +1,451 @@ +/** + * WebAuthn verification utilities + * + * Implements credential verification using Web Crypto API. + * No external dependencies. + */ + +import { base64urlDecode, base64urlEncode, sha256 } from "./crypto"; +import { decodeCbor, type CborValue } from "./cbor"; +import type { + PasskeyRegistrationCredential, + PasskeyAuthenticationCredential, +} from "./contracts"; + +const encoder = new TextEncoder(); + +type ClientData = { + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type */ + type: string; // "webauthn.create" or "webauthn.get" + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge */ + challenge: string; + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-origin */ + origin: string; + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-crossorigin */ + crossOrigin: boolean; +}; + +/** Decodes and validates clientDataJSON. Malformed input returns null. */ +export function parseClientData(clientDataJSON: string): ClientData | null { + const bytes = base64urlDecode(clientDataJSON); + if (bytes === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + const type = "type" in parsed ? parsed.type : null; + const challenge = "challenge" in parsed ? parsed.challenge : null; + const origin = "origin" in parsed ? parsed.origin : null; + const crossOrigin = "crossOrigin" in parsed ? parsed.crossOrigin : false; + + if ( + typeof type !== "string" || + typeof challenge !== "string" || + typeof origin !== "string" + ) { + return null; + } + + return { type, challenge, origin, crossOrigin: crossOrigin === true }; +} + +type ParsedAuthData = { + rpIdHash: Uint8Array; + flags: number; + signCount: number; + userPresent: boolean; + userVerified: boolean; + credentialId?: Uint8Array | undefined; + coseKey?: Map | undefined; +}; + +/** Compare two Uint8Arrays for equality */ +function arrayEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** Concatenate two Uint8Arrays */ +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +/** + * The origin must match one allowed origin exactly (scheme + host + port), + * and cross-origin ceremonies are rejected. + */ +function verifyOrigin(clientData: ClientData, allowedOrigins: string[]): void { + if (clientData.crossOrigin) { + throw new Error("Cross-origin ceremony rejected"); + } + if (!allowedOrigins.includes(clientData.origin)) { + throw new Error(`Origin not allowed: ${clientData.origin}`); + } +} + +/** + * Convert DER-encoded ECDSA signature to raw format + * + * WebAuthn returns signatures in DER format: + * 0x30 0x02 0x02 + * + * Web Crypto expects raw format: r || s (32 bytes each for P-256) + */ +function derToRaw(der: Uint8Array): Uint8Array { + function byteAt(i: number): number { + const byte = der[i]; + if (byte === undefined) { + throw new Error("DER signature truncated"); + } + return byte; + } + + // Parse DER sequence + if (byteAt(0) !== 0x30) { + throw new Error("Invalid DER signature: expected sequence"); + } + + let offset = 2; // skip 0x30 and length byte + + // Parse r integer + if (byteAt(offset) !== 0x02) { + throw new Error("Invalid DER signature: expected integer tag for r"); + } + const rLen = byteAt(offset + 1); + if (offset + 2 + rLen > der.length) { + throw new Error("DER signature truncated"); + } + let r = der.subarray(offset + 2, offset + 2 + rLen); + offset += 2 + rLen; + + // Parse s integer + if (byteAt(offset) !== 0x02) { + throw new Error("Invalid DER signature: expected integer tag for s"); + } + const sLen = byteAt(offset + 1); + if (offset + 2 + sLen > der.length) { + throw new Error("DER signature truncated"); + } + let s = der.subarray(offset + 2, offset + 2 + sLen); + + // DER integers may have leading zero for positive numbers + // Strip leading zeros but keep 32 bytes + if (r.length > 32) r = r.subarray(r.length - 32); + if (s.length > 32) s = s.subarray(s.length - 32); + + // Pad to 32 bytes each + const raw = new Uint8Array(64); + raw.set(r, 32 - r.length); + raw.set(s, 64 - s.length); + + return raw; +} + +/** + * Parse authenticator data + * + * Format: + * rpIdHash (32 bytes) + * flags (1 byte) + * signCount (4 bytes, big-endian) + * [attestedCredentialData] (if AT flag set) + * [extensions] (if ED flag set) + * + * Attested credential data format: + * aaguid (16 bytes) + * credentialIdLength (2 bytes, big-endian) + * credentialId (credentialIdLength bytes) + * credentialPublicKey (COSE, remaining bytes) + */ +function parseAuthData(authData: Uint8Array): ParsedAuthData { + if (authData.length < 37) { + throw new Error("Authenticator data too short"); + } + + const rpIdHash = authData.subarray(0, 32); + const view = new DataView( + authData.buffer, + authData.byteOffset, + authData.byteLength, + ); + const flags = view.getUint8(32); + const signCount = view.getUint32(33, false); + + const userPresent = !!(flags & 0x01); + const userVerified = !!(flags & 0x04); + const attestedCredentialData = !!(flags & 0x40); + + let credentialId: Uint8Array | undefined; + let coseKey: Map | undefined; + + if (attestedCredentialData) { + if (authData.length < 55) { + throw new Error("Attested credential data too short"); + } + // Skip aaguid (16 bytes), read credentialIdLength + const credIdLen = new DataView( + authData.buffer, + authData.byteOffset + 53, + 2, + ).getUint16(0, false); + if (authData.length < 55 + credIdLen) { + throw new Error("Credential id out of bounds"); + } + + credentialId = authData.subarray(55, 55 + credIdLen); + const publicKeyBytes = authData.subarray(55 + credIdLen); + const decoded = decodeCbor(publicKeyBytes); + if (!(decoded instanceof Map)) { + throw new Error("COSE key is not a map"); + } + coseKey = decoded; + } + + return { + rpIdHash, + flags, + signCount, + userPresent, + userVerified, + credentialId, + coseKey, + }; +} + +/** + * Serialize COSE key to Uint8Array for storage + * + * We store just the raw x,y coordinates (64 bytes) with a type prefix + * Format: 0x04 || x (32 bytes) || y (32 bytes) + */ +function serializeCoseKey(coseKey: Map): Uint8Array { + const kty = coseKey.get(1); + const alg = coseKey.get(3); + + if (kty !== 2 || alg !== -7) { + throw new Error("Only ES256 (P-256) keys supported"); + } + + const x = coseKey.get(-2); + const y = coseKey.get(-3); + if ( + !(x instanceof Uint8Array) || + !(y instanceof Uint8Array) || + x.length !== 32 || + y.length !== 32 + ) { + throw new Error("Invalid P-256 coordinates"); + } + + // Uncompressed point format: 0x04 || x || y + const result = new Uint8Array(65); + result[0] = 0x04; + result.set(x, 1); + result.set(y, 33); + return result; +} + +/** + * Import stored public key as CryptoKey + * + * Expects format: 0x04 || x (32 bytes) || y (32 bytes) + */ +async function importStoredKey(publicKey: Uint8Array): Promise { + if (publicKey.length !== 65 || publicKey[0] !== 0x04) { + throw new Error("Invalid stored public key format"); + } + + const x = publicKey.subarray(1, 33); + const y = publicKey.subarray(33, 65); + + return crypto.subtle.importKey( + "jwk", + { + kty: "EC", + crv: "P-256", + x: base64urlEncode(x), + y: base64urlEncode(y), + }, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); +} + +type WebAuthnPolicy = { + rpId: string; + allowedOrigins: string[]; +}; + +export type VerifyRegistrationResult = { + credentialId: string; + publicKey: Uint8Array; + counter: number; +}; + +/** + * Verify a WebAuthn registration credential + */ +export async function verifyRegistrationCredential( + credential: PasskeyRegistrationCredential, + expectedChallenge: string, + policy: WebAuthnPolicy, +): Promise { + // 1. Decode and verify clientDataJSON + const clientData = parseClientData(credential.response.clientDataJSON); + if (!clientData) { + throw new Error("Invalid clientDataJSON"); + } + + // https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type + if (clientData.type !== "webauthn.create") { + throw new Error("Invalid clientData type: expected webauthn.create"); + } + + if (clientData.challenge !== expectedChallenge) { + throw new Error("Challenge mismatch"); + } + + verifyOrigin(clientData, policy.allowedOrigins); + + // 2. Decode attestationObject (CBOR) + const attestationBytes = base64urlDecode( + credential.response.attestationObject, + ); + if (!attestationBytes) { + throw new Error("Invalid attestationObject encoding"); + } + const attestationObject = decodeCbor(attestationBytes); + if (!(attestationObject instanceof Map)) { + throw new Error("attestationObject is not a map"); + } + + const authData = attestationObject.get("authData"); + if (!(authData instanceof Uint8Array)) { + throw new Error("Missing authData in attestationObject"); + } + + // 3. Parse authData + const parsed = parseAuthData(authData); + + // 4. Verify rpIdHash + const expectedRpIdHash = await sha256(encoder.encode(policy.rpId)); + if (!arrayEqual(parsed.rpIdHash, expectedRpIdHash)) { + throw new Error("RP ID hash mismatch"); + } + + // 5. Verify flags + if (!parsed.userPresent) { + throw new Error("User presence required"); + } + + // 6. Extract credential data + if (!parsed.credentialId || !parsed.coseKey) { + throw new Error("No credential data in authData"); + } + + // For "none" attestation (which we use), we skip attestation verification + // and trust the credential. This is acceptable for most use cases. + + return { + credentialId: base64urlEncode(parsed.credentialId), + publicKey: serializeCoseKey(parsed.coseKey), + counter: parsed.signCount, + }; +} + +export type VerifyAuthenticationResult = { + counter: number; +}; + +/** + * Verify a WebAuthn authentication credential + */ +export async function verifyAuthenticationCredential( + credential: PasskeyAuthenticationCredential, + storedCredential: { publicKey: Uint8Array; counter: number }, + expectedChallenge: string, + policy: WebAuthnPolicy, +): Promise { + // 1. Decode and verify clientDataJSON + const clientDataBytes = base64urlDecode(credential.response.clientDataJSON); + const clientData = parseClientData(credential.response.clientDataJSON); + if (!clientDataBytes || !clientData) { + throw new Error("Invalid clientDataJSON"); + } + + // https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type + if (clientData.type !== "webauthn.get") { + throw new Error("Invalid clientData type: expected webauthn.get"); + } + + if (clientData.challenge !== expectedChallenge) { + throw new Error("Challenge mismatch"); + } + + verifyOrigin(clientData, policy.allowedOrigins); + + // 2. Decode authenticatorData + const authData = base64urlDecode(credential.response.authenticatorData); + if (!authData) { + throw new Error("Invalid authenticatorData encoding"); + } + const parsed = parseAuthData(authData); + + // 3. Verify rpIdHash + const expectedRpIdHash = await sha256(encoder.encode(policy.rpId)); + if (!arrayEqual(parsed.rpIdHash, expectedRpIdHash)) { + throw new Error("RP ID hash mismatch"); + } + + // 4. Verify user presence + if (!parsed.userPresent) { + throw new Error("User presence required"); + } + + // 5. Verify counter (replay protection) + // Counter of 0 means the authenticator doesn't support counters + if ( + storedCredential.counter !== 0 && + parsed.signCount !== 0 && + parsed.signCount <= storedCredential.counter + ) { + throw new Error("Signature counter replay detected"); + } + + // 6. Verify signature + const clientDataHash = await sha256(clientDataBytes); + const signedData = concat(authData, clientDataHash); + const signature = base64urlDecode(credential.response.signature); + if (!signature) { + throw new Error("Invalid signature encoding"); + } + + const publicKey = await importStoredKey(storedCredential.publicKey); + const rawSignature = derToRaw(signature); + + // Create fresh ArrayBuffers to satisfy TypeScript's BufferSource type + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + new Uint8Array(rawSignature).buffer, + new Uint8Array(signedData).buffer, + ); + + if (!valid) { + throw new Error("Invalid signature"); + } + + return { counter: parsed.signCount }; +} diff --git a/packages/spike/tsconfig.json b/packages/spike/tsconfig.json new file mode 100644 index 0000000..89ca6a2 --- /dev/null +++ b/packages/spike/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + /* Environment */ + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + + /* Base */ + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true + } +}