diff --git a/build.config.ts b/build.config.ts index a3bf56c0..279d5978 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,5 +1,27 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ + entries: [ + 'src/nitro', + 'src/nitro/config', + { + builder: 'mkdist', + input: 'src/nitro/runtime/', + outDir: 'dist/nitro/runtime', + addRelativeDeclarationExtensions: true, + ext: 'js', + pattern: [ + '**', + '!**/*.stories.{js,cts,mts,ts,jsx,tsx}', + '!**/*.{spec,test}.{js,cts,mts,ts,jsx,tsx}', + ], + esbuild: { + jsxImportSource: 'vue', + jsx: 'automatic', + jsxFactory: 'h', + }, + }, + ], externals: ['consola', '@better-auth/cli', '@better-auth/cli/api', 'drizzle-orm/utils'], + failOnWarn: false, }) diff --git a/package.json b/package.json index 015431e2..7807c42b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,14 @@ "./config": { "types": "./dist/runtime/config.d.ts", "import": "./dist/runtime/config.js" + }, + "./nitro": { + "types": "./dist/nitro.d.mts", + "import": "./dist/nitro.mjs" + }, + "./nitro/config": { + "types": "./dist/nitro/config.d.mts", + "import": "./dist/nitro/config.mjs" } }, "main": "./dist/module.mjs", @@ -29,6 +37,12 @@ ], "config": [ "./dist/runtime/config.d.ts" + ], + "nitro": [ + "./dist/nitro.d.mts" + ], + "nitro/config": [ + "./dist/nitro/config.d.mts" ] } }, @@ -53,11 +67,15 @@ }, "peerDependencies": { "@nuxthub/core": ">=0.10.5", - "better-auth": ">=1.0.0" + "better-auth": ">=1.0.0", + "nitro": "^3.0.0-0" }, "peerDependenciesMeta": { "@nuxthub/core": { "optional": true + }, + "nitro": { + "optional": true } }, "dependencies": { @@ -91,6 +109,7 @@ "drizzle-orm": "^0.45.1", "eslint": "^10.0.3", "npm-agentskills": "https://pkg.pr.new/onmax/npm-agentskills@394499e", + "nitro": "3.0.260311-beta", "nuxt": "^4.3.1", "tinyexec": "^1.0.2", "typescript": "~5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cd2a3e3..3253f326 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,7 +61,7 @@ importers: version: 4.3.1 '@nuxt/test-utils': specifier: ^4.0.0 - version: 4.0.0(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.0.0(crossws@0.4.4(srvx@0.11.12))(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) '@nuxthub/core': specifier: ^0.10.6 version: 0.10.7(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(magicast@0.5.2)(synckit@0.11.12)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3)) @@ -95,6 +95,9 @@ importers: eslint: specifier: ^10.0.3 version: 10.0.3(jiti@2.6.1) + nitro: + specifier: 3.0.260311-beta + version: 3.0.260311-beta(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0))(giget@3.1.2)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.6)(miniflare@4.20260310.0)(mongodb@7.1.0)(rollup@4.59.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) npm-agentskills: specifier: https://pkg.pr.new/onmax/npm-agentskills@394499e version: https://pkg.pr.new/onmax/npm-agentskills@394499e(@nuxt/kit@4.3.1(magicast@0.5.2))(nuxt@4.3.1(7abe57d85d4f40b2ae678c6f5296d586)) @@ -158,7 +161,7 @@ importers: version: 14.2.1(magicast@0.5.2)(nuxt@4.3.1(7abe57d85d4f40b2ae678c6f5296d586))(vue@3.5.29(typescript@5.9.3)) docus: specifier: ^5.8.1 - version: 5.8.1(3d72889f1a95b3366e51eb0df9c735ed) + version: 5.8.1(2fb836f0343e06e6e9e39fdb30ae9342) playground: dependencies: @@ -4721,6 +4724,14 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crossws@0.4.4: + resolution: {integrity: sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==} + peerDependencies: + srvx: '>=0.7.1' + peerDependenciesMeta: + srvx: + optional: true + css-background-parser@0.1.0: resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} @@ -5247,6 +5258,15 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-runner@0.1.6: + resolution: {integrity: sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA==} + hasBin: true + peerDependencies: + miniflare: ^4.0.0 + peerDependenciesMeta: + miniflare: + optional: true + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -5892,6 +5912,16 @@ packages: crossws: optional: true + h3@2.0.1-rc.18: + resolution: {integrity: sha512-2EdYEOIJwZHfhfdxvqZsmmUz4tgwzQSuzre+l50j+voHJV4m7j3zw2lYLgHoyfkCF9EAZcaH4ea0zH/hgcs9Yg==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -6002,6 +6032,9 @@ packages: httpxy@0.1.7: resolution: {integrity: sha512-pXNx8gnANKAndgga5ahefxc++tJvNL87CXoRwxn1cJE2ZkWEojF3tNfQIEhZX/vfpt+wzeAzpUI4qkediX1MLQ==} + httpxy@0.3.1: + resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -6861,9 +6894,40 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nf3@0.3.13: + resolution: {integrity: sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw==} + nitro-cloudflare-dev@0.2.2: resolution: {integrity: sha512-aZfNTVdgXPQeAmXW0Tw8hm3usAHr4qVG4Bg3WhHBGeZYuXr9OyT04Ztb+STkMzhyaXvfMHViAaPUPg06iAYqag==} + nitro@3.0.260311-beta: + resolution: {integrity: sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + dotenv: '*' + giget: '*' + jiti: ^2.6.1 + rollup: ^4.59.0 + vite: ^7 || ^8 || >=8.0.0-0 + xml2js: ^0.6.2 + zephyr-agent: ^0.1.15 + peerDependenciesMeta: + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + nitropack@2.13.1: resolution: {integrity: sha512-2dDj89C4wC2uzG7guF3CnyG+zwkZosPEp7FFBGHB3AJo11AywOolWhyQJFHDzve8COvGxJaqscye9wW2IrUsNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7011,6 +7075,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ocache@0.1.4: + resolution: {integrity: sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -7829,6 +7896,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -8070,6 +8140,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.11.12: + resolution: {integrity: sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA==} + engines: {node: '>=20.16.0'} + hasBin: true + srvx@0.11.8: resolution: {integrity: sha512-2n9t0YnAXPJjinytvxccNgs7rOA5gmE7Wowt/8Dy2dx2fDC6sBhfBpbrCvjYKALlVukPS/Uq3QwkolKNa7P/2Q==} engines: {node: '>=20.16.0'} @@ -8610,6 +8685,80 @@ packages: uploadthing: optional: true + unstorage@2.0.0-alpha.7: + resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==} + peerDependencies: + '@azure/app-configuration': ^1.11.0 + '@azure/cosmos': ^4.9.1 + '@azure/data-tables': ^13.3.2 + '@azure/identity': ^4.13.0 + '@azure/keyvault-secrets': ^4.10.0 + '@azure/storage-blob': ^12.31.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.13.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.36.2 + '@vercel/blob': '>=0.27.3' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1.0.1 + aws4fetch: ^1.0.20 + chokidar: ^4 || ^5 + db0: '>=0.3.4' + idb-keyval: ^6.2.2 + ioredis: ^5.9.3 + lru-cache: ^11.2.6 + mongodb: ^6 || ^7 + ofetch: '*' + uploadthing: ^7.7.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + chokidar: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + lru-cache: + optional: true + mongodb: + optional: true + ofetch: + optional: true + uploadthing: + optional: true + untun@0.1.3: resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} hasBin: true @@ -10954,7 +11103,7 @@ snapshots: errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 - h3: 1.15.5 + h3: 1.15.6 impound: 1.0.0 klona: 2.0.6 mocked-exports: 0.1.1 @@ -11020,7 +11169,7 @@ snapshots: errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 - h3: 1.15.5 + h3: 1.15.6 impound: 1.0.0 klona: 2.0.6 mocked-exports: 0.1.1 @@ -11098,7 +11247,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/test-utils@4.0.0(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))': + '@nuxt/test-utils@4.0.0(crossws@0.4.4(srvx@0.11.12))(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@clack/prompts': 1.0.0 '@nuxt/devtools-kit': 2.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) @@ -11112,7 +11261,7 @@ snapshots: fake-indexeddb: 6.2.5 get-port-please: 3.2.0 h3: 1.15.5 - h3-next: h3@2.0.1-rc.11 + h3-next: h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.11.12)) local-pkg: 1.1.2 magic-string: 0.30.21 node-fetch-native: 1.6.7 @@ -11127,7 +11276,7 @@ snapshots: tinyexec: 1.0.2 ufo: 1.6.3 unplugin: 3.0.0 - vitest-environment-nuxt: 1.0.1(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) + vitest-environment-nuxt: 1.0.1(crossws@0.4.4(srvx@0.11.12))(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) vue: 3.5.29(typescript@5.9.3) optionalDependencies: playwright-core: 1.58.2 @@ -14171,6 +14320,10 @@ snapshots: dependencies: uncrypto: 0.1.3 + crossws@0.4.4(srvx@0.11.12): + optionalDependencies: + srvx: 0.11.12 + css-background-parser@0.1.0: {} css-box-shadow@1.0.0-3: {} @@ -14340,7 +14493,7 @@ snapshots: diff@8.0.3: {} - docus@5.8.1(3d72889f1a95b3366e51eb0df9c735ed): + docus@5.8.1(2fb836f0343e06e6e9e39fdb30ae9342): dependencies: '@ai-sdk/gateway': 3.0.66(zod@4.3.6) '@ai-sdk/mcp': 1.0.25(zod@4.3.6) @@ -14369,7 +14522,7 @@ snapshots: motion-v: 1.10.3(@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) nuxt: 4.3.1(7abe57d85d4f40b2ae678c6f5296d586) nuxt-llms: 0.2.0(magicast@0.5.2) - nuxt-og-image: 5.1.13(@unhead/vue@2.1.12(vue@3.5.29(typescript@5.9.3)))(magicast@0.5.2)(unstorage@1.17.4(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) + nuxt-og-image: 5.1.13(@unhead/vue@2.1.12(vue@3.5.29(typescript@5.9.3)))(magicast@0.5.2)(unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(mongodb@7.1.0)(ofetch@1.5.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) pkg-types: 2.3.0 scule: 1.3.0 shiki-stream: 0.1.4(vue@3.5.29(typescript@5.9.3)) @@ -14599,6 +14752,14 @@ snapshots: entities@7.0.1: {} + env-runner@0.1.6(miniflare@4.20260310.0): + dependencies: + crossws: 0.4.4(srvx@0.11.12) + httpxy: 0.3.1 + srvx: 0.11.12 + optionalDependencies: + miniflare: 4.20260310.0 + error-stack-parser-es@1.0.5: {} errx@0.1.0: {} @@ -15448,10 +15609,19 @@ snapshots: ufo: 1.6.3 uncrypto: 0.1.3 - h3@2.0.1-rc.11: + h3@2.0.1-rc.11(crossws@0.4.4(srvx@0.11.12)): dependencies: rou3: 0.7.12 srvx: 0.10.1 + optionalDependencies: + crossws: 0.4.4(srvx@0.11.12) + + h3@2.0.1-rc.18(crossws@0.4.4(srvx@0.11.12)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.12 + optionalDependencies: + crossws: 0.4.4(srvx@0.11.12) has-property-descriptors@1.0.2: dependencies: @@ -15644,6 +15814,8 @@ snapshots: httpxy@0.1.7: {} + httpxy@0.3.1: {} + human-signals@5.0.0: {} human-signals@8.0.1: {} @@ -16653,12 +16825,66 @@ snapshots: negotiator@1.0.0: {} + nf3@0.3.13: {} + nitro-cloudflare-dev@0.2.2: dependencies: consola: 3.4.2 mlly: 1.8.0 pkg-types: 2.3.0 + nitro@3.0.260311-beta(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0))(giget@3.1.2)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.6)(miniflare@4.20260310.0)(mongodb@7.1.0)(rollup@4.59.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)): + dependencies: + consola: 3.4.2 + crossws: 0.4.4(srvx@0.11.12) + db0: 0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)) + env-runner: 0.1.6(miniflare@4.20260310.0) + h3: 2.0.1-rc.18(crossws@0.4.4(srvx@0.11.12)) + hookable: 6.0.1 + nf3: 0.3.13 + ocache: 0.1.4 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.0.0-rc.9 + srvx: 0.11.12 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(mongodb@7.1.0)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 17.3.1 + giget: 3.1.2 + jiti: 2.6.1 + rollup: 4.59.0 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + nitropack@2.13.1(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0))(rolldown@1.0.0-beta.57): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -16690,7 +16916,7 @@ snapshots: exsolve: 1.0.8 globby: 16.1.1 gzip-size: 7.0.0 - h3: 1.15.5 + h3: 1.15.6 hookable: 5.5.3 httpxy: 0.1.7 ioredis: 5.10.0 @@ -16793,7 +17019,7 @@ snapshots: exsolve: 1.0.8 globby: 16.1.1 gzip-size: 7.0.0 - h3: 1.15.5 + h3: 1.15.6 hookable: 5.5.3 httpxy: 0.1.7 ioredis: 5.10.0 @@ -16951,7 +17177,7 @@ snapshots: transitivePeerDependencies: - magicast - nuxt-og-image@5.1.13(@unhead/vue@2.1.12(vue@3.5.29(typescript@5.9.3)))(magicast@0.5.2)(unstorage@1.17.4(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)): + nuxt-og-image@5.1.13(@unhead/vue@2.1.12(vue@3.5.29(typescript@5.9.3)))(magicast@0.5.2)(unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(mongodb@7.1.0)(ofetch@1.5.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)): dependencies: '@nuxt/devtools-kit': 3.2.3(magicast@0.5.2)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) '@nuxt/kit': 4.4.2(magicast@0.5.2) @@ -16982,7 +17208,7 @@ snapshots: strip-literal: 3.1.0 ufo: 1.6.3 unplugin: 2.3.11 - unstorage: 1.17.4(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(mongodb@7.1.0)(ofetch@1.5.1) unwasm: 0.5.3 yoga-wasm-web: 0.3.3 transitivePeerDependencies: @@ -17293,6 +17519,10 @@ snapshots: obug@2.1.1: {} + ocache@0.1.4: + dependencies: + ohash: 2.0.11 + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -18359,6 +18589,8 @@ snapshots: rou3@0.7.12: {} + rou3@0.8.1: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -18678,6 +18910,8 @@ snapshots: srvx@0.10.1: {} + srvx@0.11.12: {} + srvx@0.11.8: {} stackback@0.0.2: {} @@ -19256,6 +19490,24 @@ snapshots: db0: 0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)) ioredis: 5.10.0 + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(mongodb@7.1.0)(ofetch@1.5.1): + optionalDependencies: + chokidar: 5.0.0 + db0: 0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)) + ioredis: 5.10.0 + lru-cache: 11.2.6 + mongodb: 7.1.0 + ofetch: 1.5.1 + + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)))(ioredis@5.10.0)(lru-cache@11.2.6)(mongodb@7.1.0)(ofetch@2.0.0-alpha.3): + optionalDependencies: + chokidar: 5.0.0 + db0: 0.3.4(@libsql/client@0.17.0)(better-sqlite3@12.6.2)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260312.1)(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.19.0)(prisma@5.22.0)) + ioredis: 5.10.0 + lru-cache: 11.2.6 + mongodb: 7.1.0 + ofetch: 2.0.0-alpha.3 + untun@0.1.3: dependencies: citty: 0.1.6 @@ -19410,9 +19662,9 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 - vitest-environment-nuxt@1.0.1(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)): + vitest-environment-nuxt@1.0.1(crossws@0.4.4(srvx@0.11.12))(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)): dependencies: - '@nuxt/test-utils': 4.0.0(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) + '@nuxt/test-utils': 4.0.0(crossws@0.4.4(srvx@0.11.12))(magicast@0.5.2)(playwright-core@1.58.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' diff --git a/renovate.json b/renovate.json index 5db72dd6..c0e9794e 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "dependencyDashboard": false, "extends": [ "config:recommended" ] diff --git a/src/nitro.ts b/src/nitro.ts new file mode 100644 index 00000000..484a70c8 --- /dev/null +++ b/src/nitro.ts @@ -0,0 +1,7 @@ +import type {} from './nitro/augment' + +export { default } from './nitro/module' +export { serverAuth } from './nitro/runtime/server/utils/auth' +export { getRequestSession, getUserSession, requireUserSession } from './nitro/runtime/server/utils/session' +export type { BetterAuthNitroOptions } from './nitro/module-types' +export type { AppSession, AuthMeta, AuthMode, AuthRouteRules, AuthSession, AuthUser, RequireSessionOptions, ServerAuthContext, UserMatch } from './nitro/runtime/types' diff --git a/src/nitro/augment.ts b/src/nitro/augment.ts new file mode 100644 index 00000000..a8622189 --- /dev/null +++ b/src/nitro/augment.ts @@ -0,0 +1,18 @@ +import type { BetterAuthNitroOptions } from './module-types' +import type { AuthMeta } from './runtime/types' + +declare module 'nitro/types' { + interface NitroConfig { + betterAuth?: BetterAuthNitroOptions + } + + interface NitroRouteRules { + auth?: AuthMeta + } + + interface NitroRouteConfig { + auth?: AuthMeta + } +} + +export {} diff --git a/src/nitro/config-path.ts b/src/nitro/config-path.ts new file mode 100644 index 00000000..bd1bc72c --- /dev/null +++ b/src/nitro/config-path.ts @@ -0,0 +1,45 @@ +import { existsSync } from 'node:fs' +import { isAbsolute, relative, resolve } from 'pathe' + +const CONFIG_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs', '.cts', '.cjs'] as const + +function hasKnownConfigExtension(path: string): boolean { + return CONFIG_EXTENSIONS.some(extension => path.endsWith(extension)) +} + +export interface ResolvedNitroAuthConfigPath { + requestedPath: string + resolvedPath: string | null +} + +export function resolveNitroAuthConfigPath(rootDir: string, configPath: string): ResolvedNitroAuthConfigPath { + const requestedPath = isAbsolute(configPath) ? configPath : resolve(rootDir, configPath) + + if (hasKnownConfigExtension(requestedPath)) { + return { + requestedPath, + resolvedPath: existsSync(requestedPath) ? requestedPath : null, + } + } + + for (const extension of CONFIG_EXTENSIONS) { + const candidate = `${requestedPath}${extension}` + if (existsSync(candidate)) { + return { + requestedPath, + resolvedPath: candidate, + } + } + } + + return { + requestedPath, + resolvedPath: null, + } +} + +export function formatMissingNitroAuthConfigError(rootDir: string, configPath: string): string { + const expectedPath = hasKnownConfigExtension(configPath) ? configPath : `${configPath}.ts` + const displayPath = isAbsolute(expectedPath) ? relative(rootDir, expectedPath) : expectedPath + return `[nuxt-better-auth] Missing ${displayPath} - export default defineServerAuth(...) from @onmax/nuxt-better-auth/nitro/config` +} diff --git a/src/nitro/config.ts b/src/nitro/config.ts new file mode 100644 index 00000000..f846a511 --- /dev/null +++ b/src/nitro/config.ts @@ -0,0 +1,16 @@ +import type {} from './augment' +import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth' + +export interface ServerAuthContext { + runtimeConfig: Record +} + +export type ServerAuthConfig = Omit & { + plugins?: readonly BetterAuthPlugin[] +} + +export function defineServerAuth(config: (ctx: ServerAuthContext) => R & ServerAuthConfig): (ctx: ServerAuthContext) => R +export function defineServerAuth(config: R & ServerAuthConfig): (ctx: ServerAuthContext) => R +export function defineServerAuth(config: ServerAuthConfig | ((ctx: ServerAuthContext) => ServerAuthConfig)): (ctx: ServerAuthContext) => ServerAuthConfig { + return typeof config === 'function' ? config : () => config +} diff --git a/src/nitro/module-types.ts b/src/nitro/module-types.ts new file mode 100644 index 00000000..c9a9b5c9 --- /dev/null +++ b/src/nitro/module-types.ts @@ -0,0 +1,17 @@ +export interface BetterAuthNitroOptions { + /** + * Path to the Better Auth server config. Relative paths resolve from the Nitro root. + * Defaults to `server/auth.config`. + */ + config?: string +} + +export interface ResolvedBetterAuthNitroOptions { + config: string +} + +export function normalizeBetterAuthNitroOptions(options?: BetterAuthNitroOptions): ResolvedBetterAuthNitroOptions { + return { + config: options?.config || 'server/auth.config', + } +} diff --git a/src/nitro/module.ts b/src/nitro/module.ts new file mode 100644 index 00000000..6c4f5ed2 --- /dev/null +++ b/src/nitro/module.ts @@ -0,0 +1,111 @@ +import type {} from './augment' +import type { Nitro } from 'nitro/types' +import type { BetterAuthNitroOptions } from './module-types' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { formatMissingNitroAuthConfigError, resolveNitroAuthConfigPath } from './config-path' +import { normalizeBetterAuthNitroOptions } from './module-types' + +interface NitroHandlerLike { + route: string + handler: string + method?: string + middleware?: boolean +} + +interface NitroPluginTarget { + plugins?: string[] +} + +function resolveRuntimePath(path: string): string { + const candidates = ['.js', '.mjs', '.ts', '.mts'] + const prefixes = ['./nitro/runtime', './runtime'] + + for (const prefix of prefixes) { + for (const extension of candidates) { + const resolved = fileURLToPath(new URL(`${prefix}${path}${extension}`, import.meta.url)) + if (existsSync(resolved)) + return resolved + } + } + + throw new Error(`[nuxt-better-auth] Missing runtime file for ${path}`) +} + +function addNitroHandler(target: { handlers?: NitroHandlerLike[] }, handler: NitroHandlerLike): void { + target.handlers ||= [] + + const alreadyRegistered = target.handlers.some(existing => + existing.route === handler.route + && existing.handler === handler.handler + && existing.method === handler.method + && Boolean(existing.middleware) === Boolean(handler.middleware), + ) + + if (!alreadyRegistered) + target.handlers.push(handler) +} + +function addNitroPlugin(target: NitroPluginTarget, plugin: string): void { + target.plugins ||= [] + + if (!target.plugins.includes(plugin)) + target.plugins.push(plugin) +} + +function setupRuntimeConfig(nitro: Nitro): void { + const options = nitro.options as typeof nitro.options & { + runtimeConfig?: Record + } + + options.runtimeConfig ||= {} + options.runtimeConfig.public ||= {} + + if (!options.runtimeConfig.public.siteUrl && process.env.NUXT_PUBLIC_SITE_URL) + options.runtimeConfig.public.siteUrl = process.env.NUXT_PUBLIC_SITE_URL + + const currentSecret = typeof options.runtimeConfig.betterAuthSecret === 'string' + ? options.runtimeConfig.betterAuthSecret + : undefined + + options.runtimeConfig.betterAuthSecret = currentSecret || process.env.NUXT_BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET || '' + + const betterAuthSecret = options.runtimeConfig.betterAuthSecret as string + if (!options.dev && !betterAuthSecret) { + throw new Error('[nuxt-better-auth] NUXT_BETTER_AUTH_SECRET is required in production. Set NUXT_BETTER_AUTH_SECRET or BETTER_AUTH_SECRET environment variable.') + } + if (betterAuthSecret && betterAuthSecret.length < 32) { + throw new Error('[nuxt-better-auth] NUXT_BETTER_AUTH_SECRET must be at least 32 characters for security') + } +} + +export function installBetterAuthNitroModule(nitro: Nitro, rawOptions?: BetterAuthNitroOptions): void { + const options = nitro.options as typeof nitro.options & { + alias?: Record + betterAuth?: BetterAuthNitroOptions + handlers?: NitroHandlerLike[] + } + + const config = normalizeBetterAuthNitroOptions(rawOptions ?? options.betterAuth) + const { resolvedPath } = resolveNitroAuthConfigPath(nitro.options.rootDir, config.config) + if (!resolvedPath) + throw new Error(formatMissingNitroAuthConfigError(nitro.options.rootDir, config.config)) + + options.alias ||= {} + options.alias['#better-auth-nitro/server'] = resolvedPath + + setupRuntimeConfig(nitro) + addNitroPlugin(options, resolveRuntimePath('/plugin')) + + addNitroHandler(options, { + route: '/api/auth/**', + handler: resolveRuntimePath('/server/api/auth/[...all]'), + }) +} + +export default { + name: '@onmax/nuxt-better-auth/nitro', + setup(nitro: Nitro) { + installBetterAuthNitroModule(nitro) + }, +} diff --git a/src/nitro/runtime/plugin.ts b/src/nitro/runtime/plugin.ts new file mode 100644 index 00000000..300b4dec --- /dev/null +++ b/src/nitro/runtime/plugin.ts @@ -0,0 +1,8 @@ +import { definePlugin } from 'nitro' +import { enforceRouteAccess } from './server/internal/route-access' + +export default definePlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + await enforceRouteAccess(event) + }) +}) diff --git a/src/nitro/runtime/server/api/auth/[...all].ts b/src/nitro/runtime/server/api/auth/[...all].ts new file mode 100644 index 00000000..d691cca7 --- /dev/null +++ b/src/nitro/runtime/server/api/auth/[...all].ts @@ -0,0 +1,8 @@ +import type { H3Event } from 'nitro/h3' +import { defineEventHandler } from 'nitro/h3' +import { serverAuth } from '../../utils/auth' + +export default defineEventHandler(async (event: H3Event) => { + const auth = serverAuth(event) + return auth.handler(event.req) +}) diff --git a/src/nitro/runtime/server/internal/base-url.ts b/src/nitro/runtime/server/internal/base-url.ts new file mode 100644 index 00000000..92205254 --- /dev/null +++ b/src/nitro/runtime/server/internal/base-url.ts @@ -0,0 +1,237 @@ +import type { BetterAuthOptions } from 'better-auth' +import type { H3Event } from 'nitro/h3' +import { getRequestHost, getRequestProtocol } from 'nitro/h3' +import { withoutProtocol } from 'ufo' + +export interface RuntimeConfigLike { + public?: { + siteUrl?: unknown + } +} + +export interface BaseURLOptions { + env?: Partial> + isDev?: boolean + isPrerender?: boolean +} + +function resolveOptions(options: BaseURLOptions = {}): Required { + return { + env: options.env ?? process.env, + isDev: options.isDev ?? Boolean(import.meta.dev), + isPrerender: options.isPrerender ?? Boolean(import.meta.prerender), + } +} + +export function normalizeLoopbackOrigin(origin: string, isDev: boolean): string { + if (!isDev) + return origin + + try { + const url = new URL(origin) + if (url.hostname === '127.0.0.1' || url.hostname === '::1' || url.hostname === '[::1]') { + url.hostname = 'localhost' + return url.origin + } + } + catch { + // Invalid URL is handled by validateURL. + } + + return origin +} + +export function validateURL(url: string, isDev: boolean): string { + try { + return normalizeLoopbackOrigin(new URL(url).origin, isDev) + } + catch { + throw new Error(`Invalid siteUrl: "${url}". Must be a valid URL.`) + } +} + +export function resolveConfiguredSiteUrl(config: RuntimeConfigLike, options: BaseURLOptions = {}): string | undefined { + if (typeof config.public?.siteUrl !== 'string' || !config.public.siteUrl) + return undefined + + return validateURL(config.public.siteUrl, resolveOptions(options).isDev) +} + +export function resolveEventOrigin(event?: H3Event, options: BaseURLOptions = {}): string | undefined { + if (!event) + return undefined + + const host = getRequestHost(event, { xForwardedHost: true }) + const protocol = getRequestProtocol(event, { xForwardedProto: true }) + if (!host || !protocol) + return undefined + + try { + return validateURL(`${protocol}://${host}`, resolveOptions(options).isDev) + } + catch { + return undefined + } +} + +export function getNitroOrigin(options: BaseURLOptions = {}): string | undefined { + const resolved = resolveOptions(options) + const { env, isDev, isPrerender } = resolved + const cert = env.NITRO_SSL_CERT + const key = env.NITRO_SSL_KEY + let host: string | undefined = env.NITRO_HOST || env.HOST + let port: string | undefined + if (isDev) + port = env.NITRO_PORT || env.PORT || '3000' + let protocol = (cert && key) || !isDev ? 'https' : 'http' + + try { + if ((isDev || isPrerender) && env.__NUXT_DEV__) { + const origin = JSON.parse(env.__NUXT_DEV__).proxy.url + host = withoutProtocol(origin) + protocol = origin.includes('https') ? 'https' : 'http' + } + else if ((isDev || isPrerender) && env.NUXT_VITE_NODE_OPTIONS) { + const origin = JSON.parse(env.NUXT_VITE_NODE_OPTIONS).baseURL.replace('/__nuxt_vite_node__', '') + host = withoutProtocol(origin) + protocol = origin.includes('https') ? 'https' : 'http' + } + } + catch { + // JSON parse failed, continue with env fallbacks. + } + + if (!host) + return undefined + + if (host.startsWith('[') && host.includes(']:')) { + const lastBracketColon = host.lastIndexOf(']:') + const extractedPort = host.slice(lastBracketColon + 2) + host = host.slice(0, lastBracketColon + 1) + if (extractedPort) + port = extractedPort + } + else if (host.includes(':') && !host.startsWith('[')) { + const hostParts = host.split(':') + port = hostParts.pop() + host = hostParts.join(':') + } + + const portSuffix = port ? `:${port}` : '' + return `${protocol}://${host}${portSuffix}` +} + +export function resolveEnvironmentOrigin(options: BaseURLOptions = {}): { origin: string, source: string } | undefined { + const resolved = resolveOptions(options) + const nitroOrigin = getNitroOrigin(resolved) + if (nitroOrigin) + return { origin: validateURL(nitroOrigin, resolved.isDev), source: 'Nitro environment detection' } + + if (resolved.env.VERCEL_URL) + return { origin: validateURL(`https://${resolved.env.VERCEL_URL}`, resolved.isDev), source: 'VERCEL_URL' } + + if (resolved.env.CF_PAGES_URL) + return { origin: validateURL(`https://${resolved.env.CF_PAGES_URL}`, resolved.isDev), source: 'CF_PAGES_URL' } + + if (resolved.env.URL) { + const rawUrl = resolved.env.URL + return { origin: validateURL(rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`, resolved.isDev), source: 'URL' } + } + + return undefined +} + +export function resolveDevFallback(options: BaseURLOptions = {}): { origin: string, source: string } | undefined { + if (!resolveOptions(options).isDev) + return undefined + + return { origin: 'http://localhost:3000', source: 'development fallback' } +} + +export function getBaseURL(config: RuntimeConfigLike, event?: H3Event, options: BaseURLOptions = {}): string { + const configuredSiteUrl = resolveConfiguredSiteUrl(config, options) + if (configuredSiteUrl) + return configuredSiteUrl + + const eventOrigin = resolveEventOrigin(event, options) + if (eventOrigin) + return eventOrigin + + const environmentOrigin = resolveEnvironmentOrigin(options) + if (environmentOrigin) + return environmentOrigin.origin + + const devFallback = resolveDevFallback(options) + if (devFallback) + return devFallback.origin + + throw new Error('siteUrl required. Set NUXT_PUBLIC_SITE_URL.') +} + +export function dedupeOrigins(origins: readonly string[]): string[] { + return [...new Set(origins)] +} + +export function getDevTrustedOrigins(options: BaseURLOptions = {}): string[] { + const fallbackOrigin = 'http://localhost:3000' + const nitroOrigin = getNitroOrigin(options) + if (!nitroOrigin) + return [fallbackOrigin] + + try { + const url = new URL(nitroOrigin) + const protocol = url.protocol === 'https:' ? 'https' : 'http' + const port = url.port || '3000' + const localhostOrigin = `${protocol}://localhost:${port}` + return dedupeOrigins([localhostOrigin, url.origin]) + } + catch { + return [fallbackOrigin] + } +} + +export function getRequestOrigin(request?: Request): string | undefined { + if (!request) + return undefined + + try { + return new URL(request.url).origin + } + catch { + return undefined + } +} + +export function withDevTrustedOrigins( + trustedOrigins: BetterAuthOptions['trustedOrigins'] | undefined, + options: BaseURLOptions & { hasExplicitSiteUrl: boolean }, +): BetterAuthOptions['trustedOrigins'] | undefined { + const resolved = resolveOptions(options) + if (!resolved.isDev || !options.hasExplicitSiteUrl) + return trustedOrigins + + const devOrigins = getDevTrustedOrigins(resolved) + const mergeOrigins = (origins: readonly (string | null | undefined)[], request?: Request): string[] => { + const validOrigins = origins.filter((origin): origin is string => typeof origin === 'string') + const requestOrigin = getRequestOrigin(request) + return dedupeOrigins(requestOrigin ? [...validOrigins, ...devOrigins, requestOrigin] : [...validOrigins, ...devOrigins]) + } + + if (typeof trustedOrigins === 'function') { + return async (request?: Request) => { + const resolvedOrigins = await trustedOrigins(request) + return mergeOrigins(resolvedOrigins, request) + } + } + + if (Array.isArray(trustedOrigins)) { + const baseOrigins = mergeOrigins(trustedOrigins) + return async (request?: Request) => { + return mergeOrigins(baseOrigins, request) + } + } + + return async (request?: Request) => { + return mergeOrigins([], request) + } +} diff --git a/src/nitro/runtime/server/internal/route-access.ts b/src/nitro/runtime/server/internal/route-access.ts new file mode 100644 index 00000000..9d73f3ae --- /dev/null +++ b/src/nitro/runtime/server/internal/route-access.ts @@ -0,0 +1,31 @@ +import type { H3Event } from 'nitro/h3' +import type { AuthMeta, AuthMode } from '../../types' +import { getRouteRules } from 'nitro/app' +import { getRequestURL, HTTPError } from 'nitro/h3' +import { matchesUser } from '../../utils/match-user' +import { getUserSession, requireUserSession } from '../utils/session' + +export async function enforceRouteAccess(event: H3Event): Promise { + const path = getRequestURL(event).pathname + + if (path.startsWith('/api/auth/')) + return + + const resolved = getRouteRules(event.req.method || '', path) + const auth = resolved.routeRules?.auth?.options as AuthMeta | undefined + if (auth === undefined || auth === false) + return + + const mode: AuthMode = typeof auth === 'string' ? auth : auth.only ?? 'user' + + if (mode === 'guest') { + const session = await getUserSession(event) + if (session) + throw new HTTPError('Authenticated users not allowed', { status: 403 }) + return + } + + const session = await requireUserSession(event) + if (typeof auth === 'object' && auth.user && !matchesUser(session.user, auth.user)) + throw new HTTPError('Access denied', { status: 403 }) +} diff --git a/src/nitro/runtime/server/middleware/route-access.ts b/src/nitro/runtime/server/middleware/route-access.ts new file mode 100644 index 00000000..212d84e8 --- /dev/null +++ b/src/nitro/runtime/server/middleware/route-access.ts @@ -0,0 +1,4 @@ +import { defineEventHandler } from 'nitro/h3' +import { enforceRouteAccess } from '../internal/route-access' + +export default defineEventHandler(enforceRouteAccess) diff --git a/src/nitro/runtime/server/utils/auth.ts b/src/nitro/runtime/server/utils/auth.ts new file mode 100644 index 00000000..c75e5409 --- /dev/null +++ b/src/nitro/runtime/server/utils/auth.ts @@ -0,0 +1,55 @@ +import type { BetterAuthOptions } from 'better-auth' +import type { H3Event } from 'nitro/h3' +import { betterAuth } from 'better-auth' +import { useRuntimeConfig } from 'nitro/runtime-config' +import { getBaseURL, resolveConfiguredSiteUrl, withDevTrustedOrigins } from '../internal/base-url' + +type CreateServerAuth = (ctx: { runtimeConfig: Record }) => BetterAuthOptions +let createServerAuth: CreateServerAuth = () => { + throw new Error('[nuxt-better-auth] Missing Nitro server auth config. Ensure @onmax/nuxt-better-auth/nitro is registered in nitro.modules.') +} + +try { + const mod = await import('#better-auth-nitro/server') + if (typeof mod.default === 'function') + createServerAuth = mod.default as CreateServerAuth +} +catch { + // Allow package import outside a configured Nitro runtime. +} + +type AuthOptions = ReturnType +type AuthInstance = ReturnType> + +const authCache = new Map() + +function getBetterAuthSecret(runtimeConfig: Record): string { + if (typeof runtimeConfig.betterAuthSecret === 'string') + return runtimeConfig.betterAuthSecret + + return '' +} + +export function serverAuth(event?: H3Event): AuthInstance { + const runtimeConfig = useRuntimeConfig() as Record + const siteUrl = getBaseURL(runtimeConfig, event) + const hasExplicitSiteUrl = Boolean(resolveConfiguredSiteUrl(runtimeConfig)) + const cacheKey = hasExplicitSiteUrl ? '__explicit__' : siteUrl + + const cached = authCache.get(cacheKey) + if (cached) + return cached + + const userConfig = createServerAuth({ runtimeConfig }) as BetterAuthOptions + const auth = betterAuth({ + ...userConfig, + secret: getBetterAuthSecret(runtimeConfig), + baseURL: siteUrl, + trustedOrigins: withDevTrustedOrigins(userConfig.trustedOrigins, { + hasExplicitSiteUrl, + }), + }) + + authCache.set(cacheKey, auth) + return auth +} diff --git a/src/nitro/runtime/server/utils/session.ts b/src/nitro/runtime/server/utils/session.ts new file mode 100644 index 00000000..bd3eed9c --- /dev/null +++ b/src/nitro/runtime/server/utils/session.ts @@ -0,0 +1,84 @@ +import type { H3Event } from 'nitro/h3' +import type { AppSession, RequireSessionOptions } from '../../types' +import { HTTPError } from 'nitro/h3' +import { matchesUser } from '../../utils/match-user' +import { serverAuth } from './auth' + +const requestSessionLoadKey = Symbol.for('nuxt-better-auth.nitro.requestSessionLoad') + +interface RequestSessionContext { + requestSession?: AppSession | null + [requestSessionLoadKey]?: Promise +} + +const fallbackRequestSessionContext = new WeakMap() + +function getRequestSessionContext(event: H3Event): RequestSessionContext { + const eventWithContext = event as H3Event & { context?: unknown } + if (eventWithContext.context && typeof eventWithContext.context === 'object') + return eventWithContext.context as RequestSessionContext + + let context = fallbackRequestSessionContext.get(event as object) + if (!context) { + context = {} + fallbackRequestSessionContext.set(event as object, context) + } + return context +} + +function loadSession(event: H3Event): Promise { + const auth = serverAuth(event) + return auth.api.getSession({ headers: event.req.headers }) as Promise +} + +export async function getRequestSession(event: H3Event): Promise { + const context = getRequestSessionContext(event) + if (context.requestSession !== undefined) + return context.requestSession + + const inFlight = context[requestSessionLoadKey] + if (inFlight) + return inFlight + + const load = loadSession(event) + + context[requestSessionLoadKey] = load + try { + const session = await load + context.requestSession = session + return session + } + finally { + delete context[requestSessionLoadKey] + } +} + +export async function getUserSession(event: H3Event): Promise { + const context = getRequestSessionContext(event) + if (context.requestSession !== undefined) + return context.requestSession + + const inFlight = context[requestSessionLoadKey] + if (inFlight) + return inFlight + + return loadSession(event) +} + +export async function requireUserSession(event: H3Event, options?: RequireSessionOptions): Promise { + const session = await getRequestSession(event) + + if (!session) + throw new HTTPError('Authentication required', { status: 401 }) + + if (options?.user && !matchesUser(session.user, options.user)) + throw new HTTPError('Access denied', { status: 403 }) + + if (options?.rule) { + const allowed = await options.rule({ user: session.user, session: session.session }) + if (!allowed) + throw new HTTPError('Access denied', { status: 403 }) + } + + return session +} diff --git a/src/nitro/runtime/types.ts b/src/nitro/runtime/types.ts new file mode 100644 index 00000000..96d6c5db --- /dev/null +++ b/src/nitro/runtime/types.ts @@ -0,0 +1,48 @@ +import type { NitroRouteRules } from 'nitro/types' + +export interface AuthUser { + id: string + createdAt: Date + updatedAt: Date + email: string + emailVerified: boolean + name: string + image?: string | null +} + +export interface AuthSession { + id: string + createdAt: Date + updatedAt: Date + userId: string + expiresAt: Date + token: string + ipAddress?: string | null + userAgent?: string | null +} + +export interface ServerAuthContext { + runtimeConfig: Record +} + +export type UserMatch = { [K in keyof T]?: T[K] | T[K][] } + +export interface AppSession { + user: AuthUser + session: AuthSession +} + +export interface RequireSessionOptions { + user?: UserMatch + rule?: (ctx: { user: AuthUser, session: AuthSession }) => boolean | Promise +} + +export type AuthMode = 'guest' | 'user' + +export type AuthMeta = false | AuthMode | { + only?: AuthMode + redirectTo?: string + user?: UserMatch +} + +export type AuthRouteRules = NitroRouteRules & { auth?: AuthMeta } diff --git a/src/nitro/runtime/utils/match-user.ts b/src/nitro/runtime/utils/match-user.ts new file mode 100644 index 00000000..718ae776 --- /dev/null +++ b/src/nitro/runtime/utils/match-user.ts @@ -0,0 +1,16 @@ +import type { UserMatch } from '../types' + +export function matchesUser(user: T, match: UserMatch): boolean { + for (const [key, expected] of Object.entries(match)) { + const actual = (user as Record)[key] + if (Array.isArray(expected)) { + if (!expected.includes(actual as never)) + return false + } + else { + if (actual !== expected) + return false + } + } + return true +} diff --git a/test/exports/module.yaml b/test/exports/module.yaml index bab0f668..37edf3e2 100644 --- a/test/exports/module.yaml +++ b/test/exports/module.yaml @@ -5,3 +5,11 @@ ./config: defineClientAuth: function defineServerAuth: function +./nitro: + default: object + getRequestSession: function + getUserSession: function + requireUserSession: function + serverAuth: function +./nitro/config: + defineServerAuth: function diff --git a/test/fixtures/nitro-basic/nitro.config.ts b/test/fixtures/nitro-basic/nitro.config.ts new file mode 100644 index 00000000..5e37dd44 --- /dev/null +++ b/test/fixtures/nitro-basic/nitro.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'pathe' +import { defineNitroConfig } from 'nitro/config' + +export default defineNitroConfig({ + serverDir: 'server', + modules: [resolve(import.meta.dirname, '../../../dist/nitro.mjs')], + betterAuth: { + config: 'server/auth.config', + }, + routeRules: { + '/api/test/me': { + auth: 'user', + }, + '/api/test/guest': { + auth: { + only: 'guest', + }, + }, + }, +}) diff --git a/test/fixtures/nitro-basic/server/api/test/guest.get.ts b/test/fixtures/nitro-basic/server/api/test/guest.get.ts new file mode 100644 index 00000000..a12b407f --- /dev/null +++ b/test/fixtures/nitro-basic/server/api/test/guest.get.ts @@ -0,0 +1,7 @@ +import { defineEventHandler } from 'nitro/h3' + +export default defineEventHandler(() => { + return { + guest: true, + } +}) diff --git a/test/fixtures/nitro-basic/server/api/test/me.get.ts b/test/fixtures/nitro-basic/server/api/test/me.get.ts new file mode 100644 index 00000000..ed3599e9 --- /dev/null +++ b/test/fixtures/nitro-basic/server/api/test/me.get.ts @@ -0,0 +1,9 @@ +import { defineEventHandler } from 'nitro/h3' +import { requireUserSession } from '../../../../../../dist/nitro.mjs' + +export default defineEventHandler(async (event) => { + const session = await requireUserSession(event) + return { + userId: session.user.id, + } +}) diff --git a/test/fixtures/nitro-basic/server/auth.config.ts b/test/fixtures/nitro-basic/server/auth.config.ts new file mode 100644 index 00000000..ad5284ff --- /dev/null +++ b/test/fixtures/nitro-basic/server/auth.config.ts @@ -0,0 +1,7 @@ +import { defineServerAuth } from '../../../../dist/nitro/config.mjs' + +export default defineServerAuth({ + emailAndPassword: { + enabled: true, + }, +}) diff --git a/test/nitro-base-url.test.ts b/test/nitro-base-url.test.ts new file mode 100644 index 00000000..dcc518e7 --- /dev/null +++ b/test/nitro-base-url.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + getBaseURL, + getDevTrustedOrigins, + getNitroOrigin, + resolveConfiguredSiteUrl, + resolveEnvironmentOrigin, + withDevTrustedOrigins, +} from '../src/nitro/runtime/server/internal/base-url' + +describe('nitro base URL resolution', () => { + it('prefers an explicit siteUrl', () => { + expect(resolveConfiguredSiteUrl({ public: { siteUrl: 'https://explicit.example.com' } })).toBe('https://explicit.example.com') + expect(getBaseURL({ public: { siteUrl: 'https://explicit.example.com' } })).toBe('https://explicit.example.com') + }) + + it('falls back to Nitro host detection in development', () => { + expect(getNitroOrigin({ + env: { + NITRO_HOST: 'localhost', + NITRO_PORT: '3001', + }, + isDev: true, + })).toBe('http://localhost:3001') + }) + + it('falls back to deployment environment variables', () => { + expect(resolveEnvironmentOrigin({ + env: { + VERCEL_URL: 'my-app.vercel.app', + }, + isDev: false, + })).toEqual({ + origin: 'https://my-app.vercel.app', + source: 'VERCEL_URL', + }) + }) + + it('uses a localhost dev fallback when no other signal exists', () => { + expect(getBaseURL({ public: {} }, undefined, { + env: {}, + isDev: true, + })).toBe('http://localhost:3000') + }) + + it('adds localhost and request origins to trusted origins in dev', async () => { + const merged = withDevTrustedOrigins(['https://foo.workers.dev'], { + env: { + NITRO_HOST: '127.0.0.1', + NITRO_PORT: '4000', + }, + hasExplicitSiteUrl: true, + isDev: true, + }) + + expect(getDevTrustedOrigins({ + env: { + NITRO_HOST: '127.0.0.1', + NITRO_PORT: '4000', + }, + isDev: true, + })).toEqual(['http://localhost:4000', 'http://127.0.0.1:4000']) + + const resolved = await merged?.(new Request('http://127.0.0.1:4000/api/test')) + expect(resolved).toContain('https://foo.workers.dev') + expect(resolved).toContain('http://localhost:4000') + expect(resolved).toContain('http://127.0.0.1:4000') + }) +}) diff --git a/test/nitro-config.test.ts b/test/nitro-config.test.ts new file mode 100644 index 00000000..e83934ec --- /dev/null +++ b/test/nitro-config.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { defineServerAuth } from '../src/nitro/config' + +describe('defineServerAuth (nitro)', () => { + it('wraps object syntax in a factory', () => { + const factory = defineServerAuth({ + emailAndPassword: { + enabled: true, + }, + }) + + expect(factory({ runtimeConfig: {} })).toEqual({ + emailAndPassword: { + enabled: true, + }, + }) + }) + + it('passes runtimeConfig to function syntax', () => { + const factory = defineServerAuth(({ runtimeConfig }) => ({ + appName: String(runtimeConfig.appName), + })) + + expect(factory({ runtimeConfig: { appName: 'nitro-app' } })).toEqual({ + appName: 'nitro-app', + }) + }) +}) diff --git a/test/nitro-integration.test.ts b/test/nitro-integration.test.ts new file mode 100644 index 00000000..fc76fb5b --- /dev/null +++ b/test/nitro-integration.test.ts @@ -0,0 +1,105 @@ +import { spawn, spawnSync } from 'node:child_process' +import { createServer } from 'node:net' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const rootDir = fileURLToPath(new URL('..', import.meta.url)) +const fixtureDir = fileURLToPath(new URL('./fixtures/nitro-basic', import.meta.url)) +const env = { + ...process.env, + NUXT_BETTER_AUTH_SECRET: 'test-secret-for-testing-only-32chars', +} + +let previewProcess: ReturnType | undefined +let baseUrl = '' + +function findOpenPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') + return reject(new Error('Failed to resolve test port')) + + server.close((error) => { + if (error) + reject(error) + else + resolve(address.port) + }) + }) + }) +} + +async function waitForServer(url: string, output: () => string): Promise { + const timeoutAt = Date.now() + 20_000 + while (Date.now() < timeoutAt) { + try { + const response = await fetch(`${url}/api/auth/ok`) + if (response.ok) + return + } + catch { + // Server is still starting. + } + + await new Promise(resolve => setTimeout(resolve, 250)) + } + + throw new Error(`Nitro preview server did not start in time.\n${output()}`) +} + +beforeAll(async () => { + const buildPackage = spawnSync('pnpm', ['exec', 'nuxt-module-build', 'build'], { + cwd: rootDir, + env, + encoding: 'utf8', + }) + expect(buildPackage.status, `package build failed:\n${buildPackage.stdout}\n${buildPackage.stderr}`).toBe(0) + expect(existsSync(fileURLToPath(new URL('../dist/nitro.mjs', import.meta.url)))).toBe(true) + + const buildFixture = spawnSync('pnpm', ['exec', 'nitro', 'build', '--dir', fixtureDir], { + cwd: rootDir, + env, + encoding: 'utf8', + }) + expect(buildFixture.status, `fixture build failed:\n${buildFixture.stdout}\n${buildFixture.stderr}`).toBe(0) + + const port = await findOpenPort() + baseUrl = `http://127.0.0.1:${port}` + + let output = '' + previewProcess = spawn('pnpm', ['exec', 'nitro', 'preview', '--dir', fixtureDir, '--port', String(port), '--host', '127.0.0.1'], { + cwd: rootDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + previewProcess.stdout?.on('data', chunk => { output += String(chunk) }) + previewProcess.stderr?.on('data', chunk => { output += String(chunk) }) + + await waitForServer(baseUrl, () => output) +}, 120_000) + +afterAll(() => { + previewProcess?.kill('SIGTERM') +}) + +describe('nitro entry integration', () => { + it('registers the Better Auth handler', async () => { + const response = await fetch(`${baseUrl}/api/auth/ok`) + expect(response.status).toBe(200) + }) + + it('enforces route-rule auth on protected API routes', async () => { + const response = await fetch(`${baseUrl}/api/test/me`) + expect(response.status).toBe(401) + }) + + it('allows unauthenticated access to guest routes', async () => { + const response = await fetch(`${baseUrl}/api/test/guest`) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ guest: true }) + }) +}) diff --git a/test/nitro-module.test.ts b/test/nitro-module.test.ts new file mode 100644 index 00000000..3d7b497d --- /dev/null +++ b/test/nitro-module.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { afterEach, describe, expect, it } from 'vitest' +import { installBetterAuthNitroModule } from '../src/nitro/module' + +const tempDirs: string[] = [] + +function createFixtureRoot(): string { + const rootDir = mkdtempSync(join(tmpdir(), 'nuxt-better-auth-nitro-')) + tempDirs.push(rootDir) + return rootDir +} + +afterEach(async () => { + delete process.env.NUXT_BETTER_AUTH_SECRET + + while (tempDirs.length) { + const dir = tempDirs.pop() + if (dir) + await rm(dir, { recursive: true, force: true }) + } +}) + +describe('nitro module entry', () => { + it('registers the server config alias and handlers using the default path', () => { + const rootDir = createFixtureRoot() + mkdirSync(join(rootDir, 'server'), { recursive: true }) + writeFileSync(join(rootDir, 'server/auth.config.ts'), 'export default () => ({})\n') + + const nitro = { + options: { + rootDir, + dev: true, + runtimeConfig: {}, + }, + } as any + + installBetterAuthNitroModule(nitro) + + expect(nitro.options.alias['#better-auth-nitro/server']).toBe(join(rootDir, 'server/auth.config.ts')) + expect(nitro.options.plugins).toEqual(expect.arrayContaining([ + expect.stringContaining('/nitro/runtime/plugin'), + ])) + expect(nitro.options.handlers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + route: '/api/auth/**', + handler: expect.stringContaining('/nitro/runtime/server/api/auth/[...all]'), + }), + ])) + }) + + it('supports a custom config path and injects the runtime secret', () => { + process.env.NUXT_BETTER_AUTH_SECRET = 'test-secret-for-testing-only-32chars' + + const rootDir = createFixtureRoot() + mkdirSync(join(rootDir, 'config'), { recursive: true }) + writeFileSync(join(rootDir, 'config/custom-auth.ts'), 'export default () => ({})\n') + + const nitro = { + options: { + rootDir, + dev: false, + runtimeConfig: {}, + betterAuth: { + config: 'config/custom-auth', + }, + }, + } as any + + installBetterAuthNitroModule(nitro) + + expect(nitro.options.alias['#better-auth-nitro/server']).toBe(join(rootDir, 'config/custom-auth.ts')) + expect(nitro.options.runtimeConfig.betterAuthSecret).toBe('test-secret-for-testing-only-32chars') + }) + + it('throws when the config file cannot be resolved', () => { + const rootDir = createFixtureRoot() + const nitro = { + options: { + rootDir, + dev: true, + runtimeConfig: {}, + }, + } as any + + expect(() => installBetterAuthNitroModule(nitro)).toThrow('Missing server/auth.config.ts') + }) +}) diff --git a/test/nitro-route-access.test.ts b/test/nitro-route-access.test.ts new file mode 100644 index 00000000..d157c627 --- /dev/null +++ b/test/nitro-route-access.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getRouteRules = vi.fn() +const getUserSession = vi.fn() +const requireUserSession = vi.fn() + +vi.mock('nitro/app', () => ({ + getRouteRules, +})) + +vi.mock('nitro/h3', async () => { + const actual = await vi.importActual('nitro/h3') + return { + ...actual, + defineEventHandler: (handler: unknown) => handler, + getRequestURL: (event: { req: Request }) => new URL(event.req.url), + } +}) + +vi.mock('../src/nitro/runtime/server/utils/session', () => ({ + getUserSession, + requireUserSession, +})) + +async function loadRouteAccess() { + vi.resetModules() + return (await import('../src/nitro/runtime/server/middleware/route-access')).default +} + +describe('nitro route access middleware', () => { + beforeEach(() => { + getRouteRules.mockReset() + getUserSession.mockReset() + requireUserSession.mockReset() + }) + + it('ignores routes without auth route rules', async () => { + getRouteRules.mockReturnValue({ routeRules: {} }) + + const middleware = await loadRouteAccess() + await middleware({ req: new Request('https://example.com/api/public') }) + + expect(getUserSession).not.toHaveBeenCalled() + expect(requireUserSession).not.toHaveBeenCalled() + }) + + it('rejects authenticated users from guest routes', async () => { + getRouteRules.mockReturnValue({ + routeRules: { + auth: { + options: { only: 'guest' }, + }, + }, + }) + getUserSession.mockResolvedValue({ user: { id: 'user-1' } }) + + const middleware = await loadRouteAccess() + + await expect(middleware({ req: new Request('https://example.com/api/guest') })).rejects.toMatchObject({ + message: 'Authenticated users not allowed', + status: 403, + }) + }) + + it('requires a session for protected routes', async () => { + getRouteRules.mockReturnValue({ + routeRules: { + auth: { + options: 'user', + }, + }, + }) + requireUserSession.mockResolvedValue({ + user: { + id: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + email: 'user@example.com', + emailVerified: true, + name: 'User', + }, + session: { + id: 'session-1', + createdAt: new Date(), + updatedAt: new Date(), + userId: 'user-1', + expiresAt: new Date(), + token: 'token', + }, + }) + + const middleware = await loadRouteAccess() + await middleware({ req: new Request('https://example.com/api/protected') }) + + expect(requireUserSession).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/nitro-session-utils.test.ts b/test/nitro-session-utils.test.ts new file mode 100644 index 00000000..ef169888 --- /dev/null +++ b/test/nitro-session-utils.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getSession = vi.fn() + +vi.mock('../src/nitro/runtime/server/utils/auth', () => ({ + serverAuth: () => ({ + api: { + getSession, + }, + }), +})) + +async function loadSessionUtils() { + vi.resetModules() + return await import('../src/nitro/runtime/server/utils/session') +} + +describe('nitro session utils', () => { + beforeEach(() => { + getSession.mockReset() + }) + + it('caches request session lookups on the event', async () => { + getSession.mockResolvedValue({ + user: { + id: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + email: 'user@example.com', + emailVerified: true, + name: 'User', + }, + session: { + id: 'session-1', + createdAt: new Date(), + updatedAt: new Date(), + userId: 'user-1', + expiresAt: new Date(), + token: 'token', + }, + }) + + const { getRequestSession } = await loadSessionUtils() + const event = { req: new Request('https://example.com/api/test'), context: {} } as any + + const first = await getRequestSession(event) + const second = await getRequestSession(event) + + expect(first).toEqual(second) + expect(getSession).toHaveBeenCalledTimes(1) + }) + + it('throws a 401 HTTPError when there is no authenticated session', async () => { + getSession.mockResolvedValue(null) + + const { requireUserSession } = await loadSessionUtils() + const event = { req: new Request('https://example.com/api/test'), context: {} } as any + + await expect(requireUserSession(event)).rejects.toMatchObject({ + message: 'Authentication required', + status: 401, + }) + }) + + it('throws a 403 HTTPError when the user match fails', async () => { + getSession.mockResolvedValue({ + user: { + id: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + email: 'user@example.com', + emailVerified: true, + name: 'User', + }, + session: { + id: 'session-1', + createdAt: new Date(), + updatedAt: new Date(), + userId: 'user-1', + expiresAt: new Date(), + token: 'token', + }, + }) + + const { requireUserSession } = await loadSessionUtils() + const event = { req: new Request('https://example.com/api/test'), context: {} } as any + + await expect(requireUserSession(event, { + user: { email: 'admin@example.com' }, + })).rejects.toMatchObject({ + message: 'Access denied', + status: 403, + }) + }) +}) diff --git a/test/nitro-types.test.ts b/test/nitro-types.test.ts new file mode 100644 index 00000000..f48bc079 --- /dev/null +++ b/test/nitro-types.test.ts @@ -0,0 +1,78 @@ +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { describe, expect, it } from 'vitest' + +describe('nitro type surface', () => { + it('typechecks Nitro config augmentation and defineServerAuth', async () => { + const testDir = mkdtempSync(join(tmpdir(), 'nuxt-better-auth-nitro-types-')) + + try { + mkdirSync(join(testDir, 'nitro', 'runtime', 'server', 'internal'), { recursive: true }) + + copyFileSync(join(import.meta.dirname, '../src/nitro/config.ts'), join(testDir, 'nitro', 'config.ts')) + copyFileSync(join(import.meta.dirname, '../src/nitro/augment.ts'), join(testDir, 'nitro', 'augment.ts')) + copyFileSync(join(import.meta.dirname, '../src/nitro/module-types.ts'), join(testDir, 'nitro', 'module-types.ts')) + copyFileSync(join(import.meta.dirname, '../src/nitro/runtime/types.ts'), join(testDir, 'nitro', 'runtime', 'types.ts')) + symlinkSync(join(import.meta.dirname, '../node_modules'), join(testDir, 'node_modules'), 'dir') + + writeFileSync(join(testDir, 'check.ts'), `import type { NitroConfig } from 'nitro/types' +import { defineServerAuth } from './nitro/config' + +const authConfig = defineServerAuth(({ runtimeConfig }) => ({ + appName: String(runtimeConfig.appName ?? 'test'), +})) + +const nitroConfig: NitroConfig = { + betterAuth: { + config: 'server/auth.config', + }, + routeRules: { + '/api/protected': { + auth: 'user', + }, + '/api/guest': { + auth: { + only: 'guest', + }, + }, + }, +} + +void authConfig({ runtimeConfig: nitroConfig.runtimeConfig ?? {} }) +`) + + writeFileSync(join(testDir, 'tsconfig.json'), `{ + "compilerOptions": { + "target": "ESNext", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "files": [ + "./nitro/config.ts", + "./nitro/augment.ts", + "./nitro/module-types.ts", + "./nitro/runtime/types.ts", + "./check.ts" + ] +} +`) + + const typecheck = spawnSync('pnpm', ['exec', 'tsc', '--noEmit', '--pretty', 'false', '-p', join(testDir, 'tsconfig.json')], { + cwd: import.meta.dirname, + encoding: 'utf8', + }) + + expect(typecheck.status, `tsc failed:\n${typecheck.stdout}\n${typecheck.stderr}`).toBe(0) + } + finally { + await rm(testDir, { recursive: true, force: true }) + } + }, 30_000) +})