vasp-cli 0.3.0 → 0.4.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 (26) hide show
  1. package/dist/vasp +25 -24
  2. package/package.json +1 -1
  3. package/starters/recipe.vasp +79 -0
  4. package/templates/shared/auth/client/Login.vue.hbs +1 -1
  5. package/templates/shared/auth/client/Register.vue.hbs +1 -1
  6. package/templates/shared/drizzle/drizzle.config.hbs +10 -0
  7. package/templates/shared/drizzle/schema.hbs +14 -3
  8. package/templates/shared/jobs/_job.hbs +12 -2
  9. package/templates/shared/package.json.hbs +4 -1
  10. package/templates/shared/server/index.hbs +8 -0
  11. package/templates/shared/server/middleware/csrf.hbs +34 -0
  12. package/templates/shared/server/middleware/rateLimit.hbs +44 -0
  13. package/templates/shared/server/routes/crud/_crud.hbs +47 -3
  14. package/templates/shared/server/routes/realtime/_channel.hbs +58 -10
  15. package/templates/ssr/js/plugins/vasp.client.js.hbs +11 -1
  16. package/templates/ssr/ts/plugins/vasp.client.ts.hbs +11 -1
  17. package/templates/templates/shared/.env.hbs +14 -0
  18. package/templates/templates/shared/README.md.hbs +53 -0
  19. package/templates/templates/shared/auth/server/index.hbs +3 -8
  20. package/templates/templates/shared/auth/server/plugin.hbs +9 -0
  21. package/templates/templates/shared/auth/server/providers/github.hbs +1 -1
  22. package/templates/templates/shared/auth/server/providers/google.hbs +1 -1
  23. package/templates/templates/shared/auth/server/providers/usernameAndPassword.hbs +3 -6
  24. package/templates/templates/shared/bunfig.toml.hbs +3 -0
  25. package/templates/templates/shared/package.json.hbs +4 -1
  26. package/templates/templates/shared/server/index.hbs +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vasp-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Vasp CLI — declarative full-stack framework for Vue developers",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,79 @@
1
+ app RecipeApp {
2
+ title: "Recipe App"
3
+ db: Drizzle
4
+ ssr: false
5
+ typescript: false
6
+ }
7
+
8
+ auth RecipeAuth {
9
+ userEntity: User
10
+ methods: [usernameAndPassword]
11
+ }
12
+
13
+ entity User {
14
+ id: Int @id
15
+ username: String @unique
16
+ password: String
17
+ createdAt: DateTime @default(now)
18
+ }
19
+
20
+ entity Recipe {
21
+ id: Int @id
22
+ title: String
23
+ description: String
24
+ ingredients: String
25
+ instructions: String
26
+ cookTime: Int
27
+ servings: Int
28
+ imageUrl: String
29
+ createdAt: DateTime @default(now)
30
+ }
31
+
32
+ route HomeRoute {
33
+ path: "/"
34
+ to: HomePage
35
+ }
36
+
37
+ route RecipesRoute {
38
+ path: "/recipes"
39
+ to: RecipesPage
40
+ }
41
+
42
+ route AddRecipeRoute {
43
+ path: "/recipes/add"
44
+ to: AddRecipePage
45
+ }
46
+
47
+ page HomePage {
48
+ component: import Home from "@src/pages/Home.vue"
49
+ }
50
+
51
+ page RecipesPage {
52
+ component: import Recipes from "@src/pages/Recipes.vue"
53
+ }
54
+
55
+ page AddRecipePage {
56
+ component: import AddRecipe from "@src/pages/AddRecipe.vue"
57
+ }
58
+
59
+ crud Recipe {
60
+ entity: Recipe
61
+ operations: [list, create, update, delete]
62
+ }
63
+
64
+ query getRecipes {
65
+ fn: import { getRecipes } from "@src/queries.js"
66
+ entities: [Recipe]
67
+ }
68
+
69
+ action createRecipe {
70
+ fn: import { createRecipe } from "@src/actions.js"
71
+ entities: [Recipe]
72
+ auth: true
73
+ }
74
+
75
+ action deleteRecipe {
76
+ fn: import { deleteRecipe } from "@src/actions.js"
77
+ entities: [Recipe]
78
+ auth: true
79
+ }
@@ -38,7 +38,7 @@ async function handleLogin() {
38
38
  await login(username.value, password.value)
39
39
  router.push('/')
40
40
  } catch (err) {
41
- error.value = err?.data?.error || 'Login failed'
41
+ error.value = err?.data?.error || err?.message || 'Login failed'
42
42
  } finally {
43
43
  loading.value = false
44
44
  }
