diff --git a/.changeset/eighty-scissors-leave.md b/.changeset/eighty-scissors-leave.md new file mode 100644 index 0000000000..65677b4d3d --- /dev/null +++ b/.changeset/eighty-scissors-leave.md @@ -0,0 +1,5 @@ +--- +"create-t3-app": minor +--- + +feat: new better auth setup with drizzle and prisma diff --git a/.github/scripts/generate-matrix.js b/.github/scripts/generate-matrix.js new file mode 100644 index 0000000000..77de5618cc --- /dev/null +++ b/.github/scripts/generate-matrix.js @@ -0,0 +1,54 @@ +// Define all possible values +const options = { + trpc: ['true', 'false'], + tailwind: ['true', 'false'], + nextAuth: ['true', 'false'], + betterAuth: ['true', 'false'], + prisma: ['true', 'false'], + drizzle: ['true', 'false'], + appRouter: ['true', 'false'], + dbType: ['planetscale', 'sqlite', 'mysql', 'postgres'] +}; + +// Generate all combinations +function generateCombinations(opts) { + const keys = Object.keys(opts); + const combinations = []; + + function recurse(index, current) { + if (index === keys.length) { + combinations.push({...current}); + return; + } + + const key = keys[index]; + for (const value of opts[key]) { + current[key] = value; + recurse(index + 1, current); + } + } + + recurse(0, {}); + return combinations; +} + +// Filter valid combinations based on current validation logic +function isValid(combo) { + const { prisma, drizzle, nextAuth, betterAuth, dbType } = combo; + + // Not both auth true + if (nextAuth === 'true' && betterAuth === 'true') return false; + + // Not both db true + if (prisma === 'true' && drizzle === 'true') return false; + + // If no db selected, only allow sqlite + if (prisma === 'false' && drizzle === 'false' && dbType !== 'sqlite') return false; + + return true; +} + +const allCombos = generateCombinations(options); +const validCombos = allCombos.filter(isValid); + +console.log(`matrix=${JSON.stringify({include: validCombos})}`); diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5c43580b12..32ff9e87ec 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -20,47 +20,42 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + generate-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.generate.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - name: Generate valid matrix combinations + id: generate + run: node .github/scripts/generate-matrix.js >> $GITHUB_OUTPUT + build-t3-app: + needs: generate-matrix runs-on: ubuntu-latest # if: | # contains(github.event.pull_request.labels.*.name, 'πŸ“Œ area: cli') || # contains(github.event.pull_request.labels.*.name, 'πŸ“Œ area: t3-app') strategy: - matrix: - trpc: ["true", "false"] - tailwind: ["true", "false"] - nextAuth: ["true", "false"] - prisma: ["true", "false"] - appRouter: ["true", "false"] - drizzle: ["true", "false"] - dbType: ["planetscale", "sqlite", "mysql", "postgres"] - - name: "Build and Start T3 App ${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }}" + matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} + + name: "Build and Start T3 App ${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.betterAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }}" steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check valid matrix - id: matrix-valid - run: | - echo "continue=${{ (matrix.prisma == 'false' || matrix.drizzle == 'false') && (matrix.drizzle == 'true' || matrix.prisma == 'true' || matrix.dbType == 'sqlite') }}" >> $GITHUB_OUTPUT - - uses: ./.github/actions/setup - if: ${{Β steps.matrix-valid.outputs.continue == 'true' }} - run: pnpm turbo --filter=create-t3-app build - if: ${{ steps.matrix-valid.outputs.continue == 'true' }} # has to be scaffolded outside the CLI project so that no lint/tsconfig are leaking # through. this way it ensures that it is the app's configs that are being used # FIXME: this is a bit hacky, would rather have --packages=trpc,tailwind,... but not sure how to setup the matrix for that - - run: cd cli && pnpm start ../../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} --noGit --CI --trpc=${{ matrix.trpc }} --tailwind=${{ matrix.tailwind }} --nextAuth=${{ matrix.nextAuth }} --prisma=${{ matrix.prisma }} --drizzle=${{ matrix.drizzle }} --appRouter=${{ matrix.appRouter }} --dbProvider=${{ matrix.dbType }} --eslint - if: ${{ steps.matrix-valid.outputs.continue == 'true' }} + - run: cd cli && pnpm start ../../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.betterAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} --noGit --CI --trpc=${{ matrix.trpc }} --tailwind=${{ matrix.tailwind }} --nextAuth=${{ matrix.nextAuth }} --betterAuth=${{ matrix.betterAuth }} --prisma=${{ matrix.prisma }} --drizzle=${{ matrix.drizzle }} --appRouter=${{ matrix.appRouter }} --dbProvider=${{ matrix.dbType }} # can't use default mysql string cause t3-env blocks that - - run: cd ../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} && pnpm build - if: ${{ steps.matrix-valid.outputs.continue == 'true' }} + - run: cd ../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.betterAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} && pnpm build env: AUTH_SECRET: foo AUTH_DISCORD_ID: bar @@ -87,7 +82,7 @@ jobs: echo "continue=${{ (matrix.eslint == 'false' || matrix.biome == 'false') && (matrix.biome == 'true' || matrix.eslint == 'true') }}" >> $GITHUB_OUTPUT - uses: ./.github/actions/setup - if: ${{Β steps.matrix-valid.outputs.continue == 'true' }} + if: ${{ steps.matrix-valid.outputs.continue == 'true' }} - run: pnpm turbo --filter=create-t3-app build if: ${{ steps.matrix-valid.outputs.continue == 'true' }} diff --git a/cli/package.json b/cli/package.json index cc849a3819..79a84335e3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -78,6 +78,7 @@ "@types/fs-extra": "^11.0.4", "@types/gradient-string": "^1.1.6", "@types/node": "^20.14.10", + "better-auth": "^1.3", "drizzle-kit": "^0.30.5", "drizzle-orm": "^0.41.0", "mysql2": "^3.11.0", diff --git a/cli/src/cli/index.ts b/cli/src/cli/index.ts index 013c4c36ea..e63c05f23a 100644 --- a/cli/src/cli/index.ts +++ b/cli/src/cli/index.ts @@ -34,6 +34,8 @@ interface CliFlags { /** @internal Used in CI. */ nextAuth: boolean; /** @internal Used in CI. */ + betterAuth: boolean; + /** @internal Used in CI. */ appRouter: boolean; /** @internal Used in CI. */ dbProvider: DatabaseProvider; @@ -63,6 +65,7 @@ const defaultOptions: CliResults = { prisma: false, drizzle: false, nextAuth: false, + betterAuth: false, importAlias: "~/", appRouter: false, dbProvider: "sqlite", @@ -115,6 +118,12 @@ export const runCli = async (): Promise => { "Experimental: Boolean value if we should install NextAuth.js. Must be used in conjunction with `--CI`.", (value) => !!value && value !== "false" ) + /** @experimental Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ + .option( + "--betterAuth [boolean]", + "Experimental: Boolean value if we should install BetterAuth. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) /** @experimental - Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ .option( "--prisma [boolean]", @@ -199,6 +208,7 @@ export const runCli = async (): Promise => { if (cliResults.flags.prisma) cliResults.packages.push("prisma"); if (cliResults.flags.drizzle) cliResults.packages.push("drizzle"); if (cliResults.flags.nextAuth) cliResults.packages.push("nextAuth"); + if (cliResults.flags.betterAuth) cliResults.packages.push("betterAuth"); if (cliResults.flags.eslint) cliResults.packages.push("eslint"); if (cliResults.flags.biome) cliResults.packages.push("biome"); if (cliResults.flags.prisma && cliResults.flags.drizzle) { @@ -212,6 +222,10 @@ export const runCli = async (): Promise => { logger.warn("Incompatible combination Biome + ESLint. Exiting."); process.exit(0); } + if (cliResults.flags.nextAuth && cliResults.flags.betterAuth) { + logger.warn("Incompatible combination NextAuth + BetterAuth. Exiting."); + process.exit(0); + } if (databaseProviders.includes(cliResults.flags.dbProvider) === false) { logger.warn( `Incompatible database provided. Use: ${databaseProviders.join(", ")}. Exiting.` @@ -286,6 +300,7 @@ export const runCli = async (): Promise => { options: [ { value: "none", label: "None" }, { value: "next-auth", label: "NextAuth.js" }, + { value: "better-auth", label: "BetterAuth" }, // Maybe later // { value: "clerk", label: "Clerk" }, ], @@ -372,6 +387,7 @@ export const runCli = async (): Promise => { if (project.styling) packages.push("tailwind"); if (project.trpc) packages.push("trpc"); if (project.authentication === "next-auth") packages.push("nextAuth"); + if (project.authentication === "better-auth") packages.push("betterAuth"); if (project.database === "prisma") packages.push("prisma"); if (project.database === "drizzle") packages.push("drizzle"); if (project.linter === "eslint") packages.push("eslint"); diff --git a/cli/src/helpers/selectBoilerplate.ts b/cli/src/helpers/selectBoilerplate.ts index 2b07079be6..a2a6ad3ae5 100644 --- a/cli/src/helpers/selectBoilerplate.ts +++ b/cli/src/helpers/selectBoilerplate.ts @@ -16,12 +16,17 @@ export const selectAppFile = ({ const usingTw = packages.tailwind.inUse; const usingTRPC = packages.trpc.inUse; - const usingNextAuth = packages.nextAuth.inUse; + const usingAuth = packages?.nextAuth.inUse ?? packages?.betterAuth.inUse; + const usingBetterAuth = packages?.betterAuth.inUse; let appFile = "base.tsx"; - if (usingTRPC && usingTw && usingNextAuth) { + if (usingTRPC && usingTw && usingBetterAuth) { + appFile = "with-better-auth-trpc-tw.tsx"; + } else if (usingTRPC && !usingTw && usingBetterAuth) { + appFile = "with-better-auth-trpc.tsx"; + } else if (usingTRPC && usingTw && usingAuth) { appFile = "with-auth-trpc-tw.tsx"; - } else if (usingTRPC && !usingTw && usingNextAuth) { + } else if (usingTRPC && !usingTw && usingAuth) { appFile = "with-auth-trpc.tsx"; } else if (usingTRPC && usingTw) { appFile = "with-trpc-tw.tsx"; @@ -29,9 +34,9 @@ export const selectAppFile = ({ appFile = "with-trpc.tsx"; } else if (!usingTRPC && usingTw) { appFile = "with-tw.tsx"; - } else if (usingNextAuth && usingTw) { + } else if (usingAuth && usingTw) { appFile = "with-auth-tw.tsx"; - } else if (usingNextAuth && !usingTw) { + } else if (usingAuth && !usingTw) { appFile = "with-auth.tsx"; } @@ -72,10 +77,20 @@ export const selectIndexFile = ({ const usingTRPC = packages.trpc.inUse; const usingTw = packages.tailwind.inUse; - const usingAuth = packages.nextAuth.inUse; + const usingBetterAuth = packages?.betterAuth.inUse; + const usingNextAuth = packages?.nextAuth.inUse; + const usingAuth = usingNextAuth || usingBetterAuth; let indexFile = "base.tsx"; - if (usingTRPC && usingTw && usingAuth) { + if (usingTRPC && usingTw && usingBetterAuth) { + indexFile = "with-better-auth-trpc-tw.tsx"; + } else if (usingTRPC && !usingTw && usingBetterAuth) { + indexFile = "with-better-auth-trpc.tsx"; + } else if (!usingTRPC && usingTw && usingBetterAuth) { + indexFile = "with-better-auth-tw.tsx"; + } else if (!usingTRPC && !usingTw && usingBetterAuth) { + indexFile = "with-better-auth.tsx"; + } else if (usingTRPC && usingTw && usingAuth) { indexFile = "with-auth-trpc-tw.tsx"; } else if (usingTRPC && !usingTw && usingAuth) { indexFile = "with-auth-trpc.tsx"; @@ -101,10 +116,19 @@ export const selectPageFile = ({ const usingTRPC = packages.trpc.inUse; const usingTw = packages.tailwind.inUse; - const usingAuth = packages.nextAuth.inUse; + const usingAuth = packages?.nextAuth.inUse; + const usingBetterAuth = packages?.betterAuth.inUse; let indexFile = "base.tsx"; - if (usingTRPC && usingTw && usingAuth) { + if (usingTRPC && usingTw && usingBetterAuth) { + indexFile = "with-better-auth-trpc-tw.tsx"; + } else if (usingTRPC && !usingTw && usingBetterAuth) { + indexFile = "with-better-auth-trpc.tsx"; + } else if (!usingTRPC && usingTw && usingBetterAuth) { + indexFile = "with-better-auth-tw.tsx"; + } else if (!usingTRPC && !usingTw && usingBetterAuth) { + indexFile = "with-better-auth.tsx"; + } else if (usingTRPC && usingTw && usingAuth) { indexFile = "with-auth-trpc-tw.tsx"; } else if (usingTRPC && !usingTw && usingAuth) { indexFile = "with-auth-trpc.tsx"; diff --git a/cli/src/index.ts b/cli/src/index.ts index 1cd6a85413..47dce51954 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -85,6 +85,12 @@ const main = async () => { if (!noInstall) { await installDependencies({ projectDir }); + if (usePackages.prisma.inUse) { + logger.info("Generating Prisma client..."); + await execa("npx", ["prisma", "generate"], { cwd: projectDir }); + logger.info("Successfully generated Prisma client!"); + } + await formatProject({ pkgManager, projectDir, diff --git a/cli/src/installers/betterAuth.ts b/cli/src/installers/betterAuth.ts new file mode 100644 index 0000000000..9e92d681b7 --- /dev/null +++ b/cli/src/installers/betterAuth.ts @@ -0,0 +1,134 @@ +import path from "path"; +import fs from "fs-extra"; + +import { PKG_ROOT } from "~/consts.js"; +import { type AvailableDependencies } from "~/installers/dependencyVersionMap.js"; +import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; + +export const betterAuthInstaller: Installer = ({ + projectDir, + packages, + databaseProvider, + appRouter, +}) => { + const usingPrisma = packages?.prisma.inUse; + const usingDrizzle = packages?.drizzle.inUse; + + const deps: AvailableDependencies[] = ["better-auth"]; + if (usingPrisma) deps.push("@auth/prisma-adapter"); + if (usingDrizzle) deps.push("@auth/drizzle-adapter"); + + addPackageDependency({ + projectDir, + dependencies: deps, + devMode: false, + }); + + const extrasDir = path.join(PKG_ROOT, "template/extras"); + + const isAppRouter = appRouter ?? true; // Default to app router if not specified + + const apiHandlerFile = isAppRouter + ? "src/app/api/auth/[...all]/route.ts" + : "src/pages/api/auth/[...all].ts"; + + const apiHandlerSrc = path.join(extrasDir, apiHandlerFile); + const apiHandlerDest = path.join(projectDir, apiHandlerFile); + + const authConfigSrc = path.join( + extrasDir, + "src/server/better-auth/config", + usingPrisma + ? "with-prisma.ts" + : usingDrizzle + ? "with-drizzle.ts" + : "base.ts" + ); + const authConfigDest = path.join( + projectDir, + "src/server/better-auth/config.ts" + ); + + const authIndexSrc = path.join(extrasDir, "src/server/better-auth/index.ts"); + const authIndexDest = path.join( + projectDir, + "src/server/better-auth/index.ts" + ); + + // Better Auth client and server helpers + const betterAuthClientSrc = path.join( + extrasDir, + "src/server/better-auth/client.ts" + ); + const betterAuthClientDest = path.join( + projectDir, + "src/server/better-auth/client.ts" + ); + const betterAuthServerSrc = path.join( + extrasDir, + "src/server/better-auth/server.ts" + ); + const betterAuthServerDest = path.join( + projectDir, + "src/server/better-auth/server.ts" + ); + + fs.copySync(apiHandlerSrc, apiHandlerDest); + fs.copySync(authConfigSrc, authConfigDest); + fs.copySync(authIndexSrc, authIndexDest); + fs.copySync(betterAuthClientSrc, betterAuthClientDest); + fs.copySync(betterAuthServerSrc, betterAuthServerDest); + + // Update Better Auth adapter provider according to selected DB + try { + if (fs.pathExistsSync(authConfigDest)) { + const content = fs.readFileSync(authConfigDest, "utf8"); + + // Map CLI database provider to adapter provider strings + const providerForDrizzle = (db: string) => { + switch (db) { + case "postgres": + return "pg"; + case "mysql": + case "planetscale": + return "mysql"; + case "sqlite": + return "sqlite"; + default: + return "pg"; + } + }; + + const providerForPrisma = (db: string) => { + switch (db) { + case "postgres": + return "postgresql"; + case "mysql": + case "planetscale": + return "mysql"; + case "sqlite": + return "sqlite"; + default: + return "postgresql"; + } + }; + + const providerValue = usingPrisma + ? providerForPrisma(databaseProvider) + : usingDrizzle + ? providerForDrizzle(databaseProvider) + : undefined; + + if (providerValue) { + const updated = content.replace( + /(provider:\s*")[^"]+("\s*,?)/, + `$1${providerValue}$2` + ); + fs.writeFileSync(authConfigDest, updated, "utf8"); + } + } + } catch { + // Non-fatal: leave default provider from template + } +}; diff --git a/cli/src/installers/dependencyVersionMap.ts b/cli/src/installers/dependencyVersionMap.ts index a346468c81..3e798d8e73 100644 --- a/cli/src/installers/dependencyVersionMap.ts +++ b/cli/src/installers/dependencyVersionMap.ts @@ -8,6 +8,9 @@ export const dependencyVersionMap = { "@auth/prisma-adapter": "^2.7.2", "@auth/drizzle-adapter": "^1.7.2", + // Better-Auth + "better-auth": "^1.3", + // Prisma prisma: "^6.6.0", "@prisma/client": "^6.6.0", diff --git a/cli/src/installers/drizzle.ts b/cli/src/installers/drizzle.ts index c9bbae8590..088fd57fc0 100644 --- a/cli/src/installers/drizzle.ts +++ b/cli/src/installers/drizzle.ts @@ -43,12 +43,15 @@ export const drizzleInstaller: Installer = ({ ); const configDest = path.join(projectDir, "drizzle.config.ts"); + const schemaBaseName = packages?.betterAuth.inUse + ? "with-better-auth" + : packages?.nextAuth.inUse + ? "with-auth" + : "base"; const schemaSrc = path.join( extrasDir, "src/server/db/schema-drizzle", - packages?.nextAuth.inUse - ? `with-auth-${databaseProvider}.ts` - : `base-${databaseProvider}.ts` + `${schemaBaseName}-${databaseProvider}.ts` ); const schemaDest = path.join(projectDir, "src/server/db/schema.ts"); diff --git a/cli/src/installers/envVars.ts b/cli/src/installers/envVars.ts index 88d7b6cc30..6b3b4c2331 100644 --- a/cli/src/installers/envVars.ts +++ b/cli/src/installers/envVars.ts @@ -11,7 +11,8 @@ export const envVariablesInstaller: Installer = ({ databaseProvider, scopedAppName, }) => { - const usingAuth = packages?.nextAuth.inUse; + const usingNextAuth = packages?.nextAuth.inUse; + const usingBetterAuth = packages?.betterAuth.inUse; const usingPrisma = packages?.prisma.inUse; const usingDrizzle = packages?.drizzle.inUse; @@ -19,7 +20,8 @@ export const envVariablesInstaller: Installer = ({ const usingPlanetScale = databaseProvider === "planetscale"; const envContent = getEnvContent( - !!usingAuth, + !!usingNextAuth, + !!usingBetterAuth, !!usingPrisma, !!usingDrizzle, databaseProvider, @@ -29,14 +31,17 @@ export const envVariablesInstaller: Installer = ({ let envFile = ""; if (usingDb) { if (usingPlanetScale) { - if (usingAuth) envFile = "with-auth-db-planetscale.js"; + if (usingBetterAuth) envFile = "with-better-auth-db-planetscale.js"; + else if (usingNextAuth) envFile = "with-auth-db-planetscale.js"; else envFile = "with-db-planetscale.js"; } else { - if (usingAuth) envFile = "with-auth-db.js"; + if (usingBetterAuth) envFile = "with-better-auth-db.js"; + else if (usingNextAuth) envFile = "with-auth-db.js"; else envFile = "with-db.js"; } } else { - if (usingAuth) envFile = "with-auth.js"; + if (usingBetterAuth) envFile = "with-better-auth.js"; + else if (usingNextAuth) envFile = "with-auth.js"; } if (envFile !== "") { @@ -58,17 +63,23 @@ export const envVariablesInstaller: Installer = ({ const secret = Buffer.from( crypto.getRandomValues(new Uint8Array(32)) ).toString("base64"); - const _envContent = envContent.replace( - 'AUTH_SECRET=""', - `AUTH_SECRET="${secret}" # Generated by create-t3-app.` - ); + const _envContent = envContent + .replace( + 'AUTH_SECRET=""', + `AUTH_SECRET="${secret}" # Generated by create-t3-app.` + ) + .replace( + 'BETTER_AUTH_SECRET=""', + `BETTER_AUTH_SECRET="${secret}" # Generated by create-t3-app.` + ); fs.writeFileSync(envDest, _envContent, "utf-8"); fs.writeFileSync(envExampleDest, _exampleEnvContent, "utf-8"); }; const getEnvContent = ( - usingAuth: boolean, + usingNextAuth: boolean, + usingBetterAuth: boolean, usingPrisma: boolean, usingDrizzle: boolean, databaseProvider: DatabaseProvider, @@ -81,7 +92,7 @@ const getEnvContent = ( .trim() .concat("\n"); - if (usingAuth) + if (usingNextAuth) content += ` # Next Auth # You can generate a new secret on the command line with: @@ -92,6 +103,17 @@ AUTH_SECRET="" # Next Auth Discord Provider AUTH_DISCORD_ID="" AUTH_DISCORD_SECRET="" +`; + + if (usingBetterAuth) + content += ` +# Better Auth +# Secret used by Better Auth +BETTER_AUTH_SECRET="" + +# Better Auth GitHub OAuth +BETTER_AUTH_GITHUB_CLIENT_ID="" +BETTER_AUTH_GITHUB_CLIENT_SECRET="" `; if (usingPrisma) @@ -105,11 +127,11 @@ AUTH_DISCORD_SECRET="" if (usingPrisma || usingDrizzle) { if (databaseProvider === "planetscale") { if (usingDrizzle) { - content += `# Get the Database URL from the "prisma" dropdown selector in PlanetScale. + content += `# Get the Database URL from the "prisma" dropdown selector in PlanetScale. # Change the query params at the end of the URL to "?ssl={"rejectUnauthorized":true}" DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?ssl={"rejectUnauthorized":true}'`; } else { - content = `# Get the Database URL from the "prisma" dropdown selector in PlanetScale. + content += `# Get the Database URL from the "prisma" dropdown selector in PlanetScale. DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?sslaccept=strict'`; } } else if (databaseProvider === "mysql") { @@ -122,7 +144,7 @@ DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?sslaccept=strict'`; content += "\n"; } - if (!usingAuth && !usingPrisma) + if (!usingNextAuth && !usingBetterAuth && !usingPrisma && !usingDrizzle) content += ` # Example: # SERVERVAR="foo" diff --git a/cli/src/installers/index.ts b/cli/src/installers/index.ts index e04718c532..70dc526e09 100644 --- a/cli/src/installers/index.ts +++ b/cli/src/installers/index.ts @@ -4,6 +4,7 @@ import { prismaInstaller } from "~/installers/prisma.js"; import { tailwindInstaller } from "~/installers/tailwind.js"; import { trpcInstaller } from "~/installers/trpc.js"; import { type PackageManager } from "~/utils/getUserPkgManager.js"; +import { betterAuthInstaller } from "./betterAuth.js"; import { biomeInstaller } from "./biome.js"; import { dbContainerInstaller } from "./dbContainer.js"; import { drizzleInstaller } from "./drizzle.js"; @@ -13,6 +14,7 @@ import { dynamicEslintInstaller } from "./eslint.js"; // Should increase extensibility in the future export const availablePackages = [ "nextAuth", + "betterAuth", "prisma", "drizzle", "tailwind", @@ -61,6 +63,10 @@ export const buildPkgInstallerMap = ( inUse: packages.includes("nextAuth"), installer: nextAuthInstaller, }, + betterAuth: { + inUse: packages.includes("betterAuth"), + installer: betterAuthInstaller, + }, prisma: { inUse: packages.includes("prisma"), installer: prismaInstaller, diff --git a/cli/src/installers/prisma.ts b/cli/src/installers/prisma.ts index 36c2b7cff7..fdd4044a69 100644 --- a/cli/src/installers/prisma.ts +++ b/cli/src/installers/prisma.ts @@ -30,10 +30,15 @@ export const prismaInstaller: Installer = ({ const extrasDir = path.join(PKG_ROOT, "template/extras"); + const schemaBaseName = packages?.betterAuth.inUse + ? "with-better-auth" + : packages?.nextAuth.inUse + ? "with-auth" + : "base"; const schemaSrc = path.join( extrasDir, "prisma/schema", - `${packages?.nextAuth.inUse ? "with-auth" : "base"}${ + `${schemaBaseName}${ databaseProvider === "planetscale" ? "-planetscale" : "" }.prisma` ); diff --git a/cli/src/installers/trpc.ts b/cli/src/installers/trpc.ts index 697867a662..09490f7dfa 100644 --- a/cli/src/installers/trpc.ts +++ b/cli/src/installers/trpc.ts @@ -23,6 +23,7 @@ export const trpcInstaller: Installer = ({ }); const usingAuth = packages?.nextAuth.inUse; + const usingBetterAuth = packages?.betterAuth.inUse; const usingPrisma = packages?.prisma.inUse; const usingDrizzle = packages?.drizzle.inUse; const usingDb = usingPrisma === true || usingDrizzle === true; @@ -36,14 +37,14 @@ export const trpcInstaller: Installer = ({ const apiHandlerSrc = path.join(extrasDir, srcToUse); const apiHandlerDest = path.join(projectDir, srcToUse); - const trpcFile = - usingAuth && usingDb - ? "with-auth-db.ts" - : usingAuth - ? "with-auth.ts" - : usingDb - ? "with-db.ts" - : "base.ts"; + const trpcFile = (() => { + if (usingBetterAuth && usingDb) return "with-better-auth-db.ts"; + if (usingBetterAuth) return "with-better-auth.ts"; + if (usingAuth && usingDb) return "with-auth-db.ts"; + if (usingAuth) return "with-auth.ts"; + if (usingDb) return "with-db.ts"; + return "base.ts"; + })(); const trpcSrc = path.join( extrasDir, "src/server/api", @@ -56,11 +57,11 @@ export const trpcInstaller: Installer = ({ const rootRouterDest = path.join(projectDir, "src/server/api/root.ts"); const exampleRouterFile = - usingAuth && usingPrisma + (usingAuth || usingBetterAuth) && usingPrisma ? "with-auth-prisma.ts" - : usingAuth && usingDrizzle + : (usingAuth || usingBetterAuth) && usingDrizzle ? "with-auth-drizzle.ts" - : usingAuth + : usingAuth || usingBetterAuth ? "with-auth.ts" : usingPrisma ? "with-prisma.ts" diff --git a/cli/template/base/tsconfig.json b/cli/template/base/tsconfig.json index 1fd505b2fe..3eb759e7e2 100644 --- a/cli/template/base/tsconfig.json +++ b/cli/template/base/tsconfig.json @@ -38,5 +38,5 @@ "**/*.js", ".next/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "generated"] } diff --git a/cli/template/extras/prisma/schema/base-planetscale.prisma b/cli/template/extras/prisma/schema/base-planetscale.prisma index 6b9dd139de..d46d9af07c 100644 --- a/cli/template/extras/prisma/schema/base-planetscale.prisma +++ b/cli/template/extras/prisma/schema/base-planetscale.prisma @@ -4,6 +4,7 @@ generator client { provider = "prisma-client-js" previewFeatures = ["driverAdapters"] + output = "../generated/prisma" } datasource db { diff --git a/cli/template/extras/prisma/schema/base.prisma b/cli/template/extras/prisma/schema/base.prisma index ddb6e0995c..7b1a4ddd4f 100644 --- a/cli/template/extras/prisma/schema/base.prisma +++ b/cli/template/extras/prisma/schema/base.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" + output = "../generated/prisma" } datasource db { diff --git a/cli/template/extras/prisma/schema/with-auth-planetscale.prisma b/cli/template/extras/prisma/schema/with-auth-planetscale.prisma index 198915b9d2..98798d5e0c 100644 --- a/cli/template/extras/prisma/schema/with-auth-planetscale.prisma +++ b/cli/template/extras/prisma/schema/with-auth-planetscale.prisma @@ -2,7 +2,9 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + output = "../generated/prisma" + previewFeatures = ["driverAdapters"] } diff --git a/cli/template/extras/prisma/schema/with-auth.prisma b/cli/template/extras/prisma/schema/with-auth.prisma index b17831e607..94a918ef14 100644 --- a/cli/template/extras/prisma/schema/with-auth.prisma +++ b/cli/template/extras/prisma/schema/with-auth.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" + output = "../generated/prisma" } datasource db { diff --git a/cli/template/extras/prisma/schema/with-better-auth-planetscale.prisma b/cli/template/extras/prisma/schema/with-better-auth-planetscale.prisma new file mode 100644 index 0000000000..a008971f46 --- /dev/null +++ b/cli/template/extras/prisma/schema/with-better-auth-planetscale.prisma @@ -0,0 +1,90 @@ +// Prisma schema for Better Auth +// learn more: https://better-auth.com/docs/concepts/database + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below +// Further reading: +// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string + +datasource db { + provider = "mysql" + relationMode = "prisma" + url = env("DATABASE_URL") +} + +model Post { + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + createdBy User @relation(fields: [createdById], references: [id]) + createdById String + + @@index([name]) +} + +model User { + id String @id + name String @db.Text + email String + emailVerified Boolean @default(false) + image String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + sessions Session[] + accounts Account[] + posts Post[] + + @@unique([email]) + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? @db.Text + userAgent String? @db.Text + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@map("session") +} + +model Account { + id String @id + accountId String @db.Text + providerId String @db.Text + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? @db.Text + refreshToken String? @db.Text + idToken String? @db.Text + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? @db.Text + password String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("account") +} + +model Verification { + id String @id + identifier String @db.Text + value String @db.Text + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("verification") +} diff --git a/cli/template/extras/prisma/schema/with-better-auth.prisma b/cli/template/extras/prisma/schema/with-better-auth.prisma new file mode 100644 index 0000000000..9c0c418698 --- /dev/null +++ b/cli/template/extras/prisma/schema/with-better-auth.prisma @@ -0,0 +1,89 @@ +// Prisma schema for Better Auth +// learn more: https://better-auth.com/docs/concepts/database + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +// NOTE: When using mysql or sqlserver, uncomment the //@db.Text annotations in model Account below +// Further reading: +// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model Post { + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + createdBy User @relation(fields: [createdById], references: [id]) + createdById String + + @@index([name]) +} + +model User { + id String @id + name String //@db.Text + email String + emailVerified Boolean @default(false) + image String? //@db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + sessions Session[] + accounts Account[] + posts Post[] + + @@unique([email]) + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? //@db.Text + userAgent String? //@db.Text + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@map("session") +} + +model Account { + id String @id + accountId String //@db.Text + providerId String //@db.Text + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? //@db.Text + refreshToken String? //@db.Text + idToken String? //@db.Text + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? //@db.Text + password String? //@db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("account") +} + +model Verification { + id String @id + identifier String //@db.Text + value String //@db.Text + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("verification") +} diff --git a/cli/template/extras/src/app/api/auth/[...all]/route.ts b/cli/template/extras/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000000..7f3d6c19c8 --- /dev/null +++ b/cli/template/extras/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,5 @@ +import { toNextJsHandler } from "better-auth/next-js"; + +import { auth } from "~/server/better-auth"; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/cli/template/extras/src/app/page/with-better-auth-trpc-tw.tsx b/cli/template/extras/src/app/page/with-better-auth-trpc-tw.tsx new file mode 100644 index 0000000000..cf5f9a7426 --- /dev/null +++ b/cli/template/extras/src/app/page/with-better-auth-trpc-tw.tsx @@ -0,0 +1,103 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { LatestPost } from "~/app/_components/post"; +import { auth } from "~/server/better-auth"; +import { getSession } from "~/server/better-auth/server"; +import { api, HydrateClient } from "~/trpc/server"; + +export default async function Home() { + const hello = await api.post.hello({ text: "from tRPC" }); + const session = await getSession(); + + if (session) { + void api.post.getLatest.prefetch(); + } + + return ( + +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+

