@kevinmarrec/create-app 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +11 -11
  3. package/template/backend/.env.development +3 -1
  4. package/template/backend/package.json +9 -8
  5. package/template/backend/src/auth/index.ts +23 -0
  6. package/template/backend/src/core/builder.ts +14 -0
  7. package/template/backend/src/core/context.ts +10 -0
  8. package/template/backend/src/core/index.ts +33 -0
  9. package/template/backend/src/core/middlewares/auth.ts +37 -0
  10. package/template/backend/src/core/router/auth.ts +55 -0
  11. package/template/backend/src/{router → core/router}/index.ts +2 -2
  12. package/template/backend/src/database/drizzle.config.ts +9 -0
  13. package/template/backend/src/database/index.ts +4 -4
  14. package/template/backend/src/database/migrations/0000_violet_enchantress.sql +49 -0
  15. package/template/backend/src/database/migrations/meta/0000_snapshot.json +292 -7
  16. package/template/backend/src/database/migrations/meta/_journal.json +2 -2
  17. package/template/backend/src/database/schema/accounts.ts +20 -0
  18. package/template/backend/src/database/schema/index.ts +3 -0
  19. package/template/backend/src/database/schema/sessions.ts +15 -0
  20. package/template/backend/src/database/schema/users.ts +7 -3
  21. package/template/backend/src/database/schema/verifications.ts +11 -0
  22. package/template/backend/{env.d.ts → src/env.d.ts} +2 -0
  23. package/template/backend/src/{server.ts → main.ts} +10 -18
  24. package/template/backend/src/utils/headers.ts +7 -0
  25. package/template/backend/src/{logger.ts → utils/logger.ts} +1 -3
  26. package/template/frontend/package.json +12 -13
  27. package/template/frontend/src/App.vue +96 -10
  28. package/template/frontend/src/composables/auth.ts +31 -0
  29. package/template/frontend/src/composables/index.ts +1 -0
  30. package/template/frontend/src/lib/orpc.ts +6 -1
  31. package/template/knip.config.ts +1 -1
  32. package/template/package.json +9 -9
  33. package/template/backend/src/config/database.ts +0 -11
  34. package/template/backend/src/config/drizzle.ts +0 -10
  35. package/template/backend/src/config/logger.ts +0 -19
  36. package/template/backend/src/config/server.ts +0 -40
  37. package/template/backend/src/database/migrations/0000_white_bishop.sql +0 -6
  38. package/template/backend/src/lib/orpc.ts +0 -10
  39. package/template/backend/src/router/welcome.ts +0 -11
  40. package/template/frontend/src/queries/index.ts +0 -1
  41. package/template/frontend/src/queries/welcome.ts +0 -10
  42. package/template/frontend/src/utils.ts +0 -1
  43. /package/template/frontend/{env.d.ts → src/env.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { x } from "tinyexec";
8
8
  import fs from "node:fs/promises";
9
9
 
10
10
  //#region package.json
11
- var version = "0.3.0";
11
+ var version = "0.5.0";
12
12
 
13
13
  //#endregion
14
14
  //#region src/utils/fs.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kevinmarrec/create-app",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.5.0",
5
5
  "description": "CLI that scaffolds an opinionated Bun & Vue fullstack application.",
6
6
  "author": "Kevin Marrec <kevin@marrec.io>",
7
7
  "license": "MIT",
@@ -49,19 +49,19 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@faker-js/faker": "^10.0.0",
52
- "@kevinmarrec/eslint-config": "^1.0.0",
53
- "@kevinmarrec/stylelint-config": "^1.0.0",
54
- "@kevinmarrec/tsconfig": "^1.0.0",
55
- "@types/bun": "^1.2.21",
52
+ "@kevinmarrec/eslint-config": "^1.4.0",
53
+ "@kevinmarrec/stylelint-config": "^1.2.0",
54
+ "@kevinmarrec/tsconfig": "^1.1.0",
55
+ "@types/bun": "^1.2.22",
56
56
  "@vitest/coverage-v8": "^3.2.4",
57
57
  "bumpp": "^10.2.3",
58
- "eslint": "^9.34.0",
59
- "knip": "^5.63.0",
60
- "stylelint": "^16.23.1",
61
- "taze": "^19.3.0",
62
- "tsdown": "^0.14.2",
58
+ "eslint": "^9.36.0",
59
+ "knip": "^5.63.1",
60
+ "stylelint": "^16.24.0",
61
+ "taze": "^19.7.0",
62
+ "tsdown": "^0.15.2",
63
63
  "typescript": "^5.9.2",
64
64
  "vitest": "^3.2.4",
65
- "vue-tsc": "^3.0.6"
65
+ "vue-tsc": "^3.0.7"
66
66
  }
67
67
  }
@@ -1,2 +1,4 @@
1
- NODE_ENV=development
1
+ BETTER_AUTH_SECRET=foo
2
+ BETTER_AUTH_URL=http://localhost:5137
2
3
  DATABASE_URL=dev.db
4
+ NODE_ENV=development
@@ -3,20 +3,21 @@
3
3
  "type": "module",
4
4
  "private": true,
5
5
  "scripts": {
6
- "dev": "bun --watch --no-clear-screen src/server.ts",
7
- "build": "bun build src/server.ts --compile --minify --sourcemap --outfile dist/server",
8
- "db:generate": "bun --bun run drizzle-kit generate --config src/config/drizzle.ts",
9
- "db:migrate": "bun --bun run drizzle-kit migrate --config src/config/drizzle.ts"
6
+ "dev": "bun --watch --no-clear-screen src/main.ts",
7
+ "build": "bun build src/main.ts --compile --minify --sourcemap --outfile dist/server",
8
+ "db:generate": "bun --bun run drizzle-kit generate --config src/database/drizzle.config.ts",
9
+ "db:migrate": "bun --bun run drizzle-kit migrate --config src/database/drizzle.config.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@orpc/server": "^1.8.5",
12
+ "@orpc/server": "^1.8.9",
13
+ "better-auth": "^1.3.12",
13
14
  "drizzle-orm": "^0.44.5",
14
- "pino": "^9.9.0",
15
+ "pino": "^9.10.0",
15
16
  "valibot": "^1.1.0"
16
17
  },