@@ -34,7 +34,7 @@ async function handleRegister() {
34
34
  await register(username.value, password.value, email.value || undefined)
35
35
  router.push('/')
36
36
  } catch (err) {
37
- error.value = err?.data?.error || 'Registration failed'
37
+ error.value = err?.data?.error || err?.message || 'Registration failed'
38
38
  } finally {
39
39
  loading.value = false
40
40
  }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'drizzle-kit'
2
+
3
+ export default defineConfig({
4
+ schema: './drizzle/schema.{{ext}}',
5
+ out: './drizzle/migrations',
6
+ dialect: 'postgresql',
7
+ dbCredentials: {
8
+ url: process.env.DATABASE_URL,
9
+ },
10
+ })
@@ -1,4 +1,4 @@
1
- import { pgTable, serial, text, timestamp, boolean } from 'drizzle-orm/pg-core'
1
+ import { pgTable, serial, text, integer, boolean, timestamp, doublePrecision } from 'drizzle-orm/pg-core'
2
2
  {{#if isTypeScript}}
3
3
  import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'
4
4
  {{/if}}
@@ -21,14 +21,25 @@ export type NewUser = InferInsertModel<typeof users>
21
21
  {{/if}}
22
22
 
23
23
  {{/if}}
24
- {{#each cruds}}
25
- // {{entity}} table — add your columns below
24
+ {{#each crudsWithFields}}
25
+ {{#if hasEntity}}
26
+ // {{entity}} table — generated from entity block
27
+ export const {{camelCase entity}}s = pgTable('{{camelCase entity}}s', {
28
+ {{#each fields}}
29
+ {{camelCase name}}: {{{drizzleColumn name type modifiers}}},
30
+ {{/each}}
31
+ createdAt: timestamp('created_at').defaultNow().notNull(),
32
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
33
+ })
34
+ {{else}}
35
+ // {{entity}} table — no entity block found, add your columns below
26
36
  export const {{camelCase entity}}s = pgTable('{{camelCase entity}}s', {
27
37
  id: serial('id').primaryKey(),
28
38
  // TODO: Add your {{entity}} columns here
29
39
  createdAt: timestamp('created_at').defaultNow().notNull(),
30
40
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
31
41
  })
42
+ {{/if}}
32
43
  {{#if ../isTypeScript}}
33
44
  export type {{pascalCase entity}} = InferSelectModel<typeof {{camelCase entity}}s>
34
45
  export type New{{pascalCase entity}} = InferInsertModel<typeof {{camelCase entity}}s>
@@ -12,13 +12,23 @@ export async function register{{pascalCase name}}Worker() {
12
12
  await boss.work(JOB_NAME, async (job) => {
13
13
  await {{namedExport}}(job.data)
14
14
  })
15
+ {{#if hasSchedule}}
16
+
17
+ // Register cron schedule: {{schedule}}
18
+ await boss.schedule(JOB_NAME, '{{schedule}}', {})
19
+ {{/if}}
15
20
  }
16
21
 
17
22
  /**
18
- * Schedule a '{{name}}' job.
23
+ * Schedule a '{{name}}' job on demand.
19
24
  * @param {unknown} data - Data to pass to the job handler
20
25
  */
21
26
  export async function schedule{{pascalCase name}}(data) {
22
27
  const boss = await getBoss()
23
- return boss.send(JOB_NAME, data)
28
+ return boss.send(JOB_NAME, data, {
29
+ retryLimit: Number(process.env.JOB_RETRY_LIMIT) || 3,
30
+ retryDelay: Number(process.env.JOB_RETRY_DELAY) || 60,
31
+ retryBackoff: true,
32
+ expireInMinutes: Number(process.env.JOB_EXPIRE_MINUTES) || 15,
33
+ })
24
34
  }
@@ -7,7 +7,10 @@
7
7
  "dev": "vasp start",
8
8
  "build": "vasp build",
9
9
  "dev:server": "bun --hot server/index.{{ext}}",
10
- "dev:client": "{{#if isSpa}}vite{{else}}nuxt dev{{/if}}"
10
+ "dev:client": "{{#if isSpa}}vite{{else}}nuxt dev{{/if}}",
11
+ "db:generate": "bunx drizzle-kit generate",
12
+ "db:migrate": "bunx drizzle-kit migrate",
13
+ "db:studio": "bunx drizzle-kit studio"
11
14
  },
12
15
  "dependencies": {
13
16
  "@vasp-framework/runtime": "^0.1.0",
@@ -2,6 +2,10 @@ import { Elysia } from 'elysia'
2
2
  import { cors } from '@elysiajs/cors'
3
3
  import { staticPlugin } from '@elysiajs/static'
4
4
  import { db } from './db/client.{{ext}}'
5
+ import { rateLimit } from './middleware/rateLimit.{{ext}}'
6
+ {{#if isSsr}}
7
+ import { csrfProtection } from './middleware/csrf.{{ext}}'
8
+ {{/if}}
5
9
  {{#if hasAuth}}
6
10
  import { authRoutes } from './auth/index.{{ext}}'
7
11
  {{/if}}
@@ -28,6 +32,10 @@ const app = new Elysia()
28
32
  origin: process.env.CORS_ORIGIN || 'http://localhost:{{frontendPort}}',
29
33
  credentials: true,
30
34
  }))
35
+ .use(rateLimit())
36
+ {{#if isSsr}}
37
+ .use(csrfProtection())
38
+ {{/if}}
31
39
  .get('/api/health', () => ({ status: 'ok', version: '{{vaspVersion}}' }))
32
40
  {{#if hasAuth}}
33
41
  .use(authRoutes)
@@ -0,0 +1,34 @@
1
+ import { Elysia } from 'elysia'
2
+ import { randomBytes } from 'node:crypto'
3
+
4
+ const CSRF_COOKIE = 'vasp-csrf'
5
+ const CSRF_HEADER = 'x-csrf-token'
6
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
7
+
8
+ /**
9
+ * CSRF protection middleware — double-submit cookie pattern.
10
+ * On every response, sets a CSRF cookie with a random token.
11
+ * On state-changing requests (POST, PUT, DELETE), validates that the
12
+ * x-csrf-token header matches the cookie value.
13
+ */
14
+ export function csrfProtection() {
15
+ return new Elysia({ name: 'csrf' })
16
+ .onRequest(({ request, set, cookie }) => {
17
+ // Generate token if not already set
18
+ if (!cookie[CSRF_COOKIE]?.value) {
19
+ const token = randomBytes(32).toString('hex')
20
+ cookie[CSRF_COOKIE].set({ value: token, httpOnly: false, sameSite: 'strict', path: '/' })
21
+ }
22
+
23
+ // Skip validation for safe methods
24
+ if (SAFE_METHODS.has(request.method)) return
25
+
26
+ const cookieToken = cookie[CSRF_COOKIE]?.value
27
+ const headerToken = request.headers.get(CSRF_HEADER)
28
+
29
+ if (!cookieToken || !headerToken || cookieToken !== headerToken) {
30
+ set.status = 403
31
+ return { error: 'CSRF token mismatch' }
32
+ }
33
+ })
34
+ }
@@ -0,0 +1,44 @@
1
+ import { Elysia } from 'elysia'
2
+
3
+ const MAX_REQUESTS = Number(process.env.RATE_LIMIT_MAX) || 100
4
+ const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000
5
+
6
+ const hits = new Map()
7
+
8
+ // Clean up expired entries every 60 seconds
9
+ setInterval(() => {
10
+ const now = Date.now()
11
+ for (const [key, entry] of hits) {
12
+ if (now - entry.start > WINDOW_MS) hits.delete(key)
13
+ }
14
+ }, 60_000)
15
+
16
+ /**
17
+ * Simple in-memory sliding-window rate limiter.
18
+ * Limits each IP to MAX_REQUESTS per WINDOW_MS.
19
+ */
20
+ export function rateLimit() {
21
+ return new Elysia({ name: 'rate-limit' })
22
+ .onBeforeHandle(({ request, set }) => {
23
+ const ip = request.headers.get('x-forwarded-for')
24
+ || request.headers.get('x-real-ip')
25
+ || 'unknown'
26
+ const now = Date.now()
27
+ let entry = hits.get(ip)
28
+
29
+ if (!entry || now - entry.start > WINDOW_MS) {
30
+ entry = { start: now, count: 0 }
31
+ hits.set(ip, entry)
32
+ }
33
+
34
+ entry.count++
35
+
36
+ set.headers['x-ratelimit-limit'] = String(MAX_REQUESTS)
37
+ set.headers['x-ratelimit-remaining'] = String(Math.max(0, MAX_REQUESTS - entry.count))
38
+
39
+ if (entry.count > MAX_REQUESTS) {
40
+ set.status = 429
41
+ return { error: 'Too many requests. Please try again later.' }
42
+ }
43
+ })
44
+ }
@@ -1,23 +1,61 @@
1
1
  import { Elysia, t } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
- import { eq } from 'drizzle-orm'
3
+ import { eq, sql, asc, desc, and, ilike } from 'drizzle-orm'
4
4
  import { {{camelCase entity}}s } from '../../../drizzle/schema.{{ext}}'
5
5
  {{#if hasAuth}}
6
6
  import { requireAuth } from '../../auth/middleware.{{ext}}'
7
7
  {{/if}}
8
+ {{#if hasRealtime}}
9
+ import { publish{{pascalCase entity}} } from '../realtime/{{camelCase realtimeName}}.{{ext}}'
10
+ {{/if}}
8
11
 
9
12
  export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{camelCase entity}}' })
10
13
  {{#if hasAuth}}
11
14
  .use(requireAuth)
12
15
  {{/if}}
13
16
  {{#if (includes operations "list")}}
14
- .get('/', async () => {
15
- return db.select().from({{camelCase entity}}s)
17
+ .get('/', async ({ query }) => {
18
+ const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 100)
19
+ const offset = Math.max(Number(query.offset) || 0, 0)
20
+
21
+ const table = {{camelCase entity}}s
22
+
23
+ // Multi-column sorting: orderBy=col1,col2 & dir=asc,desc
24
+ const orderByFields = (query.orderBy ?? 'id').split(',')
25
+ const directions = (query.dir ?? 'asc').split(',')
26
+ const orderClauses = orderByFields.map((field, i) => {
27
+ const col = table[field.trim()] ?? table.id
28
+ const dirFn = (directions[i] ?? directions[0] ?? 'asc').trim() === 'desc' ? desc : asc
29
+ return dirFn(col)
30
+ })
31
+
32
+ // Build WHERE conditions from filter.* query params
33
+ const conditions = []
34
+ for (const [key, value] of Object.entries(query)) {
35
+ if (!key.startsWith('filter.')) continue
36
+ const field = key.slice(7)
37
+ if (!table[field]) continue
38
+ conditions.push(eq(table[field], value))
39
+ }
40
+ const where = conditions.length > 0 ? and(...conditions) : undefined
41
+
42
+ const baseQuery = db.select().from(table)
43
+ const countQuery = db.select({ count: sql`count(*)::int` }).from(table)
44
+
45
+ const [data, countResult] = await Promise.all([
46
+ (where ? baseQuery.where(where) : baseQuery).orderBy(...orderClauses).limit(limit).offset(offset),
47
+ where ? countQuery.where(where) : countQuery,
48
+ ])
49
+
50
+ return { data, total: countResult[0]?.count ?? 0, limit, offset }
16
51
  })
17
52
  {{/if}}
18
53
  {{#if (includes operations "create")}}
19
54
  .post('/', async ({ body }) => {
20
55
  const [created] = await db.insert({{camelCase entity}}s).values(body).returning()
56
+ {{#if hasRealtime}}
57
+ publish{{pascalCase entity}}('created', created)
58
+ {{/if}}
21
59
  return created
22
60
  })
23
61
  {{/if}}
@@ -30,6 +68,9 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
30
68
  .put('/:id', async ({ params: { id }, body, set }) => {
31
69
  const [updated] = await db.update({{camelCase entity}}s).set(body).where(eq({{camelCase entity}}s.id, Number(id))).returning()
32
70
  if (!updated) { set.status = 404; return { error: 'Not found' } }
71
+ {{#if hasRealtime}}
72
+ publish{{pascalCase entity}}('updated', updated)
73
+ {{/if}}
33
74
  return updated
34
75
  })
35
76
  {{/if}}
@@ -37,6 +78,9 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
37
78
  .delete('/:id', async ({ params: { id }, set }) => {
38
79
  const [deleted] = await db.delete({{camelCase entity}}s).where(eq({{camelCase entity}}s.id, Number(id))).returning()
39
80
  if (!deleted) { set.status = 404; return { error: 'Not found' } }
81
+ {{#if hasRealtime}}
82
+ publish{{pascalCase entity}}('deleted', deleted)
83
+ {{/if}}
40
84
  return { ok: true }
41
85
  })
42
86
  {{/if}}
@@ -1,30 +1,78 @@
1
1
  import { Elysia } from 'elysia'
2
+ {{#if ../hasAuth}}
3
+ import jwt from '@elysiajs/jwt'
4
+ {{/if}}
2
5
 
3
- // In-process subscriber set for '{{name}}' channel
4
- // Swap this for a Redis adapter in production multi-instance deployments
5
- const subscribers = new Set()
6
+ // Room-based subscriber map for '{{name}}' channel
7
+ // Each room key maps to a set of connected WebSocket clients
8
+ const rooms = new Map()
9
+
10
+ function getRoom(roomId) {
11
+ if (!rooms.has(roomId)) rooms.set(roomId, new Set())
12
+ return rooms.get(roomId)
13
+ }
6
14
 
7
15
  /**
8
- * Publish a realtime event to all connected subscribers of '{{name}}'.
16
+ * Publish a realtime event to all subscribers in a specific room of '{{name}}'.
9
17
  * Called automatically by CRUD mutation handlers.
10
18
  */
11
- export function publish{{pascalCase name}}(event, data) {
19
+ export function publish{{pascalCase name}}(event, data, roomId = 'default') {
20
+ const room = rooms.get(roomId)
21
+ if (!room) return
22
+ const message = JSON.stringify({ channel: '{{camelCase name}}', room: roomId, event, data })
23
+ for (const ws of room) {
24
+ try { ws.send(message) } catch { room.delete(ws) }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Broadcast to all rooms of '{{name}}'.
30
+ */
31
+ export function broadcast{{pascalCase name}}(event, data) {
12
32
  const message = JSON.stringify({ channel: '{{camelCase name}}', event, data })
13
- for (const ws of subscribers) {
14
- try { ws.send(message) } catch { subscribers.delete(ws) }
33
+ for (const [, room] of rooms) {
34
+ for (const ws of room) {
35
+ try { ws.send(message) } catch { room.delete(ws) }
36
+ }
15
37
  }
16
38
  }
17
39
 
18
40
  export const {{camelCase name}}Channel = new Elysia()
41
+ {{#if ../hasAuth}}
42
+ .use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET || 'vasp-dev-secret' }))
43
+ {{/if}}
19
44
  .ws('/ws/{{camelCase name}}', {
45
+ {{#if ../hasAuth}}
46
+ async beforeHandle({ jwt: jwtPlugin, request }) {
47
+ const url = new URL(request.url)
48
+ const token = url.searchParams.get('token')
49
+ if (!token) return new Response('Unauthorized', { status: 401 })
50
+ const payload = await jwtPlugin.verify(token)
51
+ if (!payload) return new Response('Unauthorized', { status: 401 })
52
+ },
53
+ {{/if}}
20
54
  open(ws) {
21
- subscribers.add(ws)
55
+ const room = ws.data?.query?.room ?? 'default'
56
+ getRoom(room).add(ws)
57
+ ws.data._room = room
22
58
  },
23
59
  message(ws, msg) {
24
- // Echo back for debugging; extend for room-based subscriptions
60
+ // Client can switch rooms via { action: 'join', room: 'roomId' }
61
+ try {
62
+ const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg
63
+ if (parsed.action === 'join' && parsed.room) {
64
+ const oldRoom = ws.data._room
65
+ if (oldRoom) rooms.get(oldRoom)?.delete(ws)
66
+ ws.data._room = parsed.room
67
+ getRoom(parsed.room).add(ws)
68
+ ws.send(JSON.stringify({ ack: 'joined', room: parsed.room }))
69
+ return
70
+ }
71
+ } catch { /* ignore parse errors */ }
25
72
  ws.send(JSON.stringify({ ack: msg }))
26
73
  },
27
74
  close(ws) {
28
- subscribers.delete(ws)
75
+ const room = ws.data?._room
76
+ if (room) rooms.get(room)?.delete(ws)
29
77
  },
30
78
  })
@@ -2,6 +2,11 @@
2
2
  // Runs only on the client after hydration — routes calls to the Elysia backend via ofetch
3
3
  import { $fetch } from 'ofetch'
4
4
 
5
+ function getCsrfToken() {
6
+ const match = document.cookie.match(/(?:^|;\s*)vasp-csrf=([^;]*)/)
7
+ return match ? decodeURIComponent(match[1]) : undefined
8
+ }
9
+
5
10
  export default defineNuxtPlugin((nuxtApp) => {
6
11
  const config = useRuntimeConfig()
7
12
  const baseURL = config.public.apiBase
@@ -11,7 +16,12 @@ export default defineNuxtPlugin((nuxtApp) => {
11
16
  return $fetch(`/queries/${name}`, { baseURL, method: 'GET', query: args })
12
17
  },
13
18
  async action(name, args) {
14
- return $fetch(`/actions/${name}`, { baseURL, method: 'POST', body: args })
19
+ return $fetch(`/actions/${name}`, {
20
+ baseURL,
21
+ method: 'POST',
22
+ body: args,
23
+ headers: { 'x-csrf-token': getCsrfToken() ?? '' },
24
+ })
15
25
  },
16
26
  })
17
27
  })
@@ -2,6 +2,11 @@
2
2
  // Runs only on the client after hydration — routes calls to the Elysia backend via ofetch
3
3
  import { $fetch } from 'ofetch'
4
4
 
5
+ function getCsrfToken(): string | undefined {
6
+ const match = document.cookie.match(/(?:^|;\s*)vasp-csrf=([^;]*)/)
7
+ return match ? decodeURIComponent(match[1]) : undefined
8
+ }
9
+
5
10
  export default defineNuxtPlugin((nuxtApp) => {
6
11
  const config = useRuntimeConfig()
7
12
  const baseURL = config.public.apiBase as string
@@ -11,7 +16,12 @@ export default defineNuxtPlugin((nuxtApp) => {
11
16
  return $fetch<T>(`/queries/${name}`, { baseURL, method: 'GET', query: args as Record<string, unknown> })
12
17
  },
13
18
  async action<T = unknown>(name: string, args?: unknown): Promise<T> {
14
- return $fetch<T>(`/actions/${name}`, { baseURL, method: 'POST', body: args })
19
+ return $fetch<T>(`/actions/${name}`, {
20
+ baseURL,
21
+ method: 'POST',
22
+ body: args,
23
+ headers: { 'x-csrf-token': getCsrfToken() ?? '' },
24
+ })
15
25
  },
16
26
  })
17
27
  })
@@ -0,0 +1,14 @@
1
+ DATABASE_URL=postgres://user:password@localhost:5432/{{kebabCase appName}}
2
+ PORT={{backendPort}}
3
+ VITE_API_URL=http://localhost:{{backendPort}}/api
4
+ {{#if hasAuth}}
5
+ JWT_SECRET=change-me-in-production
6
+ {{#if (includes authMethods "google")}}
7
+ GOOGLE_CLIENT_ID=
8
+ GOOGLE_CLIENT_SECRET=
9
+ {{/if}}
10
+ {{#if (includes authMethods "github")}}
11
+ GITHUB_CLIENT_ID=
12
+ GITHUB_CLIENT_SECRET=
13
+ {{/if}}
14
+ {{/if}}
@@ -0,0 +1,53 @@
1
+ # {{appTitle}}
2
+
3
+ A full-stack app built with [Vasp](https://github.com/AliBeigi/Vasp).
4
+
5
+ ## Getting Started
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ bun install
10
+
11
+ # Set up your database
12
+ # Make sure PostgreSQL is running, then push the schema:
13
+ bun run db:push
14
+
15
+ # Start the dev server (backend + frontend)
16
+ vasp start
17
+ ```
18
+
19
+ ## Project Structure
20
+
21
+ ```
22
+ main.vasp # Vasp declarative config
23
+ server/ # Elysia backend
24
+ routes/ # API routes (queries, actions, CRUD)
25
+ db/ # Drizzle DB client
26
+ middleware/ # Rate limiting{{#if hasAuth}}, auth{{/if}}
27
+ {{#if hasJobs}} jobs/ # Background jobs (PgBoss)
28
+ {{/if}}src/ # Frontend source
29
+ pages/ # Vue page components
30
+ components/ # Shared components
31
+ drizzle/ # Database schema & migrations
32
+ ```
33
+
34
+ ## Scripts
35
+
36
+ | Command | Description |
37
+ |---------|-------------|
38
+ | `vasp start` | Start dev server (backend + frontend) |
39
+ | `vasp build` | Production build |
40
+ | `bun run db:push` | Push schema to database |
41
+ | `bun run db:generate` | Generate a migration |
42
+ | `bun run db:migrate` | Run migrations |
43
+ | `bun run db:studio` | Open Drizzle Studio |
44
+
45
+ ## Environment Variables
46
+
47
+ Copy `.env.example` to `.env` and update the values:
48
+
49
+ - `DATABASE_URL` — PostgreSQL connection string
50
+ - `PORT` — Backend server port (default: {{backendPort}})
51
+ - `VITE_API_URL` — Frontend API base URL
52
+ {{#if hasAuth}}- `JWT_SECRET` — Secret for JWT token signing
53
+ {{/if}}
@@ -1,9 +1,8 @@
1
1
  import { Elysia } from 'elysia'
2
- import { jwt } from '@elysiajs/jwt'
3
- import { cookie } from '@elysiajs/cookie'
4
2
  import { db } from '../db/client.{{ext}}'
5
3
  import { users } from '../../drizzle/schema.{{ext}}'
6
4
  import { eq } from 'drizzle-orm'
5
+ import { authPlugin } from './plugin.{{ext}}'
7
6
  {{#if (includes authMethods "usernameAndPassword")}}
8
7
  import { usernameAndPasswordRoutes } from './providers/usernameAndPassword.{{ext}}'
9
8
  {{/if}}
@@ -14,13 +13,9 @@ import { googleRoutes } from './providers/google.{{ext}}'
14
13
  import { githubRoutes } from './providers/github.{{ext}}'
15
14
  {{/if}}
16
15
 
17
- const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'
16
+ export { authPlugin } from './plugin.{{ext}}'
18
17
 
19
- export const authPlugin = new Elysia({ name: 'auth-plugin' })
20
- .use(jwt({ name: 'jwt', secret: JWT_SECRET }))
21
- .use(cookie())
22
-
23
- export const authRoutes = new Elysia({ prefix: '/auth' })
18
+ export const authRoutes = new Elysia({ prefix: '/api/auth' })
24
19
  .use(authPlugin)
25
20
  {{#if (includes authMethods "usernameAndPassword")}}
26
21
  .use(usernameAndPasswordRoutes)
@@ -0,0 +1,9 @@
1
+ import { Elysia } from 'elysia'
2
+ import { jwt } from '@elysiajs/jwt'
3
+ import { cookie } from '@elysiajs/cookie'
4
+
5
+ const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'
6
+
7
+ export const authPlugin = new Elysia({ name: 'auth-plugin' })
8
+ .use(jwt({ name: 'jwt', secret: JWT_SECRET }))
9
+ .use(cookie())
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
3
  import { users } from '../../../drizzle/schema.{{ext}}'
4
4
  import { eq } from 'drizzle-orm'
5
- import { authPlugin } from '../index.{{ext}}'
5
+ import { authPlugin } from '../plugin.{{ext}}'
6
6
 
7
7
  const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ''
8
8
  const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ''
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
2
2
  import { db } from '../../db/client.{{ext}}'
3
3
  import { users } from '../../../drizzle/schema.{{ext}}'
4
4
  import { eq } from 'drizzle-orm'
5
- import { authPlugin } from '../index.{{ext}}'
5
+ import { authPlugin } from '../plugin.{{ext}}'
6
6
 
7
7
  const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''
8
8
  const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''