diff --git a/.env.dist b/.env.dist index 9c4f729..056a6d9 100644 --- a/.env.dist +++ b/.env.dist @@ -3,4 +3,6 @@ DB_HOST="localhost" DB_PORT="5432" DB_USER="postgres" DB_PASSWORD="xxxxxx" -DB_NAME="example" \ No newline at end of file +DB_NAME="example" +FRONTEND_URL="http://localhost:3001" +BETTER_AUTH_SECRET=RANDOM_STRING (check https://www.better-auth.com/docs/installation) diff --git a/README.md b/README.md index c0df61f..06ff976 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ - [x] App based folder structure - [x] Postgres database support (drizzle) - [x] Serve static files +- [x] Better auth integration +- [x] CORS support ## Getting Started To get started with this template, simply paste this command into your terminal: diff --git a/bun.lockb b/bun.lockb index 68adb4a..58c7045 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database_migrations/0001_thankful_mathemanic.sql b/database_migrations/0001_thankful_mathemanic.sql new file mode 100644 index 0000000..9454b44 --- /dev/null +++ b/database_migrations/0001_thankful_mathemanic.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS "account" ( + "id" text PRIMARY KEY NOT NULL, + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "expiresAt" timestamp, + "password" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "session" ( + "id" text PRIMARY KEY NOT NULL, + "expiresAt" timestamp NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "emailVerified" boolean NOT NULL, + "image" text, + "createdAt" timestamp NOT NULL, + "updatedAt" timestamp NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expiresAt" timestamp NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/database_migrations/meta/0001_snapshot.json b/database_migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..e218592 --- /dev/null +++ b/database_migrations/meta/0001_snapshot.json @@ -0,0 +1,286 @@ +{ + "id": "56ce94aa-a168-4104-ba96-f7e2973fad5c", + "prevId": "8164177c-dd44-4165-b734-fc0acaa9171b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.animals": { + "name": "animals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "science_name": { + "name": "science_name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_idx": { + "name": "name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/database_migrations/meta/_journal.json b/database_migrations/meta/_journal.json index 754aad8..3f21571 100644 --- a/database_migrations/meta/_journal.json +++ b/database_migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1725031182851, "tag": "0000_mysterious_magma", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1728741980064, + "tag": "0001_thankful_mathemanic", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index dc4db02..480b749 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - schema: './src/**/models.ts', + schema: [ + './src/**/models.ts', + './src/**/models/*.ts' + ], out: './database_migrations', dialect: 'postgresql', dbCredentials: { diff --git a/package.json b/package.json index 5660d0a..e0ac60b 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "dev": "bun run --watch src/server.ts", "build": "bun build --minify --target bun --outdir dist src/index.ts", "compile": "bun build --compile --minify --target bun --outfile server src/index.ts", + "better-auth_generate": "npx better-auth generate --output=src/auth/models/better_auth.ts", "makemigrations": "npx drizzle-kit generate", "migrate": "npx drizzle-kit migrate" }, "dependencies": { + "@elysiajs/cors": "^1.1.1", "@elysiajs/static": "^1.1.1", - "@elysiajs/swagger": "^1.1.1", + "@elysiajs/swagger": "^1.1.5", + "better-auth": "^0.4.2", "drizzle-orm": "^0.33.0", "elysia": "latest", "postgres": "^3.4.4" diff --git a/src/auth/models/better_auth.ts b/src/auth/models/better_auth.ts new file mode 100644 index 0000000..2dcb966 --- /dev/null +++ b/src/auth/models/better_auth.ts @@ -0,0 +1,38 @@ +import { pgTable, text, integer, timestamp, boolean } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('emailVerified').notNull(), + image: text('image'), + createdAt: timestamp('createdAt').notNull(), + updatedAt: timestamp('updatedAt').notNull() + }); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp('expiresAt').notNull(), + ipAddress: text('ipAddress'), + userAgent: text('userAgent'), + userId: text('userId').notNull().references(()=> user.id) + }); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text('accountId').notNull(), + providerId: text('providerId').notNull(), + userId: text('userId').notNull().references(()=> user.id), + accessToken: text('accessToken'), + refreshToken: text('refreshToken'), + idToken: text('idToken'), + expiresAt: timestamp('expiresAt'), + password: text('password') + }); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expiresAt').notNull() + }); diff --git a/src/auth/routers.ts b/src/auth/routers.ts new file mode 100644 index 0000000..740fc8c --- /dev/null +++ b/src/auth/routers.ts @@ -0,0 +1,8 @@ +import Elysia, { t } from "elysia" +import views from "./views" + +const appPrefix = "/api/auth" +const app = new Elysia({ prefix: appPrefix }) + .all("/*", views.betterAuthView) + +export default app \ No newline at end of file diff --git a/src/auth/views.ts b/src/auth/views.ts new file mode 100644 index 0000000..33af6bc --- /dev/null +++ b/src/auth/views.ts @@ -0,0 +1,24 @@ +import { Context } from "elysia" +import { auth } from "../lib/auth"; + +const betterAuthView = (context: Context) => { + const BETTER_AUTH_ACCEPT_METHODS = ["POST", "GET"] + // validate request method + if(BETTER_AUTH_ACCEPT_METHODS.includes(context.request.method)) { + try { + console.log(context.request.url) + return auth.handler(context.request); + } + catch(error){ + console.log(error) + } + } + else { + context.error(405) + } +} + +// export application views +export default { + betterAuthView +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 92b9747..616574d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,10 @@ export const DB_PORT = parseInt(process.env.DB_PORT || "5432") export const DB_USER = process.env.DB_USER export const DB_PASSWORD = process.env.DB_PASSWORD export const DB_NAME = process.env.DB_NAME +export const DB_PROVIDER = "pg" + +// CORS domain +export const FRONTEND_URL = process.env.FRONTEND_URL || false // Swagger configuration export const SWAGGER_PATH = "/documents" \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..8500fb2 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,20 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { db } from "../core/database"; // your drizzle instance +import { DB_PROVIDER } from "../config"; +import { account, session, user, verification } from "../auth/models/better_auth"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: DB_PROVIDER, + schema: { + user: user, + session: session, + account: account, + verification: verification + } + }), + emailAndPassword: { + enabled: true + }, +}); \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 6b338b0..ac23542 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,10 @@ import { Elysia } from "elysia"; import staticPlugin from "@elysiajs/static"; import { swagger } from '@elysiajs/swagger' -import { APP_PORT, SWAGGER_PATH } from "./config"; +import { APP_PORT, FRONTEND_URL, SWAGGER_PATH } from "./config"; import exampleApp from "./example_app/routers"; +import cors from "@elysiajs/cors"; +import authApp from "./auth/routers"; const app = new Elysia() // static plugin @@ -12,7 +14,13 @@ const app = new Elysia() .use(swagger({ path: SWAGGER_PATH })) + // cors + .use(cors({ + origin: FRONTEND_URL + })) + // load apps .use(exampleApp) + .use(authApp) .listen(APP_PORT); console.log(