Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
38ef7be
feat: better-auth
luoling8192 Dec 3, 2025
f3c9105
feat: login with google
luoling8192 Dec 4, 2025
76cdff6
feat: auth composable, structure, CORS allow *, correct package.json
luoling8192 Dec 4, 2025
8a6504b
feat: login page
luoling8192 Dec 5, 2025
96dc495
feat: jwt
luoling8192 Dec 5, 2025
a8bacfd
feat: header
luoling8192 Dec 6, 2025
a553cb7
chore(stage-web): auth page updated
nekomeowww Dec 11, 2025
60d9d27
feat: main page
Neko-233 Dec 15, 2025
3607606
feat: dockerfile
luoling8192 Dec 16, 2025
ae0b0b2
fix: ignore script
luoling8192 Dec 16, 2025
badf2ce
chore: move
luoling8192 Dec 16, 2025
32f5f82
feat: deploy
luoling8192 Dec 16, 2025
b91f16d
feat: remove pre deploy command
luoling8192 Dec 16, 2025
5bf748b
feat: remove cache
luoling8192 Dec 16, 2025
de45c38
chore: remove frozen-lockfile
luoling8192 Dec 16, 2025
1e51866
fix: docker file
luoling8192 Dec 16, 2025
b3eef77
feat: handle error
luoling8192 Dec 16, 2025
d875f2a
feat: api server url
luoling8192 Dec 16, 2025
d746b6e
chore: api url
luoling8192 Dec 16, 2025
8a2df3b
Merge branch 'main' into dev/better-auth
luoling8192 Dec 18, 2025
09f75a9
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 18, 2025
a6ad149
Merge branch 'main' into dev/better-auth
luoling8192 Dec 20, 2025
e84526b
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 20, 2025
cdeb4e4
feat: dynamic cors
luoling8192 Dec 20, 2025
8f360f9
Merge branch 'main' into dev/better-auth
luoling8192 Dec 22, 2025
e42c980
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 22, 2025
ef83e85
Merge branch '0.9.0' into dev/better-auth
luoling8192 Dec 24, 2025
4985d77
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 24, 2025
32d9d14
chore: bump logg to 1.2.11
luoling8192 Dec 24, 2025
698072d
chore: bump logg to 1.2.11
luoling8192 Dec 24, 2025
e28bb3f
Merge branch 'main' into dev/better-auth
luoling8192 Dec 24, 2025
3d301a4
fix: lockfile
luoling8192 Dec 24, 2025
3345084
chore: cleanup
luoling8192 Dec 24, 2025
a28bef1
chore: remove any
luoling8192 Dec 24, 2025
e848e9e
chore: cleanup
luoling8192 Dec 24, 2025
ff361f2
fix: state mismatch
luoling8192 Dec 24, 2025
9521f04
Revert "fix: state mismatch"
luoling8192 Dec 24, 2025
12595e4
Reapply "fix: state mismatch"
luoling8192 Dec 24, 2025
f176f1a
Apply suggestions from code review
luoling8192 Dec 25, 2025
234bc22
feat: injecta
luoling8192 Dec 25, 2025
7b78bb2
feat: valibot
luoling8192 Dec 25, 2025
6175a03
chore: better DX
luoling8192 Dec 25, 2025
64d07b0
chore: db migration schema
luoling8192 Dec 25, 2025
b7cf0cf
feat: regenerate
luoling8192 Dec 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/server/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DATABASE_URL=""

AUTH_GOOGLE_CLIENT_ID=""
AUTH_GOOGLE_CLIENT_SECRET=""

AUTH_GITHUB_CLIENT_ID=""
AUTH_GITHUB_CLIENT_SECRET=""
14 changes: 14 additions & 0 deletions apps/server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:24-alpine

WORKDIR /app

RUN corepack enable

COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/server apps/server

RUN pnpm install --frozen-lockfile --ignore-scripts

EXPOSE 3000

CMD ["pnpm", "-F", "@proj-airi/api-server", "start"]
33 changes: 33 additions & 0 deletions apps/server/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
version: '3.9'