+ {hello ? hello.greeting : "Loading tRPC query..."} +

+ +
+

+ {session && Logged in as {session.user?.name}} +

+ {!session ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + {session?.user && } +
+
+
+ ); +} diff --git a/cli/template/extras/src/app/page/with-better-auth-trpc.tsx b/cli/template/extras/src/app/page/with-better-auth-trpc.tsx new file mode 100644 index 0000000000..bddf1fbe59 --- /dev/null +++ b/cli/template/extras/src/app/page/with-better-auth-trpc.tsx @@ -0,0 +1,101 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { LatestPost } from "~/app/_components/post"; +import { auth } from "~/server/better-auth"; +import { getSession } from "~/server/better-auth/server"; +import { api, HydrateClient } from "~/trpc/server"; +import styles from "./index.module.css"; + +export default async function Home() { + const hello = await api.post.hello({ text: "from tRPC" }); + const session = await getSession(); + + if (session?.user) { + void api.post.getLatest.prefetch(); + } + + return ( + +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+

+ {hello ? hello.greeting : "Loading tRPC query..."} +

+ +
+

+ {session && Logged in as {session.user?.name}} +

+ {!session ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + {session?.user && } +
+
+
+ ); +} diff --git a/cli/template/extras/src/app/page/with-better-auth-tw.tsx b/cli/template/extras/src/app/page/with-better-auth-tw.tsx new file mode 100644 index 0000000000..0e66ae58ef --- /dev/null +++ b/cli/template/extras/src/app/page/with-better-auth-tw.tsx @@ -0,0 +1,88 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { auth } from "~/server/better-auth"; +import { getSession } from "~/server/better-auth/server"; + +export default async function Home() { + const session = await getSession(); + + return ( +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how to + deploy it. +
+ +
+
+
+

+ {session && Logged in as {session.user?.name}} +

+ {!session ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/cli/template/extras/src/app/page/with-better-auth.tsx b/cli/template/extras/src/app/page/with-better-auth.tsx new file mode 100644 index 0000000000..c694d6f5b9 --- /dev/null +++ b/cli/template/extras/src/app/page/with-better-auth.tsx @@ -0,0 +1,86 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { auth } from "~/server/better-auth"; +import { getSession } from "~/server/better-auth/server"; +import styles from "./index.module.css"; + +export default async function Home() { + const session = await getSession(); + + return ( +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how to + deploy it. +
+ +
+
+
+

+ {session && Logged in as {session.user?.name}} +

+ {!session ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/cli/template/extras/src/env/with-better-auth-db-planetscale.js b/cli/template/extras/src/env/with-better-auth-db-planetscale.js new file mode 100644 index 0000000000..3e8bb4ee6f --- /dev/null +++ b/cli/template/extras/src/env/with-better-auth-db-planetscale.js @@ -0,0 +1,58 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + BETTER_AUTH_SECRET: + process.env.NODE_ENV === "production" + ? z.string() + : z.string().optional(), + BETTER_AUTH_GITHUB_CLIENT_ID: z.string(), + BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string(), + DATABASE_URL: z + .string() + .url() + .refine( + (str) => !str.includes("YOUR_MYSQL_URL_HERE"), + "You forgot to change the default URL" + ), + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID, + BETTER_AUTH_GITHUB_CLIENT_SECRET: process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET, + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially + * useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and + * `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}); diff --git a/cli/template/extras/src/env/with-better-auth-db.js b/cli/template/extras/src/env/with-better-auth-db.js new file mode 100644 index 0000000000..748f7adda4 --- /dev/null +++ b/cli/template/extras/src/env/with-better-auth-db.js @@ -0,0 +1,50 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + BETTER_AUTH_SECRET: + process.env.NODE_ENV === "production" ? z.string() : z.string().optional(), + BETTER_AUTH_GITHUB_CLIENT_ID: z.string(), + BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string(), + DATABASE_URL: z.string().url(), + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID, + BETTER_AUTH_GITHUB_CLIENT_SECRET: process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET, + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially + * useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and + * `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}); diff --git a/cli/template/extras/src/env/with-better-auth.js b/cli/template/extras/src/env/with-better-auth.js new file mode 100644 index 0000000000..544c4a9aa6 --- /dev/null +++ b/cli/template/extras/src/env/with-better-auth.js @@ -0,0 +1,49 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + /** + * Specify your server-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. + */ + server: { + BETTER_AUTH_SECRET: + process.env.NODE_ENV === "production" ? z.string() : z.string().optional(), + BETTER_AUTH_GITHUB_CLIENT_ID: z.string(), + BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string(), + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + }, + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app + * isn't built with invalid env vars. To expose them to the client, prefix them with + * `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + + /** + * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. + * middlewares) or client-side so we need to destruct manually. + */ + runtimeEnv: { + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID, + BETTER_AUTH_GITHUB_CLIENT_SECRET: process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET, + NODE_ENV: process.env.NODE_ENV, + // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, + }, + /** + * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially + * useful for Docker builds. + */ + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + /** + * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and + * `SOME_VAR=''` will throw an error. + */ + emptyStringAsUndefined: true, +}); diff --git a/cli/template/extras/src/pages/_app/with-better-auth-trpc-tw.tsx b/cli/template/extras/src/pages/_app/with-better-auth-trpc-tw.tsx new file mode 100644 index 0000000000..70edf4236e --- /dev/null +++ b/cli/template/extras/src/pages/_app/with-better-auth-trpc-tw.tsx @@ -0,0 +1,20 @@ +import { type AppType } from "next/app"; +import { Geist } from "next/font/google"; + +import { api } from "~/utils/api"; + +import "~/styles/globals.css"; + +const geist = Geist({ + subsets: ["latin"], +}); + +const MyApp: AppType = ({ Component, pageProps }) => { + return ( +
+ +
+ ); +}; + +export default api.withTRPC(MyApp); diff --git a/cli/template/extras/src/pages/_app/with-better-auth-trpc.tsx b/cli/template/extras/src/pages/_app/with-better-auth-trpc.tsx new file mode 100644 index 0000000000..70edf4236e --- /dev/null +++ b/cli/template/extras/src/pages/_app/with-better-auth-trpc.tsx @@ -0,0 +1,20 @@ +import { type AppType } from "next/app"; +import { Geist } from "next/font/google"; + +import { api } from "~/utils/api"; + +import "~/styles/globals.css"; + +const geist = Geist({ + subsets: ["latin"], +}); + +const MyApp: AppType = ({ Component, pageProps }) => { + return ( +
+ +
+ ); +}; + +export default api.withTRPC(MyApp); diff --git a/cli/template/extras/src/pages/api/auth/[...all].ts b/cli/template/extras/src/pages/api/auth/[...all].ts new file mode 100644 index 0000000000..7732ef9074 --- /dev/null +++ b/cli/template/extras/src/pages/api/auth/[...all].ts @@ -0,0 +1,8 @@ +import { toNodeHandler } from "better-auth/node"; + +import { auth } from "~/server/better-auth"; + +// Disallow body parsing, we will parse it manually +export const config = { api: { bodyParser: false } }; + +export default toNodeHandler(auth.handler); diff --git a/cli/template/extras/src/pages/index/with-better-auth-trpc-tw.tsx b/cli/template/extras/src/pages/index/with-better-auth-trpc-tw.tsx new file mode 100644 index 0000000000..396454ff6e --- /dev/null +++ b/cli/template/extras/src/pages/index/with-better-auth-trpc-tw.tsx @@ -0,0 +1,99 @@ +import Head from "next/head"; +import Link from "next/link"; + +import { authClient } from "~/server/better-auth/client"; +import { api } from "~/utils/api"; + +export default function Home() { + const hello = api.post.hello.useQuery({ text: "from tRPC" }); + + return ( + <> + + Create T3 App + + + +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+

+ {hello.data ? hello.data.greeting : "Loading tRPC query..."} +

+ +
+
+
+ + ); +} + +function AuthShowcase() { + const { data: sessionData, isPending } = authClient.useSession(); + + const { data: secretMessage } = api.post.getSecretMessage.useQuery( + undefined, // no input + { enabled: sessionData?.user !== undefined } + ); + + if (isPending) { + return

Loading...

; + } + + return ( +
+

+ {sessionData && Logged in as {sessionData.user?.name}} + {secretMessage && - {secretMessage}} +

+ {sessionData ? ( + + ) : ( + + )} +
+ ); +} diff --git a/cli/template/extras/src/pages/index/with-better-auth-trpc.tsx b/cli/template/extras/src/pages/index/with-better-auth-trpc.tsx new file mode 100644 index 0000000000..63a5d92154 --- /dev/null +++ b/cli/template/extras/src/pages/index/with-better-auth-trpc.tsx @@ -0,0 +1,100 @@ +import Head from "next/head"; +import Link from "next/link"; + +import { authClient } from "~/server/better-auth/client"; +import { api } from "~/utils/api"; +import styles from "./index.module.css"; + +export default function Home() { + const hello = api.post.hello.useQuery({ text: "from tRPC" }); + + return ( + <> + + Create T3 App + + + +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+

+ {hello.data ? hello.data.greeting : "Loading tRPC query..."} +

+ +
+
+
+ + ); +} + +function AuthShowcase() { + const { data: sessionData, isPending } = authClient.useSession(); + + const { data: secretMessage } = api.post.getSecretMessage.useQuery( + undefined, // no input + { enabled: sessionData?.user !== undefined } + ); + + if (isPending) { + return

Loading...

; + } + + return ( +
+

+ {sessionData && Logged in as {sessionData.user?.name}} + {secretMessage && - {secretMessage}} +

+ {sessionData ? ( + + ) : ( + + )} +
+ ); +} diff --git a/cli/template/extras/src/pages/index/with-better-auth-tw.tsx b/cli/template/extras/src/pages/index/with-better-auth-tw.tsx new file mode 100644 index 0000000000..efa6fe00ea --- /dev/null +++ b/cli/template/extras/src/pages/index/with-better-auth-tw.tsx @@ -0,0 +1,87 @@ +import Head from "next/head"; +import Link from "next/link"; + +import { authClient } from "~/server/better-auth/client"; + +export default function Home() { + return ( + <> + + Create T3 App + + + +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+ +
+
+
+ + ); +} + +function AuthShowcase() { + const { data: sessionData, isPending } = authClient.useSession(); + + if (isPending) { + return

Loading...

; + } + + return ( +
+

+ {sessionData && Logged in as {sessionData.user?.name}} +

+ {sessionData ? ( + + ) : ( + + )} +
+ ); +} diff --git a/cli/template/extras/src/pages/index/with-better-auth.tsx b/cli/template/extras/src/pages/index/with-better-auth.tsx new file mode 100644 index 0000000000..e77a3e1768 --- /dev/null +++ b/cli/template/extras/src/pages/index/with-better-auth.tsx @@ -0,0 +1,88 @@ +import Head from "next/head"; +import Link from "next/link"; + +import { authClient } from "~/server/better-auth/client"; +import styles from "./index.module.css"; + +export default function Home() { + return ( + <> + + Create T3 App + + + +
+
+

+ Create T3 App +

+
+ +

First Steps β†’

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Documentation β†’

+
+ Learn more about Create T3 App, the libraries it uses, and how + to deploy it. +
+ +
+
+ +
+
+
+ + ); +} + +function AuthShowcase() { + const { data: sessionData, isPending } = authClient.useSession(); + + if (isPending) { + return

Loading...

; + } + + return ( +
+

+ {sessionData && Logged in as {sessionData.user?.name}} +

+ {sessionData ? ( + + ) : ( + + )} +
+ ); +} diff --git a/cli/template/extras/src/server/api/trpc-app/with-better-auth-db.ts b/cli/template/extras/src/server/api/trpc-app/with-better-auth-db.ts new file mode 100644 index 0000000000..2dd55313d5 --- /dev/null +++ b/cli/template/extras/src/server/api/trpc-app/with-better-auth-db.ts @@ -0,0 +1,134 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import { auth } from "~/server/better-auth"; +import { db } from "~/server/db"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ +export const createTRPCContext = async (opts: { headers: Headers }) => { + const session = await auth.api.getSession({ + headers: opts.headers, + }); + return { + db, + session, + ...opts, + }; +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); + }); diff --git a/cli/template/extras/src/server/api/trpc-app/with-better-auth.ts b/cli/template/extras/src/server/api/trpc-app/with-better-auth.ts new file mode 100644 index 0000000000..18e95d9ff0 --- /dev/null +++ b/cli/template/extras/src/server/api/trpc-app/with-better-auth.ts @@ -0,0 +1,131 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ +import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import { auth } from "~/server/better-auth"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ +export const createTRPCContext = async (opts: { headers: Headers }) => { + const session = await auth.api.getSession({ + headers: opts.headers, + }); + return { + session, + ...opts, + }; +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); + }); diff --git a/cli/template/extras/src/server/api/trpc-pages/with-better-auth-db.ts b/cli/template/extras/src/server/api/trpc-pages/with-better-auth-db.ts new file mode 100644 index 0000000000..f291a93e00 --- /dev/null +++ b/cli/template/extras/src/server/api/trpc-pages/with-better-auth-db.ts @@ -0,0 +1,168 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +import { initTRPC, TRPCError } from "@trpc/server"; +import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import { auth } from "~/server/better-auth"; +import { type Session } from "~/server/better-auth/config"; +import { db } from "~/server/db"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + */ + +interface CreateContextOptions { + session: Session | null; +} + +/** + * This helper generates the "internals" for a tRPC context. If you need to use it, you can export + * it from here. + * + * Examples of things you may need it for: + * - testing, so we don't have to mock Next.js' req/res + * - tRPC's `createSSGHelpers`, where we don't have req/res + * + * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts + */ +const createInnerTRPCContext = (opts: CreateContextOptions) => { + return { + session: opts.session, + db, + }; +}; + +/** + * This is the actual context you will use in your router. It will be used to process every request + * that goes through your tRPC endpoint. + * + * @see https://trpc.io/docs/context + */ +export const createTRPCContext = async ({ req }: CreateNextContextOptions) => { + // Convert IncomingHttpHeaders to Headers object + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + value.forEach((v) => headers.append(key, v)); + } else if (value) { + headers.append(key, value); + } + } + + const session = await auth.api.getSession({ + headers, + }); + return createInnerTRPCContext({ + session, + }); +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ + +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); + }); diff --git a/cli/template/extras/src/server/api/trpc-pages/with-better-auth.ts b/cli/template/extras/src/server/api/trpc-pages/with-better-auth.ts new file mode 100644 index 0000000000..55a130e26d --- /dev/null +++ b/cli/template/extras/src/server/api/trpc-pages/with-better-auth.ts @@ -0,0 +1,166 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ +import { initTRPC, TRPCError } from "@trpc/server"; +import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; +// import { } from "next-auth"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +import { auth } from "~/server/better-auth"; +import { type Session } from "~/server/better-auth/config"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + */ + +interface CreateContextOptions { + session: Session | null; +} + +/** + * This helper generates the "internals" for a tRPC context. If you need to use it, you can export + * it from here. + * + * Examples of things you may need it for: + * - testing, so we don't have to mock Next.js' req/res + * - tRPC's `createSSGHelpers`, where we don't have req/res + * + * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts + */ +const createInnerTRPCContext = ({ session }: CreateContextOptions) => { + return { + session, + }; +}; + +/** + * This is the actual context you will use in your router. It will be used to process every request + * that goes through your tRPC endpoint. + * + * @see https://trpc.io/docs/context + */ +export const createTRPCContext = async ({ req }: CreateNextContextOptions) => { + // Convert IncomingHttpHeaders to Headers object + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + value.forEach((v) => headers.append(key, v)); + } else if (value) { + headers.append(key, value); + } + } + + const session = await auth.api.getSession({ + headers, + }); + return createInnerTRPCContext({ + session, + }); +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ + +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +export const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: { ...ctx.session, user: ctx.session.user }, + }, + }); + }); diff --git a/cli/template/extras/src/server/better-auth/client.ts b/cli/template/extras/src/server/better-auth/client.ts new file mode 100644 index 0000000000..493f849930 --- /dev/null +++ b/cli/template/extras/src/server/better-auth/client.ts @@ -0,0 +1,5 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); + +export type Session = typeof authClient.$Infer.Session; diff --git a/cli/template/extras/src/server/better-auth/config/base.ts b/cli/template/extras/src/server/better-auth/config/base.ts new file mode 100644 index 0000000000..abf50facaa --- /dev/null +++ b/cli/template/extras/src/server/better-auth/config/base.ts @@ -0,0 +1,9 @@ +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + }, +}); + +export type Session = typeof auth.$Infer.Session; diff --git a/cli/template/extras/src/server/better-auth/config/with-drizzle.ts b/cli/template/extras/src/server/better-auth/config/with-drizzle.ts new file mode 100644 index 0000000000..b1784e3af9 --- /dev/null +++ b/cli/template/extras/src/server/better-auth/config/with-drizzle.ts @@ -0,0 +1,23 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; + +import { env } from "~/env"; +import { db } from "~/server/db"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", // or "pg" or "mysql" + }), + emailAndPassword: { + enabled: true, + }, + socialProviders: { + github: { + clientId: env.BETTER_AUTH_GITHUB_CLIENT_ID, + clientSecret: env.BETTER_AUTH_GITHUB_CLIENT_SECRET, + redirectURI: "http://localhost:3000/api/auth/callback/github", + }, + }, +}); + +export type Session = typeof auth.$Infer.Session; diff --git a/cli/template/extras/src/server/better-auth/config/with-prisma.ts b/cli/template/extras/src/server/better-auth/config/with-prisma.ts new file mode 100644 index 0000000000..e3643a3bcf --- /dev/null +++ b/cli/template/extras/src/server/better-auth/config/with-prisma.ts @@ -0,0 +1,23 @@ +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; + +import { env } from "~/env"; +import { db } from "~/server/db"; + +export const auth = betterAuth({ + database: prismaAdapter(db, { + provider: "postgresql", // or "sqlite" or "mysql" + }), + emailAndPassword: { + enabled: true, + }, + socialProviders: { + github: { + clientId: env.BETTER_AUTH_GITHUB_CLIENT_ID, + clientSecret: env.BETTER_AUTH_GITHUB_CLIENT_SECRET, + redirectURI: "http://localhost:3000/api/auth/callback/github", + }, + }, +}); + +export type Session = typeof auth.$Infer.Session; diff --git a/cli/template/extras/src/server/better-auth/index.ts b/cli/template/extras/src/server/better-auth/index.ts new file mode 100644 index 0000000000..d705e873e5 --- /dev/null +++ b/cli/template/extras/src/server/better-auth/index.ts @@ -0,0 +1 @@ +export { auth } from "./config"; diff --git a/cli/template/extras/src/server/better-auth/server.ts b/cli/template/extras/src/server/better-auth/server.ts new file mode 100644 index 0000000000..c5c53ebfd4 --- /dev/null +++ b/cli/template/extras/src/server/better-auth/server.ts @@ -0,0 +1,7 @@ +import { auth } from "."; +import { headers } from "next/headers"; +import { cache } from "react"; + +export const getSession = cache(async () => + auth.api.getSession({ headers: await headers() }) +); diff --git a/cli/template/extras/src/server/db/db-prisma-planetscale.ts b/cli/template/extras/src/server/db/db-prisma-planetscale.ts index 440ce5da39..3cc171d9ee 100644 --- a/cli/template/extras/src/server/db/db-prisma-planetscale.ts +++ b/cli/template/extras/src/server/db/db-prisma-planetscale.ts @@ -1,7 +1,7 @@ import { PrismaPlanetScale } from "@prisma/adapter-planetscale"; -import { PrismaClient } from "@prisma/client"; import { env } from "~/env"; +import { PrismaClient } from "../../generated/prisma"; const createPrismaClient = () => new PrismaClient({ diff --git a/cli/template/extras/src/server/db/db-prisma.ts b/cli/template/extras/src/server/db/db-prisma.ts index 07dc0271a5..37b65c64af 100644 --- a/cli/template/extras/src/server/db/db-prisma.ts +++ b/cli/template/extras/src/server/db/db-prisma.ts @@ -1,6 +1,5 @@ -import { PrismaClient } from "@prisma/client"; - import { env } from "~/env"; +import { PrismaClient } from "../../generated/prisma"; const createPrismaClient = () => new PrismaClient({ diff --git a/cli/template/extras/src/server/db/schema-drizzle/base-mysql.ts b/cli/template/extras/src/server/db/schema-drizzle/base-mysql.ts index 4361f28866..54e61bff6d 100644 --- a/cli/template/extras/src/server/db/schema-drizzle/base-mysql.ts +++ b/cli/template/extras/src/server/db/schema-drizzle/base-mysql.ts @@ -1,7 +1,6 @@ // Example model schema from the Drizzle docs // https://orm.drizzle.team/docs/sql-schema-declaration -import { sql } from "drizzle-orm"; import { index, mysqlTableCreator } from "drizzle-orm/mysql-core"; /** @@ -19,7 +18,7 @@ export const posts = createTable( name: d.varchar({ length: 256 }), createdAt: d .timestamp() - .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), updatedAt: d.timestamp().onUpdateNow(), }), diff --git a/cli/template/extras/src/server/db/schema-drizzle/base-planetscale.ts b/cli/template/extras/src/server/db/schema-drizzle/base-planetscale.ts index 4361f28866..54e61bff6d 100644 --- a/cli/template/extras/src/server/db/schema-drizzle/base-planetscale.ts +++ b/cli/template/extras/src/server/db/schema-drizzle/base-planetscale.ts @@ -1,7 +1,6 @@ // Example model schema from the Drizzle docs // https://orm.drizzle.team/docs/sql-schema-declaration -import { sql } from "drizzle-orm"; import { index, mysqlTableCreator } from "drizzle-orm/mysql-core"; /** @@ -19,7 +18,7 @@ export const posts = createTable( name: d.varchar({ length: 256 }), createdAt: d .timestamp() - .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), updatedAt: d.timestamp().onUpdateNow(), }), diff --git a/cli/template/extras/src/server/db/schema-drizzle/base-postgres.ts b/cli/template/extras/src/server/db/schema-drizzle/base-postgres.ts index ec724194c8..b359e7ec74 100644 --- a/cli/template/extras/src/server/db/schema-drizzle/base-postgres.ts +++ b/cli/template/extras/src/server/db/schema-drizzle/base-postgres.ts @@ -1,7 +1,6 @@ // Example model schema from the Drizzle docs // https://orm.drizzle.team/docs/sql-schema-declaration -import { sql } from "drizzle-orm"; import { index, pgTableCreator } from "drizzle-orm/pg-core"; /** @@ -19,7 +18,7 @@ export const posts = createTable( name: d.varchar({ length: 256 }), createdAt: d .timestamp({ withTimezone: true }) - .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()), }), diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-auth-mysql.ts b/cli/template/extras/src/server/db/schema-drizzle/with-auth-mysql.ts index 1138503d6b..6c278826f6 100644 --- a/cli/template/extras/src/server/db/schema-drizzle/with-auth-mysql.ts +++ b/cli/template/extras/src/server/db/schema-drizzle/with-auth-mysql.ts @@ -21,7 +21,7 @@ export const posts = createTable( .references(() => users.id), createdAt: d .timestamp() - .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), updatedAt: d.timestamp().onUpdateNow(), }), diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-auth-planetscale.ts b/cli/template/extras/src/server/db/schema-drizzle/with-auth-planetscale.ts index 5631092141..a8460fb333 100644 --- a/cli/template/extras/src/server/db/schema-drizzle/with-auth-planetscale.ts +++ b/cli/template/extras/src/server/db/schema-drizzle/with-auth-planetscale.ts @@ -18,7 +18,7 @@ export const posts = createTable( createdById: d.varchar({ length: 255 }).notNull(), createdAt: d .timestamp() - .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), updatedAt: d.timestamp().onUpdateNow(), }), diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-auth-postgres.ts b/cli/template/extras/src/server/db/schema-drizzle/with-auth-postgres.ts index b704601b7a..66ab653f95 100644 --- a/cli/template/extras/src/server/db/schema-drizzle/with-auth-postgres.ts +++ b/cli/template/extras/src/server/db/schema-drizzle/with-auth-postgres.ts @@ -1,4 +1,4 @@ -import { relations, sql } from "drizzle-orm"; +import { relations } from "drizzle-orm"; import { index, pgTableCreator, primaryKey } from "drizzle-orm/pg-core"; import { type AdapterAccount } from "next-auth/adapters"; @@ -21,7 +21,7 @@ export const posts = createTable( .references(() => users.id), createdAt: d .timestamp({ withTimezone: true }) - .default(sql`CURRENT_TIMESTAMP`) + .$defaultFn(() => /* @__PURE__ */ new Date()) .notNull(), updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()), }), @@ -44,7 +44,7 @@ export const users = createTable("user", (d) => ({ mode: "date", withTimezone: true, }) - .default(sql`CURRENT_TIMESTAMP`), + .$defaultFn(() => /* @__PURE__ */ new Date()), image: d.varchar({ length: 255 }), })); diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-mysql.ts b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-mysql.ts new file mode 100644 index 0000000000..f4357c066e --- /dev/null +++ b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-mysql.ts @@ -0,0 +1,106 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + mysqlTable, + mysqlTableCreator, + text, + timestamp, + varchar, +} from "drizzle-orm/mysql-core"; + +export const createTable = mysqlTableCreator((name) => `project1_${name}`); + +export const posts = createTable( + "post", + (d) => ({ + id: d.bigint({ mode: "number" }).primaryKey().autoincrement(), + name: d.varchar({ length: 256 }), + createdById: d + .varchar({ length: 255 }) + .notNull() + .references(() => user.id), + createdAt: d + .timestamp() + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: d.timestamp().onUpdateNow(), + }), + (t) => [ + index("created_by_idx").on(t.createdById), + index("name_idx").on(t.name), + ] +); + +export const user = mysqlTable("user", { + id: varchar("id", { length: 36 }).primaryKey(), + name: text("name").notNull(), + email: varchar("email", { length: 255 }).notNull().unique(), + emailVerified: boolean("email_verified") + .$defaultFn(() => false) + .notNull(), + image: text("image"), + createdAt: timestamp("created_at") + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp("updated_at") + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = mysqlTable("session", { + id: varchar("id", { length: 36 }).primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: varchar("token", { length: 255 }).notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = mysqlTable("account", { + id: varchar("id", { length: 36 }).primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); + +export const verification = mysqlTable("verification", { + id: varchar("id", { length: 36 }).primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").$defaultFn( + () => /* @__PURE__ */ new Date() + ), + updatedAt: timestamp("updated_at").$defaultFn( + () => /* @__PURE__ */ new Date() + ), +}); + +export const usersRelations = relations(user, ({ many }) => ({ + accounts: many(account), + sessions: many(session), +})); + +export const accountsRelations = relations(account, ({ one }) => ({ + user: one(user, { fields: [account.userId], references: [user.id] }), +})); + +export const sessionsRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-planetscale.ts b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-planetscale.ts new file mode 100644 index 0000000000..f4357c066e --- /dev/null +++ b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-planetscale.ts @@ -0,0 +1,106 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + mysqlTable, + mysqlTableCreator, + text, + timestamp, + varchar, +} from "drizzle-orm/mysql-core"; + +export const createTable = mysqlTableCreator((name) => `project1_${name}`); + +export const posts = createTable( + "post", + (d) => ({ + id: d.bigint({ mode: "number" }).primaryKey().autoincrement(), + name: d.varchar({ length: 256 }), + createdById: d + .varchar({ length: 255 }) + .notNull() + .references(() => user.id), + createdAt: d + .timestamp() + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: d.timestamp().onUpdateNow(), + }), + (t) => [ + index("created_by_idx").on(t.createdById), + index("name_idx").on(t.name), + ] +); + +export const user = mysqlTable("user", { + id: varchar("id", { length: 36 }).primaryKey(), + name: text("name").notNull(), + email: varchar("email", { length: 255 }).notNull().unique(), + emailVerified: boolean("email_verified") + .$defaultFn(() => false) + .notNull(), + image: text("image"), + createdAt: timestamp("created_at") + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp("updated_at") + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = mysqlTable("session", { + id: varchar("id", { length: 36 }).primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: varchar("token", { length: 255 }).notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = mysqlTable("account", { + id: varchar("id", { length: 36 }).primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); + +export const verification = mysqlTable("verification", { + id: varchar("id", { length: 36 }).primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").$defaultFn( + () => /* @__PURE__ */ new Date() + ), + updatedAt: timestamp("updated_at").$defaultFn( + () => /* @__PURE__ */ new Date() + ), +}); + +export const usersRelations = relations(user, ({ many }) => ({ + accounts: many(account), + sessions: many(session), +})); + +export const accountsRelations = relations(account, ({ one }) => ({ + user: one(user, { fields: [account.userId], references: [user.id] }), +})); + +export const sessionsRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-postgres.ts b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-postgres.ts new file mode 100644 index 0000000000..22da05f95b --- /dev/null +++ b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-postgres.ts @@ -0,0 +1,105 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgTable, + pgTableCreator, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +export const createTable = pgTableCreator((name) => `pg-drizzle_${name}`); + +export const posts = createTable( + "post", + (d) => ({ + id: d.integer().primaryKey().generatedByDefaultAsIdentity(), + name: d.varchar({ length: 256 }), + createdById: d + .varchar({ length: 255 }) + .notNull() + .references(() => user.id), + createdAt: d + .timestamp({ withTimezone: true }) + .$defaultFn(() => new Date()) + .notNull(), + updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()), + }), + (t) => [ + index("created_by_idx").on(t.createdById), + index("name_idx").on(t.name), + ] +); + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified") + .$defaultFn(() => false) + .notNull(), + image: text("image"), + createdAt: timestamp("created_at") + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp("updated_at") + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").$defaultFn( + () => /* @__PURE__ */ new Date() + ), + updatedAt: timestamp("updated_at").$defaultFn( + () => /* @__PURE__ */ new Date() + ), +}); + +export const userRelations = relations(user, ({ many }) => ({ + account: many(account), + session: many(session), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { fields: [account.userId], references: [user.id] }), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); diff --git a/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-sqlite.ts b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-sqlite.ts new file mode 100644 index 0000000000..81855863cb --- /dev/null +++ b/cli/template/extras/src/server/db/schema-drizzle/with-better-auth-sqlite.ts @@ -0,0 +1,134 @@ +import { relations, sql } from "drizzle-orm"; +import { index, sqliteTable } from "drizzle-orm/sqlite-core"; + +/** + * Multi-project schema prefix helper + */ + +// Posts example table +export const posts = sqliteTable( + "post", + (d) => ({ + id: d.integer({ mode: "number" }).primaryKey({ autoIncrement: true }), + name: d.text({ length: 256 }), + createdById: d + .text({ length: 255 }) + .notNull() + .references(() => user.id), + createdAt: d + .integer({ mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()), + }), + (t) => [ + index("created_by_idx").on(t.createdById), + index("name_idx").on(t.name), + ] +); + +// Better Auth core tables +export const user = sqliteTable("user", (d) => ({ + id: d + .text({ length: 255 }) + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + name: d.text({ length: 255 }), + email: d.text({ length: 255 }).notNull().unique(), + emailVerified: d.integer({ mode: "boolean" }).default(false), + image: d.text({ length: 255 }), + createdAt: d + .integer({ mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()), +})); + +export const userRelations = relations(user, ({ many }) => ({ + account: many(account), + session: many(session), +})); + +export const account = sqliteTable( + "account", + (d) => ({ + id: d + .text({ length: 255 }) + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: d + .text({ length: 255 }) + .notNull() + .references(() => user.id), + accountId: d.text({ length: 255 }).notNull(), + providerId: d.text({ length: 255 }).notNull(), + accessToken: d.text(), + refreshToken: d.text(), + accessTokenExpiresAt: d.integer({ mode: "timestamp" }), + refreshTokenExpiresAt: d.integer({ mode: "timestamp" }), + scope: d.text({ length: 255 }), + idToken: d.text(), + password: d.text(), + createdAt: d + .integer({ mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()), + }), + (t) => [index("account_user_id_idx").on(t.userId)] +); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { fields: [account.userId], references: [user.id] }), +})); + +export const session = sqliteTable( + "session", + (d) => ({ + id: d + .text({ length: 255 }) + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: d + .text({ length: 255 }) + .notNull() + .references(() => user.id), + token: d.text({ length: 255 }).notNull().unique(), + expiresAt: d.integer({ mode: "timestamp" }).notNull(), + ipAddress: d.text({ length: 255 }), + userAgent: d.text({ length: 255 }), + createdAt: d + .integer({ mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()), + }), + (t) => [index("session_user_id_idx").on(t.userId)] +); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); + +export const verification = sqliteTable( + "verification", + (d) => ({ + id: d + .text({ length: 255 }) + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + identifier: d.text({ length: 255 }).notNull(), + value: d.text({ length: 255 }).notNull(), + expiresAt: d.integer({ mode: "timestamp" }).notNull(), + createdAt: d + .integer({ mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()), + }), + (t) => [index("verification_identifier_idx").on(t.identifier)] +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55cb80f08c..affd2e5cfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,12 +141,15 @@ importers: '@types/node': specifier: ^20.14.10 version: 20.14.10 + better-auth: + specifier: ^1.3 + version: 1.3.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.2) drizzle-kit: specifier: ^0.30.5 version: 0.30.5 drizzle-orm: specifier: ^0.41.0 - version: 0.41.0(@libsql/client@0.14.0)(@planetscale/database@1.19.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.8.2))(typescript@5.8.2))(gel@2.0.1)(mysql2@3.11.0)(postgres@3.4.4)(prisma@6.6.0(typescript@5.8.2)) + version: 0.41.0(@libsql/client@0.14.0)(@planetscale/database@1.19.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.8.2))(typescript@5.8.2))(gel@2.0.1)(kysely@0.28.5)(mysql2@3.11.0)(postgres@3.4.4)(prisma@6.6.0(typescript@5.8.2)) mysql2: specifier: ^3.11.0 version: 3.11.0 @@ -682,6 +685,12 @@ packages: resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==} engines: {node: '>=6.9.0'} + '@better-auth/utils@0.2.6': + resolution: {integrity: sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA==} + + '@better-fetch/fetch@1.1.18': + resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@changesets/apply-release-plan@7.0.1': resolution: {integrity: sha512-aPdSq/R++HOyfEeBGjEe6LNG8gs0KMSyRETD/J2092OkNq8mOioAxyKjMbvVUdzgr/HTawzMOz7lfw339KnsCA==} @@ -1314,6 +1323,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1493,6 +1505,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@libsql/client@0.14.0': resolution: {integrity: sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==} @@ -1632,6 +1647,13 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@0.6.0': + resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1653,6 +1675,21 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@peculiar/asn1-android@2.4.0': + resolution: {integrity: sha512-YFueREq97CLslZZBI8dKzis7jMfEHSLxM+nr0Zdx1POiXFLjqqwoY5s0F1UimdBiEw/iKlHey2m56MRDv7Jtyg==} + + '@peculiar/asn1-ecc@2.4.0': + resolution: {integrity: sha512-fJiYUBCJBDkjh347zZe5H81BdJ0+OGIg0X9z06v8xXUoql3MFeENUX0JsjCaVaU9A0L85PefLPGYkIoGpTnXLQ==} + + '@peculiar/asn1-rsa@2.4.0': + resolution: {integrity: sha512-6PP75voaEnOSlWR9sD25iCQyLgFZHXbmxvUfnnDcfL6Zh5h2iHW38+bve4LfH7a60x7fkhZZNmiYqAlAff9Img==} + + '@peculiar/asn1-schema@2.4.0': + resolution: {integrity: sha512-umbembjIWOrPSOzEGG5vxFLkeM8kzIhLkgigtsOrfLKnuzxWxejAcUX+q/SoZCdemlODOcr5WiYa7+dIEzBXZQ==} + + '@peculiar/asn1-x509@2.4.0': + resolution: {integrity: sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw==} + '@petamoriken/float16@3.9.1': resolution: {integrity: sha512-j+ejhYwY6PeB+v1kn7lZFACUIG97u90WxMuGosILFsl9d4Ovi0sjk0GlPfoEcx+FzvXZDAfioD+NGnnPamXgMA==} @@ -1945,6 +1982,13 @@ packages: engines: {node: '>= 8.0.0'} hasBin: true + '@simplewebauthn/browser@13.1.2': + resolution: {integrity: sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==} + + '@simplewebauthn/server@13.1.2': + resolution: {integrity: sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g==} + engines: {node: '>=20.0.0'} + '@sindresorhus/is@0.14.0': resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} engines: {node: '>=6'} @@ -2557,6 +2601,10 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + asn1js@3.0.6: + resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} + engines: {node: '>=12.0.0'} + astring@1.8.4: resolution: {integrity: sha512-97a+l2LBU3Op3bBQEff79i/E4jMD2ZLFD8rHx9B6mXyB2uQwhJQYfiDqUwtfjF4QA1F2qs//N6Cw8LetMbQjcw==} hasBin: true @@ -2604,6 +2652,21 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-auth@1.3.7: + resolution: {integrity: sha512-/1fEyx2SGgJQM5ujozDCh9eJksnVkNU/J7Fk/tG5Y390l8nKbrPvqiFlCjlMM+scR+UABJbQzA6An7HT50LHyQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + better-call@1.0.15: + resolution: {integrity: sha512-u4ZNRB1yBx5j3CltTEbY2ZoFPVcgsuvciAqTEmPvnZpZ483vlZf4LGJ5aVau1yMlrvlyHxOCica3OqXBLhmsUw==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -4180,6 +4243,9 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@5.3.0: resolution: {integrity: sha512-IChe9AtAE79ru084ow8jzkN2lNrG3Ntfiv65Cvj9uOCE2m5LNsdHG+9EbxWxAoWRF9TgDOqLN5jm08++owDVRg==} @@ -4268,6 +4334,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.28.5: + resolution: {integrity: sha512-rlB0I/c6FBDWPcQoDtkxi9zIvpmnV5xoIalfCMSMCa7nuA6VGA3F54TW9mEgX4DVf10sXAWCF5fDbamI/5ZpKA==} + engines: {node: '>=20.0.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4713,6 +4783,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@0.11.4: + resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==} + engines: {node: ^18.0.0 || >=20.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5264,6 +5338,13 @@ packages: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.3: + resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} + engines: {node: '>=6.0.0'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5501,6 +5582,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.5.1: + resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} + rspack-resolver@1.2.2: resolution: {integrity: sha512-Fwc19jMBA3g+fxDJH2B4WxwZjE0VaaOL7OX/A4Wn5Zv7bOD/vyPZhzXfaO73Xc2GAlfi96g5fGUa378WbIGfFw==} @@ -5579,6 +5663,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -7218,6 +7305,12 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@better-auth/utils@0.2.6': + dependencies: + uncrypto: 0.1.3 + + '@better-fetch/fetch@1.1.18': {} + '@changesets/apply-release-plan@7.0.1': dependencies: '@babel/runtime': 7.24.6 @@ -7757,6 +7850,8 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -7912,6 +8007,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@levischuck/tiny-cbor@0.2.11': {} + '@libsql/client@0.14.0': dependencies: '@libsql/core': 0.14.0 @@ -8097,6 +8194,10 @@ snapshots: '@next/swc-win32-x64-msvc@15.2.3': optional: true + '@noble/ciphers@0.6.0': {} + + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8115,6 +8216,39 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@peculiar/asn1-android@2.4.0': + dependencies: + '@peculiar/asn1-schema': 2.4.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.4.0': + dependencies: + '@peculiar/asn1-schema': 2.4.0 + '@peculiar/asn1-x509': 2.4.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.4.0': + dependencies: + '@peculiar/asn1-schema': 2.4.0 + '@peculiar/asn1-x509': 2.4.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.4.0': + dependencies: + asn1js: 3.0.6 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.4.0': + dependencies: + '@peculiar/asn1-schema': 2.4.0 + asn1js: 3.0.6 + pvtsutils: 1.3.6 + tslib: 2.8.1 + '@petamoriken/float16@3.9.1': {} '@pkgjs/parseargs@0.11.0': @@ -8367,6 +8501,18 @@ snapshots: fflate: 0.7.4 string.prototype.codepointat: 0.2.1 + '@simplewebauthn/browser@13.1.2': {} + + '@simplewebauthn/server@13.1.2': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.4.0 + '@peculiar/asn1-ecc': 2.4.0 + '@peculiar/asn1-rsa': 2.4.0 + '@peculiar/asn1-schema': 2.4.0 + '@peculiar/asn1-x509': 2.4.0 + '@sindresorhus/is@0.14.0': {} '@stackblitz/sdk@1.11.0': {} @@ -9007,6 +9153,12 @@ snapshots: arrify@1.0.1: {} + asn1js@3.0.6: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.3 + tslib: 2.8.1 + astring@1.8.4: {} astro@5.5.4(@planetscale/database@1.19.0)(@types/node@20.14.10)(jiti@2.4.2)(lightningcss@1.29.2)(rollup@4.36.0)(terser@5.31.1)(typescript@5.8.2)(yaml@2.7.0): @@ -9134,6 +9286,31 @@ snapshots: base64-js@1.5.1: {} + better-auth@1.3.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.2): + dependencies: + '@better-auth/utils': 0.2.6 + '@better-fetch/fetch': 1.1.18 + '@noble/ciphers': 0.6.0 + '@noble/hashes': 1.8.0 + '@simplewebauthn/browser': 13.1.2 + '@simplewebauthn/server': 13.1.2 + better-call: 1.0.15 + defu: 6.1.4 + jose: 5.10.0 + kysely: 0.28.5 + nanostores: 0.11.4 + zod: 3.24.2 + optionalDependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + + better-call@1.0.15: + dependencies: + '@better-fetch/fetch': 1.1.18 + rou3: 0.5.1 + set-cookie-parser: 2.7.1 + uncrypto: 0.1.3 + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -9599,12 +9776,13 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.41.0(@libsql/client@0.14.0)(@planetscale/database@1.19.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.8.2))(typescript@5.8.2))(gel@2.0.1)(mysql2@3.11.0)(postgres@3.4.4)(prisma@6.6.0(typescript@5.8.2)): + drizzle-orm@0.41.0(@libsql/client@0.14.0)(@planetscale/database@1.19.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.8.2))(typescript@5.8.2))(gel@2.0.1)(kysely@0.28.5)(mysql2@3.11.0)(postgres@3.4.4)(prisma@6.6.0(typescript@5.8.2)): optionalDependencies: '@libsql/client': 0.14.0 '@planetscale/database': 1.19.0 '@prisma/client': 6.6.0(prisma@6.6.0(typescript@5.8.2))(typescript@5.8.2) gel: 2.0.1 + kysely: 0.28.5 mysql2: 3.11.0 postgres: 3.4.4 prisma: 6.6.0(typescript@5.8.2) @@ -10976,6 +11154,8 @@ snapshots: jose@4.15.9: {} + jose@5.10.0: {} + jose@5.3.0: {} joycon@3.1.1: {} @@ -11046,6 +11226,8 @@ snapshots: kleur@4.1.5: {} + kysely@0.28.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -11737,6 +11919,8 @@ snapshots: nanoid@3.3.8: {} + nanostores@0.11.4: {} + natural-compare@1.4.0: {} neotraverse@0.6.18: {} @@ -12249,6 +12433,12 @@ snapshots: punycode@2.3.0: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.3: {} + queue-microtask@1.2.3: {} quick-lru@4.0.1: {} @@ -12623,6 +12813,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.36.0 fsevents: 2.3.3 + rou3@0.5.1: {} + rspack-resolver@1.2.2: optionalDependencies: '@unrs/rspack-resolver-binding-darwin-arm64': 1.2.2 @@ -12720,6 +12912,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f729765cee..33a9daf817 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,10 @@ packages: - - "cli" - - "www" + - cli + - www + +onlyBuiltDependencies: + - '@prisma/client' + - '@prisma/engines' + - esbuild + - prisma + - sharp diff --git a/www/src/components/landingPage/cli.tsx b/www/src/components/landingPage/cli.tsx index 2e484079d3..e5650fdaf0 100644 --- a/www/src/components/landingPage/cli.tsx +++ b/www/src/components/landingPage/cli.tsx @@ -118,6 +118,7 @@ export default function CodeCard() { > ❯ β—‰ nextAuth + β—‰ better-auth
 β—‰ prisma