17
18
  "devDependencies": {
18
- "@libsql/client": "^0.15.14",
19
- "@types/bun": "^1.2.21",
19
+ "@libsql/client": "^0.15.15",
20
+ "@types/bun": "^1.2.22",
20
21
  "drizzle-kit": "^0.31.4",
21
22
  "pino-pretty": "^13.1.1"
22
23
  }
@@ -0,0 +1,23 @@
1
+ import { betterAuth } from 'better-auth'
2
+ import { type DB, drizzleAdapter } from 'better-auth/adapters/drizzle'
3
+ import type { BaseLogger } from 'pino'
4
+
5
+ export function createBetterAuth({ db, logger }: { db: DB, logger: BaseLogger }) {
6
+ return betterAuth({
7
+ database: drizzleAdapter(db, {
8
+ provider: 'sqlite',
9
+ usePlural: true,
10
+ }),
11
+ logger: {
12
+ log: (level, message) => {
13
+ logger[level](message)
14
+ },
15
+ },
16
+ emailAndPassword: {
17
+ enabled: true,
18
+ },
19
+ })
20
+ }
21
+
22
+ export type Auth = ReturnType<typeof createBetterAuth>
23
+ export type Session = Auth['$Infer']['Session']
@@ -0,0 +1,14 @@
1
+ import { os } from '@orpc/server'
2
+
3
+ import type { Context } from './context'
4
+ import { requiredAuthMiddleware } from './middlewares/auth'
5
+
6
+ export const pub = os
7
+ .$context<Context>()
8
+ .errors({
9
+ UNAUTHORIZED: { status: 401 },
10
+ })
11
+
12
+ /** @beta */
13
+ export const authed = pub
14
+ .use(requiredAuthMiddleware)
@@ -0,0 +1,10 @@
1
+ import type { Auth } from '@backend/auth'
2
+ import type { Database } from '@backend/database'
3
+ import type { Logger } from '@backend/utils/logger'
4
+ import type { RequestHeadersPluginContext, ResponseHeadersPluginContext } from '@orpc/server/plugins'
5
+
6
+ export interface Context extends RequestHeadersPluginContext, ResponseHeadersPluginContext {
7
+ auth: Auth
8
+ db: Database
9
+ logger: Logger
10
+ }
@@ -0,0 +1,33 @@
1
+ import { onError, ORPCError } from '@orpc/server'
2
+ import { RPCHandler } from '@orpc/server/fetch'
3
+ import { CORSPlugin, RequestHeadersPlugin, ResponseHeadersPlugin } from '@orpc/server/plugins'
4
+ import { APIError } from 'better-auth/api'
5
+
6
+ import { router } from './router'
7
+
8
+ export const rpcHandler = new RPCHandler(router, {
9
+ plugins: [
10
+ new CORSPlugin({
11
+ credentials: true,
12
+ maxAge: 7200, // 2 hours https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
13
+ }),
14
+ new RequestHeadersPlugin(),
15
+ new ResponseHeadersPlugin(),
16
+ ],
17
+ clientInterceptors: [
18
+ onError((error, { context }) => {
19
+ if (error instanceof APIError) {
20
+ throw new ORPCError(error.body?.code ?? 'INTERNAL_SERVER_ERROR', {
21
+ status: error.statusCode,
22
+ message: error.body?.message,
23
+ })
24
+ }
25
+
26
+ if (error instanceof ORPCError) {
27
+ throw error
28
+ }
29
+
30
+ context.logger.error(error)
31
+ }),
32
+ ],
33
+ })
@@ -0,0 +1,37 @@
1
+ import type { Session } from '@backend/auth'
2
+ import type { Context } from '@backend/core/context'
3
+ import { copyHeaders } from '@backend/utils/headers'
4
+ import { ORPCError, os } from '@orpc/server'
5
+
6
+ export const authMiddleware = os
7
+ .$context<Context>()
8
+ .middleware(async ({ context, next }) => {
9
+ const response = await context.auth.api.getSession({
10
+ headers: context.reqHeaders ?? new Headers(),
11
+ asResponse: true,
12
+ })
13
+
14
+ const session = await response.json() as Session | null
15
+
16
+ copyHeaders(response.headers, context.resHeaders)
17
+
18
+ return next({
19
+ context: {
20
+ user: session ? session.user : null,
21
+ },
22
+ })
23
+ })
24
+
25
+ export const requiredAuthMiddleware = authMiddleware
26
+ .concat(async ({ context, next }) => {
27
+ if (!context.user) {
28
+ throw new ORPCError('UNAUTHORIZED')
29
+ }
30
+
31
+ return next({
32
+ context: {
33
+ user: context.user,
34
+ },
35
+ })
36
+ },
37
+ )
@@ -0,0 +1,55 @@
1
+ import { pub } from '@backend/core/builder'
2
+ import { authMiddleware } from '@backend/core/middlewares/auth'
3
+ import { copyHeaders } from '@backend/utils/headers'
4
+ import * as v from 'valibot'
5
+
6
+ export const getCurrentUser = pub
7
+ .use(authMiddleware)
8
+ .handler(async ({ context }) => context.user)
9
+
10
+ export const signUp = pub
11
+ .input(v.object({
12
+ name: v.string(),
13
+ email: v.pipe(v.string(), v.email()),
14
+ password: v.string(),
15
+ rememberMe: v.optional(v.boolean(), true),
16
+ }))
17
+ .handler(async ({ input, context: { resHeaders, auth } }) => {
18
+ const { headers, response } = await auth.api.signUpEmail({
19
+ body: input,
20
+ returnHeaders: true,
21
+ })
22
+
23
+ copyHeaders(headers, resHeaders)
24
+
25
+ return response.user
26
+ })
27
+
28
+ export const signIn = pub
29
+ .input(v.object({
30
+ email: v.pipe(v.string(), v.email()),
31
+ password: v.string(),
32
+ rememberMe: v.optional(v.boolean(), true),
33
+ }))
34
+ .handler(async ({ input, context: { resHeaders, auth } }) => {
35
+ const { headers, response } = await auth.api.signInEmail({
36
+ body: input,
37
+ returnHeaders: true,
38
+ })
39
+
40
+ copyHeaders(headers, resHeaders)
41
+
42
+ return response.user
43
+ })
44
+
45
+ export const signOut = pub
46
+ .handler(async ({ context: { auth, reqHeaders, resHeaders } }) => {
47
+ const { headers, response } = await auth.api.signOut({
48
+ headers: reqHeaders ?? new Headers(),
49
+ returnHeaders: true,
50
+ })
51
+
52
+ copyHeaders(headers, resHeaders)
53
+
54
+ return response
55
+ })
@@ -1,9 +1,9 @@
1
- import { welcome } from './welcome'
1
+ import * as auth from './auth'
2
2
 