services:
db:
image: postgres:16-alpine
container_name: airi-postgres
environment:
- POSTGRES_DB=airi
- POSTGRES_USER=airi
- POSTGRES_PASSWORD=airi
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 10
volumes:
- pgdata:/var/lib/postgresql/data

server:
build:
context: ../..
dockerfile: apps/server/Dockerfile
depends_on:
db:
condition: service_healthy
ports:
- '3000:3000'

volumes:
pgdata:
driver: local
10 changes: 10 additions & 0 deletions apps/server/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import process from 'node:process'

export default {
schema: './src/schemas/**/*.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL,
},
}
26 changes: 26 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@proj-airi/api-server",
"version": "0.0.1",
"private": true,
"scripts": {
"apply:env": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE",
"generate:auth": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/auth.ts -y",
"dev": "pnpm run apply:env -- tsx --watch src/app.ts",
"start": "tsx src/app.ts",
"push:db": "pnpm run apply:env -- drizzle-kit push"
},
"dependencies": {
"@dotenvx/dotenvx": "^1.51.1",
"@guiiai/logg": "catalog:",
"@hono/node-server": "^1.19.6",
"better-auth": "^1.4.5",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.7",
"postgres": "^3.4.7",
"tsx": "^4.21.0"
},
"devDependencies": {
"@better-auth/cli": "^1.4.5",
"drizzle-kit": "^0.31.7"
}
}
11 changes: 11 additions & 0 deletions apps/server/railway.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "apps/server/Dockerfile"
watchPatterns = [
"apps/server/**",
"packages/**",
"pnpm-lock.yaml"
]

[deploy]
startCommand = "pnpm -F @proj-airi/api-server start"
94 changes: 94 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import process from 'node:process'

import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger as honoLogger } from 'hono/logger'

import { createAuth } from './services/auth'
import { createDrizzle } from './services/db'
import { parseEnv } from './services/env'
import { getTrustedOrigin } from './utils/origin'

function createApp() {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)

const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null
session: typeof auth.$Infer.Session.session | null
}
}>()
const env = parseEnv(process.env)

const logger = useLogger('app').useGlobalConfig()
const db = createDrizzle(env.DATABASE_URL)
const auth = createAuth(db, env)

db.execute('SELECT 1')
.then(() => {
logger.log('Connected to database')
})
.catch((err) => {
logger.withError(err).error('Failed to connect to database')
process.exit(1)
})
Comment on lines +24 to +31
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference of using this other than await

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't block the loading of subsequent routes, although you could also use await if you want.


app.use(
'/api/auth/*', // or replace with "*" to enable cors for all routes
cors({
origin(origin: string) {
return getTrustedOrigin(origin)
},
Comment on lines +43 to +45
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add // NOTICE: marker to explain why, including those context we discussed before.

credentials: true,
}),
)

app.use(honoLogger())

app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })

if (!session) {
c.set('user', null)
c.set('session', null)
await next()
return
}

c.set('user', session.user)
c.set('session', session.session)
await next()
})

app.get('/session', (c) => {
const session = c.get('session')
const user = c.get('user')

if (!user)
return c.body(null, 401)

return c.json({
session,
user,
})
})

app.on(['POST', 'GET'], '/api/auth/*', (c) => {
return auth.handler(c.req.raw)
})

logger.withFields({ port: 3000 }).log('Server started')

return app
}

serve(createApp())

function handleError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}

process.on('uncaughtException', error => handleError(error, 'Uncaught exception'))
process.on('unhandledRejection', error => handleError(error, 'Unhandled rejection'))
101 changes: 101 additions & 0 deletions apps/server/src/schemas/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { relations } from 'drizzle-orm'
import { boolean, index, pgTable, text, timestamp } 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('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.$onUpdate(() => /* @__PURE__ */ new Date())
.$onUpdate(() => /* @__PURE__ */ new Date())

Don't do this.

.notNull(),
})

export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.$onUpdate(() => /* @__PURE__ */ new Date())
.$onUpdate(() => /* @__PURE__ */ new Date())

Remove this.

.notNull(),
Comment on lines +24 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The updatedAt column in the session table is missing .defaultNow(). This is inconsistent with the user and verification tables, and means the updatedAt field will be NOT NULL but have no default value on creation. The same issue exists for the account table on lines 53-55.