3
3
  export type { RouterClient } from '@orpc/server'
4
4
 
5
5
  export const router = {
6
- welcome,
6
+ auth,
7
7
  }
8
8
 
9
9
  export type Router = typeof router
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'drizzle-kit'
2
+
3
+ export default defineConfig({
4
+ casing: 'snake_case',
5
+ dialect: 'sqlite',
6
+ dbCredentials: { url: import.meta.env.DATABASE_URL },
7
+ schema: 'src/database/schema',
8
+ out: 'src/database/migrations',
9
+ })
@@ -1,16 +1,16 @@
1
- import { url } from '@backend/config/database'
2
- import { logger } from '@backend/logger'
1
+ import { logger } from '@backend/utils/logger'
3
2
  import { drizzle } from 'drizzle-orm/bun-sqlite'
4
3
 
5
4
  import * as schema from './schema'
6
5
 
7
- export const db = drizzle(url, {
6
+ export const db = drizzle(import.meta.env.DATABASE_URL, {
7
+ casing: 'snake_case',
8
8
  schema,
9
9
  logger: {
10
10
  logQuery: (query, params) => {
11
11
  let msg = `[SQL] ${query}`
12
12
  msg += params.length ? ` [${params}]` : ''
13
- logger.info(msg)
13
+ logger.debug(msg)
14
14
  },
15
15
  },
16
16
  })
@@ -0,0 +1,49 @@
1
+ CREATE TABLE `accounts` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `user_id` text NOT NULL,
4
+ `account_id` text NOT NULL,
5
+ `provider_id` text NOT NULL,
6
+ `id_token` text,
7
+ `access_token` text,
8
+ `access_token_expires_at` integer,
9
+ `refresh_token` text,
10
+ `refresh_token_expires_at` integer,
11
+ `scope` text,
12
+ `password` text,
13
+ `created_at` integer NOT NULL,
14
+ `updated_at` integer NOT NULL,
15
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE TABLE `sessions` (
19
+ `id` text PRIMARY KEY NOT NULL,
20
+ `user_id` text NOT NULL,
21
+ `token` text NOT NULL,
22
+ `expires_at` integer NOT NULL,
23
+ `ip_address` text,
24
+ `user_agent` text,
25
+ `created_at` integer NOT NULL,
26
+ `updated_at` integer NOT NULL,
27
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
28
+ );
29
+ --> statement-breakpoint
30
+ CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint
31
+ CREATE TABLE `users` (
32
+ `id` text PRIMARY KEY NOT NULL,
33
+ `name` text NOT NULL,
34
+ `email` text NOT NULL,
35
+ `email_verified` integer DEFAULT false NOT NULL,
36
+ `image` text,
37
+ `created_at` integer NOT NULL,
38
+ `updated_at` integer NOT NULL
39
+ );
40
+ --> statement-breakpoint
41
+ CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
42
+ CREATE TABLE `verifications` (
43
+ `id` text PRIMARY KEY NOT NULL,
44
+ `identifier` text NOT NULL,
45
+ `value` text NOT NULL,
46
+ `expires_at` integer NOT NULL,
47
+ `created_at` integer NOT NULL,
48
+ `updated_at` integer NOT NULL
49
+ );
@@ -1,18 +1,221 @@
1
1
  {
2
2
  "version": "6",
3
3
  "dialect": "sqlite",
4
- "id": "2570ada9-1704-45ba-b294-429e89874a79",
4
+ "id": "28ff26ce-b417-459e-b30f-4ec14ab62876",
5
5
  "prevId": "00000000-0000-0000-0000-000000000000",
6
6
  "tables": {
7
+ "accounts": {
8
+ "name": "accounts",
9
+ "columns": {
10
+ "id": {
11
+ "name": "id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "user_id": {
18
+ "name": "user_id",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "account_id": {
25
+ "name": "account_id",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "provider_id": {
32
+ "name": "provider_id",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ },
38
+ "id_token": {
39
+ "name": "id_token",
40
+ "type": "text",
41
+ "primaryKey": false,
42
+ "notNull": false,
43
+ "autoincrement": false
44
+ },
45
+ "access_token": {
46
+ "name": "access_token",
47
+ "type": "text",
48
+ "primaryKey": false,
49
+ "notNull": false,
50
+ "autoincrement": false
51
+ },
52
+ "access_token_expires_at": {
53
+ "name": "access_token_expires_at",
54
+ "type": "integer",
55
+ "primaryKey": false,
56
+ "notNull": false,
57
+ "autoincrement": false
58
+ },
59
+ "refresh_token": {
60
+ "name": "refresh_token",
61
+ "type": "text",
62
+ "primaryKey": false,
63
+ "notNull": false,
64
+ "autoincrement": false
65
+ },
66
+ "refresh_token_expires_at": {
67
+ "name": "refresh_token_expires_at",
68
+ "type": "integer",
69
+ "primaryKey": false,
70
+ "notNull": false,
71
+ "autoincrement": false
72
+ },
73
+ "scope": {
74
+ "name": "scope",
75
+ "type": "text",
76
+ "primaryKey": false,
77
+ "notNull": false,
78
+ "autoincrement": false
79
+ },
80
+ "password": {
81
+ "name": "password",
82
+ "type": "text",
83
+ "primaryKey": false,
84
+ "notNull": false,
85
+ "autoincrement": false
86
+ },
87
+ "created_at": {
88
+ "name": "created_at",
89
+ "type": "integer",
90
+ "primaryKey": false,
91
+ "notNull": true,
92
+ "autoincrement": false
93
+ },
94
+ "updated_at": {
95
+ "name": "updated_at",
96
+ "type": "integer",
97
+ "primaryKey": false,
98
+ "notNull": true,
99
+ "autoincrement": false
100
+ }
101
+ },
102
+ "indexes": {},
103
+ "foreignKeys": {
104
+ "accounts_user_id_users_id_fk": {
105
+ "name": "accounts_user_id_users_id_fk",
106
+ "tableFrom": "accounts",
107
+ "tableTo": "users",
108
+ "columnsFrom": [
109
+ "user_id"
110
+ ],
111
+ "columnsTo": [
112
+ "id"
113
+ ],
114
+ "onDelete": "cascade",
115
+ "onUpdate": "no action"
116
+ }
117
+ },
118
+ "compositePrimaryKeys": {},
119
+ "uniqueConstraints": {},
120
+ "checkConstraints": {}
121
+ },
122
+ "sessions": {
123
+ "name": "sessions",
124
+ "columns": {
125
+ "id": {
126
+ "name": "id",
127
+ "type": "text",
128
+ "primaryKey": true,
129
+ "notNull": true,
130
+ "autoincrement": false
131
+ },
132
+ "user_id": {
133
+ "name": "user_id",
134
+ "type": "text",
135
+ "primaryKey": false,
136
+ "notNull": true,
137
+ "autoincrement": false
138
+ },
139
+ "token": {
140
+ "name": "token",
141
+ "type": "text",
142
+ "primaryKey": false,
143
+ "notNull": true,
144
+ "autoincrement": false
145
+ },
146
+ "expires_at": {
147
+ "name": "expires_at",
148
+ "type": "integer",
149
+ "primaryKey": false,
150
+ "notNull": true,
151
+ "autoincrement": false
152
+ },
153
+ "ip_address": {
154
+ "name": "ip_address",
155
+ "type": "text",
156
+ "primaryKey": false,
157
+ "notNull": false,
158
+ "autoincrement": false
159
+ },
160
+ "user_agent": {
161
+ "name": "user_agent",
162
+ "type": "text",
163
+ "primaryKey": false,
164
+ "notNull": false,
165
+ "autoincrement": false
166
+ },
167
+ "created_at": {
168
+ "name": "created_at",
169
+ "type": "integer",
170
+ "primaryKey": false,
171
+ "notNull": true,
172
+ "autoincrement": false
173
+ },
174
+ "updated_at": {
175
+ "name": "updated_at",
176
+ "type": "integer",
177
+ "primaryKey": false,
178
+ "notNull": true,
179
+ "autoincrement": false
180
+ }
181
+ },
182
+ "indexes": {
183
+ "sessions_token_unique": {
184
+ "name": "sessions_token_unique",
185
+ "columns": [
186
+ "token"
187
+ ],
188
+ "isUnique": true
189
+ }
190
+ },
191
+ "foreignKeys": {
192
+ "sessions_user_id_users_id_fk": {
193
+ "name": "sessions_user_id_users_id_fk",
194
+ "tableFrom": "sessions",
195
+ "tableTo": "users",
196
+ "columnsFrom": [
197
+ "user_id"
198
+ ],
199
+ "columnsTo": [
200
+ "id"
201
+ ],
202
+ "onDelete": "cascade",
203
+ "onUpdate": "no action"
204
+ }
205
+ },
206
+ "compositePrimaryKeys": {},
207
+ "uniqueConstraints": {},
208
+ "checkConstraints": {}
209
+ },
7
210
  "users": {
8
211
  "name": "users",
9
212
  "columns": {
10
213
  "id": {
11
214
  "name": "id",
12
- "type": "integer",
215
+ "type": "text",
13
216
  "primaryKey": true,
14
217
  "notNull": true,
15
- "autoincrement": true
218
+ "autoincrement": false
16
219
  },
17
220
  "name": {
18
221
  "name": "name",
@@ -21,15 +224,97 @@
21
224
  "notNull": true,
22
225
  "autoincrement": false
23
226
  },
24
- "createdAt": {
25
- "name": "createdAt",
227
+ "email": {
228
+ "name": "email",
229
+ "type": "text",
230
+ "primaryKey": false,
231
+ "notNull": true,
232
+ "autoincrement": false
233
+ },
234
+ "email_verified": {
235
+ "name": "email_verified",
236
+ "type": "integer",
237
+ "primaryKey": false,
238
+ "notNull": true,
239
+ "autoincrement": false,
240
+ "default": false
241
+ },
242
+ "image": {
243
+ "name": "image",
244
+ "type": "text",
245
+ "primaryKey": false,
246
+ "notNull": false,
247
+ "autoincrement": false
248
+ },
249
+ "created_at": {
250
+ "name": "created_at",
251
+ "type": "integer",
252
+ "primaryKey": false,
253
+ "notNull": true,
254
+ "autoincrement": false
255
+ },
256
+ "updated_at": {
257
+ "name": "updated_at",
258
+ "type": "integer",
259
+ "primaryKey": false,
260
+ "notNull": true,
261
+ "autoincrement": false
262
+ }
263
+ },
264
+ "indexes": {
265
+ "users_email_unique": {
266
+ "name": "users_email_unique",
267
+ "columns": [
268
+ "email"
269
+ ],
270
+ "isUnique": true
271
+ }
272
+ },
273
+ "foreignKeys": {},
274
+ "compositePrimaryKeys": {},
275
+ "uniqueConstraints": {},
276
+ "checkConstraints": {}
277
+ },
278
+ "verifications": {
279
+ "name": "verifications",
280
+ "columns": {
281
+ "id": {
282
+ "name": "id",
283
+ "type": "text",
284
+ "primaryKey": true,
285
+ "notNull": true,
286
+ "autoincrement": false
287
+ },
288
+ "identifier": {
289
+ "name": "identifier",
290
+ "type": "text",
291
+ "primaryKey": false,
292
+ "notNull": true,
293
+ "autoincrement": false
294
+ },
295
+ "value": {
296
+ "name": "value",
297
+ "type": "text",
298
+ "primaryKey": false,
299
+ "notNull": true,
300
+ "autoincrement": false
301
+ },
302
+ "expires_at": {
303
+ "name": "expires_at",
304
+ "type": "integer",
305
+ "primaryKey": false,
306
+ "notNull": true,
307
+ "autoincrement": false
308
+ },
309
+ "created_at": {
310
+ "name": "created_at",
26
311
  "type": "integer",
27
312
  "primaryKey": false,
28
313
  "notNull": true,
29
314
  "autoincrement": false
30
315
  },
31
- "updatedAt": {
32
- "name": "updatedAt",
316
+ "updated_at": {
317
+ "name": "updated_at",
33
318
  "type": "integer",
34
319
  "primaryKey": false,
35
320
  "notNull": true,
@@ -5,8 +5,8 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1751572351628,
9
- "tag": "0000_white_bishop",
8
+ "when": 1756502742132,
9
+ "tag": "0000_violet_enchantress",
10
10
  "breakpoints": true
11
11
  }
12
12
  ]
@@ -0,0 +1,20 @@
1
+ import { sqliteTable } from 'drizzle-orm/sqlite-core'
2
+
3
+ import { users } from './users'
4
+
5
+ // https://www.better-auth.com/docs/concepts/database#account
6
+ export const accounts = sqliteTable('accounts', t => ({
7
+ id: t.text().primaryKey(),
8
+ userId: t.text().notNull().references(() => users.id, { onDelete: 'cascade' }),
9
+ accountId: t.text().notNull(),
10
+ providerId: t.text().notNull(),
11
+ idToken: t.text(),
12
+ accessToken: t.text(),
13
+ accessTokenExpiresAt: t.integer({ mode: 'timestamp' }),
14
+ refreshToken: t.text(),
15
+ refreshTokenExpiresAt: t.integer({ mode: 'timestamp' }),
16
+ scope: t.text(),
17
+ password: t.text(),
18
+ createdAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
19
+ updatedAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()).$onUpdate(() => new Date()),
20
+ }))
@@ -1 +1,4 @@
1
+ export * from './accounts'
2
+ export * from './sessions'
1
3
  export * from './users'
4
+ export * from './verifications'
@@ -0,0 +1,15 @@
1
+ import { sqliteTable } from 'drizzle-orm/sqlite-core'
2
+
3
+ import { users } from './users'
4
+
5
+ // https://www.better-auth.com/docs/concepts/database#session
6
+ export const sessions = sqliteTable('sessions', t => ({
7
+ id: t.text().primaryKey(),
8
+ userId: t.text().notNull().references(() => users.id, { onDelete: 'cascade' }),
9
+ token: t.text().notNull().unique(),
10
+ expiresAt: t.integer({ mode: 'timestamp' }).notNull(),
11
+ ipAddress: t.text(),
12
+ userAgent: t.text(),
13
+ createdAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
14
+ updatedAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()).$onUpdate(() => new Date()),
15
+ }))
@@ -1,8 +1,12 @@
1
1
  import { sqliteTable } from 'drizzle-orm/sqlite-core'
2
2
 
3
+ // https://www.better-auth.com/docs/concepts/database#user
3
4
  export const users = sqliteTable('users', t => ({
4
- id: t.integer().primaryKey({ autoIncrement: true }),
5
+ id: t.text().primaryKey(),
5
6
  name: t.text().notNull(),
6
- createdAt: t.integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
7
- updatedAt: t.integer({ mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdate(() => new Date()),
7
+ email: t.text().notNull().unique(),
8
+ emailVerified: t.integer({ mode: 'boolean' }).notNull().default(false),
9
+ image: t.text(),
10
+ createdAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
11
+ updatedAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()).$onUpdate(() => new Date()),
8
12
  }))
@@ -0,0 +1,11 @@
1
+ import { sqliteTable } from 'drizzle-orm/sqlite-core'
2
+
3
+ // https://www.better-auth.com/docs/concepts/database#verification
4
+ export const verifications = sqliteTable('verifications', t => ({
5
+ id: t.text().primaryKey(),
6
+ identifier: t.text().notNull(),
7
+ value: t.text().notNull(),
8
+ expiresAt: t.integer({ mode: 'timestamp' }).notNull(),
9
+ createdAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
10
+ updatedAt: t.integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()).$onUpdate(() => new Date()),
11
+ }))
@@ -1,4 +1,6 @@
1
1
  interface ImportMetaEnv {
2
+ readonly BETTER_AUTH_SECRET: string
3
+ readonly BETTER_AUTH_URL: string
2
4
  readonly DATABASE_URL: string
3
5
  readonly LOG_LEVEL: string
4
6
  readonly NODE_ENV: string
@@ -1,30 +1,20 @@
1
1
  import process from 'node:process'
2
2
 
3
- import { onError } from '@orpc/server'
4
- import { RPCHandler } from '@orpc/server/fetch'
5
- import { CORSPlugin } from '@orpc/server/plugins'
6
-
7
- import { cors, hostname, port } from './config/server'
3
+ import { createBetterAuth } from './auth'
4
+ import { rpcHandler } from './core'
8
5
  import { db } from './database'
9
- import { logger } from './logger'
10
- import { router } from './router'
11
-
12
- const rpcHandler = new RPCHandler(router, {
13
- plugins: [new CORSPlugin(cors)],
14
- interceptors: [
15
- onError((error) => {
16
- logger.error(error)
17
- }),
18
- ],
19
- })
6
+ import { logger } from './utils/logger'
7
+
8
+ const auth = createBetterAuth({ db, logger })
20
9
 
21
10
  const server = Bun.serve({
22
- hostname,
23
- port,
11
+ hostname: import.meta.env.HOST ?? '0.0.0.0',
12
+ port: import.meta.env.PORT ?? 4000,
24
13
  async fetch(request) {
25
14
  const { matched, response } = await rpcHandler.handle(request, {
26
15
  prefix: '/rpc',
27
16
  context: {
17
+ auth,
28
18
  db,
29
19
  logger,
30
20
  },
@@ -43,6 +33,8 @@ const server = Bun.serve({
43
33
 
44
34
  logger.info(`Listening on ${server.url}`)
45
35
 
36
+ // Graceful Shutdown
37
+
46
38
  async function gracefulShutdown() {
47
39
  logger.info('Gracefully shutting down...')
48
40
  await server.stop()
@@ -0,0 +1,7 @@
1
+ function copyHeaders(source: Headers, destination?: Headers) {
2
+ source.forEach((value, key) => {
3
+ destination?.append(key, value)
4
+ })
5
+ }
6
+
7
+ export { copyHeaders }
@@ -1,9 +1,7 @@
1
1
  import pino from 'pino'
2
2
 
3
- import { level } from './config/logger'
4
-
5
3
  export const logger = pino({
6
- level,
4
+ level: import.meta.env.LOG_LEVEL ?? 'info',
7
5
  base: {},
8
6
  })
9
7
 
@@ -9,26 +9,25 @@
9
9
  "preview": "vite preview --open"
10
10
  },
11
11
  "dependencies": {
12
- "@kevinmarrec/vue-i18n": "^1.0.0",
13
- "@orpc/client": "^1.8.5",
12
+ "@kevinmarrec/vue-i18n": "^1.1.0",
13
+ "@orpc/client": "^1.8.9",
14
14
  "@orpc/tanstack-query": "1.6.4",
15
- "@tanstack/vue-query": "^5.85.5",
16
- "@unhead/vue": "^2.0.14",
17
- "@vueuse/core": "^13.8.0",
18
- "unocss": "^66.4.2",
19
- "vue": "^3.5.20"
15
+ "@tanstack/vue-query": "^5.89.0",
16
+ "@unhead/vue": "^2.0.17",
17
+ "unocss": "^66.5.1",
18
+ "vue": "^3.5.21"
20
19
  },
21
20
  "devDependencies": {
22
- "@kevinmarrec/unocss-config": "^1.0.0",
23
- "@kevinmarrec/vite-plugin-dark-mode": "^1.0.0",
21
+ "@kevinmarrec/unocss-config": "^1.3.0",
22
+ "@kevinmarrec/vite-plugin-dark-mode": "^1.1.0",
24
23
  "@modyfi/vite-plugin-yaml": "^1.1.1",
25
- "@unhead/addons": "^2.0.14",
26
- "@unocss/vite": "^66.4.2",
24
+ "@unhead/addons": "^2.0.17",
25
+ "@unocss/vite": "^66.5.1",
27
26
  "@vitejs/plugin-vue": "^6.0.1",
28
27
  "beasties": "^0.3.5",
29
28
  "rollup-plugin-visualizer": "^6.0.3",
30
- "vite": "^7.1.3",
31
- "vite-plugin-vue-devtools": "^8.0.1",
29
+ "vite": "^7.1.6",
30
+ "vite-plugin-vue-devtools": "^8.0.2",
32
31
  "vite-ssg": "^28.1.0",
33
32
  "vite-tsconfig-paths": "^5.1.4"
34
33
  }
@@ -1,25 +1,111 @@
1
1
  <script setup lang="ts">
2
- import { useHead, useI18n } from '@frontend/composables'
3
- import { useWelcome } from '@frontend/queries'
2
+ import { useAuth, useHead, useI18n } from '@frontend/composables'
3
+ import { ref } from 'vue'
4
4
 
5
5
  const { t } = useI18n()
6
-
7
6
  useHead({
8
7
  title: () => t('title'),
9
8
  })
10
9
 
11
- const { data: message } = useWelcome('world')
10
+ const { user, signUp, signIn, signOut } = useAuth()
11
+
12
+ const email = ref('')
13
+ const password = ref('')
12
14
  </script>
13
15
 
14
16
  <template>
15
17
  <div class="grid h-full place-items-center">
16
18
  <div class="flex flex-col gap-4 items-center">
17
- <h1 class="text-4xl font-bold">
18
- {{ t('body.message') }}
19
- </h1>
20
- <h2 class="text-2xl text-gray-500">
21
- {{ message }}
22
- </h2>
19
+ <!-- Login Form (when user is not logged in) -->
20
+ <div v-if="!user" class="mx-auto max-w-md w-full">
21
+ <div class="p-8 rounded-lg bg-white shadow-lg dark:bg-gray-800">
22
+ <h2 class="text-2xl text-gray-900 font-bold mb-6 text-center dark:text-white">
23
+ Welcome Back
24
+ </h2>
25
+
26
+ <form class="space-y-4" @submit.prevent>
27
+ <div>
28
+ <label for="email" class="text-sm text-gray-700 font-medium mb-2 block dark:text-gray-300">
29
+ Email
30
+ </label>
31
+ <input
32
+ id="email"
33
+ v-model="email"
34
+ type="email"
35
+ class="px-3 py-2 border border-gray-300 rounded-md w-full shadow-sm dark:text-white focus:outline-none dark:border-gray-600 focus:border-blue-500 dark:bg-gray-700 focus:ring-2 focus:ring-blue-500"
36
+ placeholder="Enter your email"
37
+ required
38
+ >
39
+ </div>
40
+
41
+ <div>
42
+ <label for="password" class="text-sm text-gray-700 font-medium mb-2 block dark:text-gray-300">
43
+ Password
44
+ </label>
45
+ <input
46
+ id="password"
47
+ v-model="password"
48
+ type="password"
49
+ class="px-3 py-2 border border-gray-300 rounded-md w-full shadow-sm dark:text-white focus:outline-none dark:border-gray-600 focus:border-blue-500 dark:bg-gray-700 focus:ring-2 focus:ring-blue-500"
50
+ placeholder="Enter your password"
51
+ required
52
+ >
53
+ </div>
54
+
55
+ <div v-if="signIn.error" class="text-sm text-red-600 dark:text-red-400">
56
+ {{ signIn.error }}
57
+ </div>
58
+
59
+ <div class="flex space-x-3">
60
+ <button
61
+ :disabled="signIn.isPending.value"
62
+ class="text-white font-medium px-4 py-2 rounded-md bg-blue-600 flex-1 transition-colors duration-200 disabled:bg-blue-400 hover:bg-blue-700"
63
+ @click="signIn.mutate({ email, password })"
64
+ >
65
+ <span v-if="signIn.isPending.value">Signing In...</span>
66
+ <span v-else>Sign In</span>
67
+ </button>
68
+
69
+ <button
70
+ :disabled="signUp.isPending.value"
71
+ class="text-white font-medium px-4 py-2 rounded-md bg-green-600 flex-1 transition-colors duration-200 disabled:bg-green-400 hover:bg-green-700"
72
+ @click="signUp.mutate({ email, password, name: 'User' })"
73
+ >
74
+ <span v-if="signUp.isPending.value">Signing Up...</span>
75
+ <span v-else>Sign Up</span>
76
+ </button>
77
+ </div>
78
+ </form>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- User Dashboard (when user is logged in) -->
83
+ <div v-else class="mx-auto max-w-md w-full">
84
+ <div class="p-8 text-center rounded-lg bg-white shadow-lg dark:bg-gray-800">
85
+ <div class="mb-6">
86
+ <div class="mx-auto mb-4 rounded-full flex h-20 w-20 items-center justify-center from-blue-500 to-purple-600 bg-gradient-to-r">
87
+ <span class="text-2xl text-white font-bold">
88
+ {{ user.name?.charAt(0).toUpperCase() || 'U' }}
89
+ </span>
90
+ </div>
91
+ <h2 class="text-2xl text-gray-900 font-bold dark:text-white">
92
+ Welcome, {{ user.name || 'User' }}!
93
+ </h2>
94
+ <p class="text-gray-600 mt-2 dark:text-gray-400">
95
+ {{ user.email }}
96
+ </p>
97
+ </div>
98
+
99
+ <button
100
+ :disabled="signOut.isPending.value"
101
+ class="text-white font-medium px-4 py-2 rounded-md bg-red-600 w-full transition-colors duration-200 disabled:bg-red-400 hover:bg-red-700"
102
+ @click="signOut.mutate({})"
103
+ >
104
+ <span v-if="signOut.isPending.value">Signing Out...</span>
105
+ <span v-else>Sign Out</span>
106
+ </button>
107
+ </div>
108
+ </div>
23
109
  </div>
24
110
  </div>
25
111
  </template>
@@ -0,0 +1,31 @@
1
+ import { orpc } from '@frontend/lib/orpc'
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
3
+
4
+ export function useAuth() {
5
+ const qc = useQueryClient()
6
+
7
+ const { data: user } = useQuery(orpc.auth.getCurrentUser.queryOptions({
8
+ retry: false,
9
+ }))
10
+
11
+ const signUp = useMutation(orpc.auth.signUp.mutationOptions({
12
+ onSuccess: () => qc.invalidateQueries({ queryKey: orpc.auth.getCurrentUser.queryKey() }),
13
+ }))
14
+
15
+ const signIn = useMutation(orpc.auth.signIn.mutationOptions({
16
+ onSuccess: () => qc.invalidateQueries({ queryKey: orpc.auth.getCurrentUser.queryKey() }),
17
+ }))
18
+
19
+ const signOut = useMutation(orpc.auth.signOut.mutationOptions({
20
+ onSuccess: () => {
21
+ qc.setQueryData<null>(orpc.auth.getCurrentUser.queryKey(), null)
22
+ },
23
+ }))
24
+
25
+ return {
26
+ user,
27
+ signUp,
28
+ signIn,
29
+ signOut,
30
+ }
31
+ }
@@ -1,2 +1,3 @@
1
+ export { useAuth } from './auth'
1
2
  export { useI18n } from '@kevinmarrec/vue-i18n'
2
3
  export { useHead } from '@unhead/vue'
@@ -1,10 +1,15 @@
1
- import type { Router, RouterClient } from '@backend/router'
1
+ import type { Router, RouterClient } from '@backend/core/router'
2
2
  import { createORPCClient } from '@orpc/client'
3
3
  import { RPCLink } from '@orpc/client/fetch'
4
4
  import { createTanstackQueryUtils } from '@orpc/tanstack-query'
5
5
 
6
6
  const link = new RPCLink({
7
7
  url: import.meta.env.VITE_API_URL,
8
+ fetch: (request, init) =>
9
+ globalThis.fetch(request, {
10
+ ...init,
11
+ credentials: 'include',
12
+ }),
8
13
  })
9
14
 
10
15
  const client: RouterClient<Router> = createORPCClient(link)
@@ -12,7 +12,7 @@ export default {
12
12
  },
13
13
  'backend': {
14
14
  drizzle: {
15
- config: ['src/config/drizzle.ts'],
15
+ config: 'src/database/drizzle.config.ts',
16
16
  },
17
17
  ignoreDependencies: ['pino-pretty'],
18
18
  },
@@ -2,7 +2,7 @@
2
2
  "name": "app",
3
3
  "type": "module",
4
4
  "private": true,
5
- "packageManager": "bun@1.2.21",
5
+ "packageManager": "bun@1.2.22",
6
6
  "engines": {
7
7
  "node": "lts/*"
8
8
  },
@@ -21,15 +21,15 @@
21
21
  "lint:inspect": "bunx @eslint/config-inspector"
22
22
  },
23
23
  "devDependencies": {
24
- "@kevinmarrec/eslint-config": "^1.0.0",
25
- "@kevinmarrec/stylelint-config": "^1.0.0",
26
- "@kevinmarrec/tsconfig": "^1.0.0",
24
+ "@kevinmarrec/eslint-config": "^1.4.0",
25
+ "@kevinmarrec/stylelint-config": "^1.2.0",
26
+ "@kevinmarrec/tsconfig": "^1.1.0",
27
27
  "concurrently": "^9.2.1",
28
- "eslint": "^9.34.0",
29
- "knip": "^5.63.0",
30
- "stylelint": "^16.23.1",
31
- "taze": "^19.3.0",
28
+ "eslint": "^9.36.0",
29
+ "knip": "^5.63.1",
30
+ "stylelint": "^16.24.0",
31
+ "taze": "^19.7.0",
32
32
  "typescript": "~5.9.2",
33
- "vue-tsc": "^3.0.6"
33
+ "vue-tsc": "^3.0.7"
34
34
  }
35
35
  }
@@ -1,11 +0,0 @@
1
- import * as v from 'valibot'
2
-
3
- const schema = v.object({
4
- url: v.string(),
5
- })
6
-
7
- const config = v.parse(schema, {
8
- url: import.meta.env.DATABASE_URL,
9
- })
10
-
11
- export const url = config.url
@@ -1,10 +0,0 @@
1
- import { defineConfig } from 'drizzle-kit'
2
-
3
- import { url } from './database'
4
-
5
- export default defineConfig({
6
- dialect: 'sqlite',
7
- dbCredentials: { url },
8
- schema: './src/database/schema',
9
- out: './src/database/migrations',
10
- })
@@ -1,19 +0,0 @@
1
- import * as v from 'valibot'
2
-
3
- const schema = v.object({
4
- level: v.optional(v.union([
5
- v.literal('trace'),
6
- v.literal('debug'),
7
- v.literal('info'),
8
- v.literal('warn'),
9
- v.literal('error'),
10
- v.literal('fatal'),
11
- v.literal('silent'),
12
- ]), 'info'),
13
- })
14
-
15
- const config = v.parse(schema, {
16
- level: import.meta.env.LOG_LEVEL,
17
- })
18
-
19
- export const level = config.level
@@ -1,40 +0,0 @@
1
- import * as v from 'valibot'
2
-
3
- const schema = v.object({
4
- cors: v.object({
5
- origin: v.optional(
6
- v.pipe(
7
- v.string(),
8
- v.minLength(1),
9
- v.transform(value => value.split(',')),
10
- ),
11
- '*',
12
- ),
13
- }),
14
- hostname: v.optional(
15
- v.pipe(
16
- v.string(),
17
- v.minLength(1),
18
- ),
19
- 'localhost',
20
- ),
21
- port: v.optional(v.pipe(
22
- v.string(),
23
- v.transform(value => +value),
24
- v.number(),
25
- v.minValue(3000),
26
- v.maxValue(65535),
27
- ), '4000'),
28
- })
29
-
30
- const config = v.parse(schema, {
31
- cors: {
32
- origin: import.meta.env.ALLOWED_ORIGINS,
33
- },
34
- hostname: import.meta.env.HOST,
35
- port: import.meta.env.PORT,
36
- })
37
-
38
- export const cors = config.cors
39
- export const hostname = config.hostname
40
- export const port = config.port
@@ -1,6 +0,0 @@
1
- CREATE TABLE `users` (
2
- `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
- `name` text NOT NULL,
4
- `createdAt` integer NOT NULL,
5
- `updatedAt` integer NOT NULL
6
- );
@@ -1,10 +0,0 @@
1
- import type { Database } from '@backend/database'
2
- import type { Logger } from '@backend/logger'
3
- import { os } from '@orpc/server'
4
-
5
- interface Context {
6
- db: Database
7
- logger: Logger
8
- }
9
-
10
- export const pub = os.$context<Context>()
@@ -1,11 +0,0 @@
1
- import { users } from '@backend/database/schema'
2
- import { pub } from '@backend/lib/orpc'
3
- import * as v from 'valibot'
4
-
5
- export const welcome = pub
6
- .input(v.string())
7
- .output(v.string())
8
- .handler(async ({ input, context: { db } }) => {
9
- const count = await db.$count(users)
10
- return `Hello ${input} (${count})`
11
- })
@@ -1 +0,0 @@
1
- export * from './welcome'
@@ -1,10 +0,0 @@
1
- import { orpc } from '@frontend/lib/orpc'
2
- import { get } from '@frontend/utils'
3
- import { useQuery } from '@tanstack/vue-query'
4
- import { computed, type MaybeRef } from 'vue'
5
-
6
- export function useWelcome(text: MaybeRef<string>) {
7
- return useQuery(computed(() => orpc.welcome.queryOptions({
8
- input: get(text),
9
- })))
10
- }
@@ -1 +0,0 @@
1
- export { get } from '@vueuse/core'
File without changes