Suggested change
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),

ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
table => [index('session_userId_idx').on(table.userId)],
)

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').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('account_userId_idx').on(table.userId)],
)

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').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('verification_identifier_idx').on(table.identifier)],
)

export const jwks = pgTable('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at').notNull(),
expiresAt: timestamp('expires_at'),
})

export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}))

export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))

export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))
Comment on lines +76 to +93
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I learned!

8 changes: 8 additions & 0 deletions apps/server/src/scripts/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import process from 'node:process'

import { createAuth } from '../services/auth'
import { createDrizzle } from '../services/db'
import { parseEnv } from '../services/env'

const env = parseEnv(process.env)
export default createAuth(createDrizzle(env.DATABASE_URL), env)
52 changes: 52 additions & 0 deletions apps/server/src/services/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Database } from './db'
import type { Env } from './env'

import process from 'node:process'

import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { bearer } from 'better-auth/plugins'

import * as authSchema from '../schemas/auth'

export function createAuth(db: Database, env: Env) {
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
...authSchema,
},
}),

plugins: [
bearer(),
],

emailAndPassword: {
enabled: true,
},

baseURL: process.env.API_SERVER_URL || 'http://localhost:3000',
trustedOrigins: ['*'],

// To skip state-mismatch errors
// https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378
advanced: {
defaultCookieAttributes: {
sameSite: 'None', // this enables cross-site cookies
secure: true, // required for SameSite=None
},
},

socialProviders: {
google: {
clientId: env.AUTH_GOOGLE_CLIENT_ID,
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
},
github: {
clientId: env.AUTH_GITHUB_CLIENT_ID,
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
},
},
})
}
9 changes: 9 additions & 0 deletions apps/server/src/services/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import postgres from 'postgres'

import { drizzle } from 'drizzle-orm/postgres-js'

export type Database = ReturnType<typeof createDrizzle>

export function createDrizzle(dsn: string) {
return drizzle(postgres(dsn))
}
17 changes: 17 additions & 0 deletions apps/server/src/services/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface Env {
DATABASE_URL: string
AUTH_GOOGLE_CLIENT_ID: string
AUTH_GOOGLE_CLIENT_SECRET: string
AUTH_GITHUB_CLIENT_ID: string
AUTH_GITHUB_CLIENT_SECRET: string
}

export function parseEnv(env: any): Env {
return {
DATABASE_URL: env.DATABASE_URL,
AUTH_GOOGLE_CLIENT_ID: env.AUTH_GOOGLE_CLIENT_ID,
AUTH_GOOGLE_CLIENT_SECRET: env.AUTH_GOOGLE_CLIENT_SECRET,
AUTH_GITHUB_CLIENT_ID: env.AUTH_GITHUB_CLIENT_ID,
AUTH_GITHUB_CLIENT_SECRET: env.AUTH_GITHUB_CLIENT_SECRET,
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The parseEnv function doesn't validate the environment variables. If a required variable is missing, it will be undefined, leading to runtime errors that are hard to debug. You should add validation to ensure all required variables are present on startup. Using a library like zod is highly recommended for this. You would need to add zod as a dependency.

Suggested change
export function parseEnv(env: any): Env {
return {
DATABASE_URL: env.DATABASE_URL,
AUTH_GOOGLE_CLIENT_ID: env.AUTH_GOOGLE_CLIENT_ID,
AUTH_GOOGLE_CLIENT_SECRET: env.AUTH_GOOGLE_CLIENT_SECRET,
AUTH_GITHUB_CLIENT_ID: env.AUTH_GITHUB_CLIENT_ID,
AUTH_GITHUB_CLIENT_SECRET: env.AUTH_GITHUB_CLIENT_SECRET,
}
}
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
AUTH_GOOGLE_CLIENT_ID: z.string().min(1),
AUTH_GOOGLE_CLIENT_SECRET: z.string().min(1),
AUTH_GITHUB_CLIENT_ID: z.string().min(1),
AUTH_GITHUB_CLIENT_SECRET: z.string().min(1),
// Add other required env vars like BASE_URL, CORS_ORIGINS, etc.
})
export function parseEnv(env: any): Env {
return envSchema.parse(env)
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow this.

Loading