create-restack-pattern 0.1.8 → 0.2.1

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 (119) hide show
  1. package/dist/index.js +284 -74
  2. package/package.json +14 -8
  3. package/template/{DEVELOPMENT.md → base/DEVELOPMENT.md} +73 -62
  4. package/template/{backend → base/backend}/package.json +13 -10
  5. package/template/{backend → base/backend}/src/config/env.ts +2 -2
  6. package/template/{backend → base/backend}/src/domains/auth/auth.model.ts +5 -3
  7. package/template/{backend → base/backend}/src/domains/auth/auth.repo.ts +23 -5
  8. package/template/{backend → base/backend}/src/domains/auth/auth.routes.ts +77 -22
  9. package/template/{backend → base/backend}/src/domains/auth/auth.service.test.ts +51 -19
  10. package/template/{backend → base/backend}/src/domains/auth/auth.service.ts +15 -10
  11. package/template/{backend → base/backend}/src/domains/auth/auth.types.ts +1 -1
  12. package/template/{backend → base/backend}/src/domains/user/user.model.ts +2 -1
  13. package/template/{backend → base/backend}/src/domains/user/user.repo.ts +3 -2
  14. package/template/base/backend/src/domains/user/user.routes.test.ts +127 -0
  15. package/template/{backend → base/backend}/src/domains/user/user.routes.ts +19 -9
  16. package/template/{backend → base/backend}/src/domains/user/user.service.ts +8 -2
  17. package/template/{backend → base/backend}/src/hono-app.ts +6 -11
  18. package/template/base/backend/src/infra/middlewares/require-auth.test.ts +91 -0
  19. package/template/base/backend/src/infra/middlewares/require-auth.ts +63 -0
  20. package/template/base/backend/src/infra/middlewares/require-role.test.ts +66 -0
  21. package/template/base/backend/src/infra/middlewares/require-role.ts +34 -0
  22. package/template/{backend → base/backend}/src/utils/logger.ts +7 -10
  23. package/template/base/backend/src/utils/uuid.test.ts +37 -0
  24. package/template/{backend → base/backend}/tsconfig.json +4 -0
  25. package/template/base/frontend/AGENTS.md +11 -0
  26. package/template/{frontend → base/frontend}/app/(auth)/login/page.tsx +1 -1
  27. package/template/{frontend → base/frontend}/app/(auth)/register/page.tsx +1 -1
  28. package/template/{frontend → base/frontend}/app/globals.css +1 -1
  29. package/template/{frontend → base/frontend}/app/layout.tsx +9 -12
  30. package/template/{frontend → base/frontend}/app/page.tsx +2 -2
  31. package/template/{frontend → base/frontend}/app/profile/page.tsx +3 -3
  32. package/template/{frontend → base/frontend}/components/ui/button.tsx +2 -2
  33. package/template/{frontend → base/frontend}/components/ui/card.tsx +8 -23
  34. package/template/{frontend → base/frontend}/components/ui/input.tsx +2 -2
  35. package/template/{frontend → base/frontend}/components/ui/label.tsx +3 -6
  36. package/template/{frontend → base/frontend}/domains/auth/components/login-form.tsx +10 -3
  37. package/template/{frontend → base/frontend}/domains/auth/components/logout-button.tsx +2 -2
  38. package/template/{frontend → base/frontend}/domains/auth/components/register-form.tsx +10 -3
  39. package/template/{frontend → base/frontend}/domains/auth/hooks/use-login.ts +2 -2
  40. package/template/{frontend → base/frontend}/domains/auth/hooks/use-register.ts +6 -3
  41. package/template/{frontend → base/frontend}/domains/auth/services/auth.api.ts +5 -7
  42. package/template/{frontend → base/frontend}/domains/user/components/profile-card.tsx +2 -4
  43. package/template/{frontend → base/frontend}/domains/user/services/user.api.ts +2 -3
  44. package/template/base/frontend/env.ts +12 -0
  45. package/template/{frontend → base/frontend}/hooks/use-auth-mutation.ts +6 -3
  46. package/template/{frontend → base/frontend}/lib/api-client.ts +40 -5
  47. package/template/{frontend → base/frontend}/lib/utils.ts +1 -1
  48. package/template/base/frontend/next.config.ts +11 -0
  49. package/template/{frontend → base/frontend}/package.json +15 -14
  50. package/template/{frontend → base/frontend}/tsconfig.json +4 -0
  51. package/template/{gitignore → base/gitignore} +15 -0
  52. package/template/base/package.json +25 -0
  53. package/template/{packages → base/packages}/shared/package.json +1 -1
  54. package/template/{packages → base/packages}/shared/src/index.ts +1 -0
  55. package/template/{packages → base/packages}/shared/src/schemas/auth.schema.ts +8 -0
  56. package/template/{packages → base/packages}/shared/src/schemas/user.schema.ts +1 -0
  57. package/template/base/packages/shared/src/utils/uuid.ts +40 -0
  58. package/template/{packages → base/packages}/shared/tsconfig.json +26 -0
  59. package/template/base/pnpm-workspace.yaml +12 -0
  60. package/template/overlays/db-postgres/backend/.env.example +6 -0
  61. package/template/overlays/db-postgres/backend/drizzle.config.ts +11 -0
  62. package/template/overlays/db-postgres/backend/scripts/db-check.js +27 -0
  63. package/template/overlays/db-postgres/backend/src/infra/db/index.ts +6 -0
  64. package/template/overlays/db-sqlite/backend/.env.example +6 -0
  65. package/template/overlays/db-sqlite/backend/drizzle.config.ts +11 -0
  66. package/template/overlays/db-sqlite/backend/scripts/db-check.js +20 -0
  67. package/template/overlays/db-sqlite/backend/src/infra/db/index.ts +8 -0
  68. package/template/overlays/id-serial/packages/shared/src/schemas/user.schema.ts +14 -0
  69. package/template/overlays/id-uuid/packages/shared/src/schemas/user.schema.ts +14 -0
  70. package/template/overlays/models/postgres-serial/backend/src/domains/auth/auth.model.ts +12 -0
  71. package/template/overlays/models/postgres-serial/backend/src/domains/user/user.model.ts +10 -0
  72. package/template/overlays/models/postgres-uuid/backend/src/domains/auth/auth.model.ts +15 -0
  73. package/template/overlays/models/postgres-uuid/backend/src/domains/user/user.model.ts +13 -0
  74. package/template/overlays/models/sqlite-serial/backend/src/domains/auth/auth.model.ts +14 -0
  75. package/template/overlays/models/sqlite-serial/backend/src/domains/user/user.model.ts +12 -0
  76. package/template/overlays/models/sqlite-uuid/backend/src/domains/auth/auth.model.ts +17 -0
  77. package/template/overlays/models/sqlite-uuid/backend/src/domains/user/user.model.ts +15 -0
  78. package/template/backend/src/domains/user/user.routes.test.ts +0 -82
  79. package/template/backend/src/infra/middlewares/require-auth.test.ts +0 -51
  80. package/template/backend/src/infra/middlewares/require-auth.ts +0 -36
  81. package/template/frontend/AGENTS.md +0 -5
  82. package/template/frontend/next.config.ts +0 -7
  83. package/template/package.json +0 -17
  84. package/template/pnpm-workspace.yaml +0 -4
  85. package/template/{backend → base/backend}/.env.example +0 -0
  86. package/template/{backend → base/backend}/README.md +0 -0
  87. package/template/{backend → base/backend}/api/index.ts +0 -0
  88. package/template/{backend → base/backend}/drizzle/0000_grey_lethal_legion.sql +0 -0
  89. package/template/{backend → base/backend}/drizzle/meta/0000_snapshot.json +0 -0
  90. package/template/{backend → base/backend}/drizzle/meta/_journal.json +0 -0
  91. package/template/{backend → base/backend}/drizzle.config.ts +0 -0
  92. package/template/{backend → base/backend}/gitignore +0 -0
  93. package/template/{backend → base/backend}/public/.gitkeep +0 -0
  94. package/template/{backend → base/backend}/scripts/db-check.js +0 -0
  95. package/template/{backend → base/backend}/src/config/.gitkeep +0 -0
  96. package/template/{backend → base/backend}/src/domains/auth/.gitkeep +0 -0
  97. package/template/{backend → base/backend}/src/domains/user/.gitkeep +0 -0
  98. package/template/{backend → base/backend}/src/index.ts +1 -1
  99. package/template/{backend → base/backend}/src/infra/db/.gitkeep +0 -0
  100. package/template/{backend → base/backend}/src/infra/db/index.ts +0 -0
  101. package/template/{backend → base/backend}/src/infra/middlewares/.gitkeep +0 -0
  102. package/template/{backend → base/backend}/vercel.json +0 -0
  103. package/template/{frontend → base/frontend}/.env.example +0 -0
  104. package/template/{frontend → base/frontend}/CLAUDE.md +0 -0
  105. package/template/{frontend → base/frontend}/README.md +0 -0
  106. package/template/{frontend → base/frontend}/app/dashboard/page.tsx +2 -2
  107. package/template/{frontend → base/frontend}/app/icon.png +0 -0
  108. package/template/{frontend → base/frontend}/components/require-auth.tsx +1 -1
  109. package/template/{frontend → base/frontend}/components.json +0 -0
  110. package/template/{frontend → base/frontend}/domains/auth/components/.gitkeep +0 -0
  111. package/template/{frontend → base/frontend}/domains/auth/hooks/.gitkeep +0 -0
  112. package/template/{frontend → base/frontend}/domains/auth/services/.gitkeep +0 -0
  113. package/template/{frontend → base/frontend}/domains/user/components/.gitkeep +0 -0
  114. package/template/{frontend → base/frontend}/domains/user/hooks/use-profile.ts +2 -2
  115. /package/template/{frontend → base/frontend}/eslint.config.mjs +0 -0
  116. /package/template/{frontend → base/frontend}/gitignore +0 -0
  117. /package/template/{frontend → base/frontend}/postcss.config.mjs +0 -0
  118. /package/template/{frontend → base/frontend}/public/favicon.ico +0 -0
  119. /package/template/{frontend → base/frontend}/public/logo.png +0 -0
@@ -9,7 +9,7 @@ type SessionRow = typeof authSessionsTable.$inferSelect
9
9
  /**
10
10
  * Repository layer for the Auth domain.
11
11
  * Encapsulates sessions management and credential-based queries.
12
- *
12
+ *
13
13
  * Semi-DDD rules:
14
14
  * - Direct database interactions (Drizzle client) must live only inside `.repo.ts` files.
15
15
  * - Under cross-domain rules, the Auth domain can query the User domain's tables (e.g. `usersTable`)
@@ -24,13 +24,24 @@ export const authRepo = {
24
24
  return user ?? null
25
25
  },
26
26
 
27
- create: async (data: { name: string; email: string; password: string }) => {
27
+ create: async (data: {
28
+ name: string
29
+ email: string
30
+ password: string
31
+ role?: string
32
+ }): Promise<UserRow> => {
28
33
  const [user] = await db.insert(usersTable).values(data).returning()
34
+ if (!user) throw new Error("Failed to create user record")
29
35
  return user
30
36
  },
31
37
 
32
- createSession: async (data: { userId: number; refreshTokenHash: string; expiresAt: Date }) => {
38
+ createSession: async (data: {
39
+ userId: number
40
+ refreshTokenHash: string
41
+ expiresAt: Date
42
+ }): Promise<SessionRow> => {
33
43
  const [session] = await db.insert(authSessionsTable).values(data).returning()
44
+ if (!session) throw new Error("Failed to create auth session")
34
45
  return session
35
46
  },
36
47
 
@@ -40,12 +51,19 @@ export const authRepo = {
40
51
  consumeSessionByHash: async (refreshTokenHash: string): Promise<SessionRow | null> => {
41
52
  const [session] = await db
42
53
  .delete(authSessionsTable)
43
- .where(and(eq(authSessionsTable.refreshTokenHash, refreshTokenHash), gt(authSessionsTable.expiresAt, new Date())))
54
+ .where(
55
+ and(
56
+ eq(authSessionsTable.refreshTokenHash, refreshTokenHash),
57
+ gt(authSessionsTable.expiresAt, new Date()),
58
+ ),
59
+ )
44
60
  .returning()
45
61
  return session ?? null
46
62
  },
47
63
 
48
64
  deleteSessionByHash: async (refreshTokenHash: string) => {
49
- await db.delete(authSessionsTable).where(eq(authSessionsTable.refreshTokenHash, refreshTokenHash))
65
+ await db
66
+ .delete(authSessionsTable)
67
+ .where(eq(authSessionsTable.refreshTokenHash, refreshTokenHash))
50
68
  },
51
69
  }
@@ -1,27 +1,34 @@
1
- import { Hono } from "hono"
2
- import type { Context } from "hono"
3
- import { setCookie, deleteCookie, getCookie } from "hono/cookie"
4
1
  import { zValidator } from "@hono/zod-validator"
5
2
  import { LoginSchema, RegisterSchema } from "@restack/shared"
6
- import { authService, ACCESS_TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS } from "./auth.service.js"
3
+ import type { Context } from "hono"
4
+ import { Hono } from "hono"
5
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie"
7
6
  import { env } from "../../config/env.js"
8
- import { logger, type AppVariables } from "../../utils/logger.js"
7
+ import { type AppVariables, logger } from "../../utils/logger.js"
8
+ import { ACCESS_TOKEN_TTL_SECONDS, authService, REFRESH_TOKEN_TTL_SECONDS } from "./auth.service.js"
9
9
 
10
10
  /**
11
11
  * Routing layer for the Auth domain.
12
12
  * Defines the public endpoints for login, registration, logout, and token rotation.
13
- *
13
+ *
14
14
  * Semi-DDD rules:
15
15
  * - `.routes.ts` files define HTTP endpoints, validate request payloads, and apply CORS/CSRF configurations.
16
16
  * - Endpoints should delegate business flow execution directly to the service layer.
17
17
  */
18
18
  const isProduction = env.NODE_ENV === "production"
19
- const sameSite = (env.COOKIE_SAME_SITE || (isProduction ? "None" : "Lax")) as "Lax" | "None" | "Strict"
19
+ const sameSite = (env.COOKIE_SAME_SITE || (isProduction ? "None" : "Lax")) as
20
+ | "Lax"
21
+ | "None"
22
+ | "Strict"
20
23
  const secure = sameSite === "None" ? true : isProduction
21
24
 
22
25
  const domainOpt = env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}
23
26
  const deleteCookieOpts = (path: string) => ({ path, secure, sameSite, ...domainOpt })
24
- const cookieOpts = (path: string, maxAge: number) => ({ ...deleteCookieOpts(path), httpOnly: true, maxAge })
27
+ const cookieOpts = (path: string, maxAge: number) => ({
28
+ ...deleteCookieOpts(path),
29
+ httpOnly: true,
30
+ maxAge,
31
+ })
25
32
 
26
33
  const accessTokenCookieOptions = cookieOpts("/", ACCESS_TOKEN_TTL_SECONDS)
27
34
  const refreshTokenCookieOptions = cookieOpts("/", REFRESH_TOKEN_TTL_SECONDS)
@@ -47,10 +54,18 @@ export const authRoutes = new Hono<{ Variables: AppVariables }>()
47
54
 
48
55
  setAuthCookies(c, tokens)
49
56
 
50
- return c.json({ user }, 201)
51
- } catch (e: any) {
52
- reqLogger.error({ err: e.message }, "Register failed")
53
- return c.json({ error: e.message }, 400)
57
+ return c.json(
58
+ {
59
+ user,
60
+ accessToken: tokens.accessToken,
61
+ refreshToken: tokens.refreshToken,
62
+ },
63
+ 201,
64
+ )
65
+ } catch (e: unknown) {
66
+ const msg = e instanceof Error ? e.message : String(e)
67
+ reqLogger.error({ err: msg }, "Register failed")
68
+ return c.json({ error: msg }, 400)
54
69
  }
55
70
  })
56
71
  .post("/login", zValidator("json", LoginSchema), async (c) => {
@@ -63,32 +78,72 @@ export const authRoutes = new Hono<{ Variables: AppVariables }>()
63
78
 
64
79
  setAuthCookies(c, tokens)
65
80
 
66
- return c.json({ user })
67
- } catch (e: any) {
68
- reqLogger.error({ err: e.message }, "Login failed")
69
- return c.json({ error: e.message }, 401)
81
+ return c.json(
82
+ {
83
+ user,
84
+ accessToken: tokens.accessToken,
85
+ refreshToken: tokens.refreshToken,
86
+ },
87
+ 200,
88
+ )
89
+ } catch (e: unknown) {
90
+ const msg = e instanceof Error ? e.message : String(e)
91
+ reqLogger.error({ err: msg }, "Login failed")
92
+ return c.json({ error: msg }, 401)
70
93
  }
71
94
  })
72
95
  .post("/refresh", async (c) => {
73
96
  const reqLogger = c.get("logger") || logger
74
- const refreshToken = getCookie(c, "refresh_token")
97
+ let refreshToken = getCookie(c, "refresh_token")
98
+
99
+ if (!refreshToken) {
100
+ try {
101
+ const body = (await c.req.json().catch(() => null)) as { refreshToken?: string } | null
102
+ if (body && typeof body.refreshToken === "string" && body.refreshToken.length > 0) {
103
+ refreshToken = body.refreshToken
104
+ }
105
+ } catch {
106
+ // Ignore body parsing failures
107
+ }
108
+ }
109
+
75
110
  if (!refreshToken) return c.json({ error: "Missing refresh token" }, 401)
76
111
 
77
112
  try {
78
113
  reqLogger.info("Attempting token rotation")
79
114
  const tokens = await authService.refresh(refreshToken)
80
115
  setAuthCookies(c, tokens)
81
- return c.json({ user: tokens.user })
82
- } catch (e: any) {
116
+ return c.json(
117
+ {
118
+ user: tokens.user,
119
+ accessToken: tokens.accessToken,
120
+ refreshToken: tokens.refreshToken,
121
+ },
122
+ 200,
123
+ )
124
+ } catch (e: unknown) {
125
+ const msg = e instanceof Error ? e.message : String(e)
83
126
  clearAuthCookies(c)
84
- reqLogger.error({ err: e.message }, "Refresh failed")
127
+ reqLogger.error({ err: msg }, "Refresh failed")
85
128
  return c.json({ error: "Invalid refresh token" }, 401)
86
129
  }
87
130
  })
88
131
  .post("/logout", async (c) => {
89
- const refreshToken = getCookie(c, "refresh_token")
132
+ let refreshToken = getCookie(c, "refresh_token")
133
+
134
+ if (!refreshToken) {
135
+ try {
136
+ const body = (await c.req.json().catch(() => null)) as { refreshToken?: string } | null
137
+ if (body && typeof body.refreshToken === "string" && body.refreshToken.length > 0) {
138
+ refreshToken = body.refreshToken
139
+ }
140
+ } catch {
141
+ // Ignore body parsing failures
142
+ }
143
+ }
144
+
90
145
  if (refreshToken) await authService.revokeSession(refreshToken)
91
146
 
92
147
  clearAuthCookies(c)
93
- return c.json({ success: true })
148
+ return c.json({ success: true }, 200)
94
149
  })
@@ -1,8 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest"
2
1
  import bcrypt from "bcryptjs"
3
- import { authService } from "./auth.service.js"
4
- import { authRepo } from "./auth.repo.js"
2
+ import { beforeEach, describe, expect, it, vi } from "vitest"
5
3
  import { userRepo } from "../user/user.repo.js"
4
+ import { authRepo } from "./auth.repo.js"
5
+ import { authService } from "./auth.service.js"
6
6
 
7
7
  vi.mock("./auth.repo.js", () => ({
8
8
  authRepo: {
@@ -24,16 +24,32 @@ vi.mock("../user/user.repo.js", () => ({
24
24
  const mockedAuthRepo = vi.mocked(authRepo, true)
25
25
  const mockedUserRepo = vi.mocked(userRepo, true)
26
26
 
27
- const dbUser = (overrides: Partial<{ id: number; name: string; email: string; password: string }> = {}) => ({
27
+ const dbUser = (
28
+ overrides: Partial<{
29
+ id: number
30
+ name: string
31
+ email: string
32
+ password: string
33
+ role: string
34
+ }> = {},
35
+ ) => ({
28
36
  id: 1,
29
37
  name: "X",
30
38
  email: "a@b.com",
31
39
  password: "hash",
40
+ role: "user",
32
41
  createdAt: new Date(),
33
42
  ...overrides,
34
43
  })
35
44
 
36
- const dbSession = (overrides: Partial<{ id: number; userId: number; refreshTokenHash: string; expiresAt: Date }> = {}) => ({
45
+ const dbSession = (
46
+ overrides: Partial<{
47
+ id: number
48
+ userId: number
49
+ refreshTokenHash: string
50
+ expiresAt: Date
51
+ }> = {},
52
+ ) => ({
37
53
  id: 1,
38
54
  userId: 1,
39
55
  refreshTokenHash: "hash",
@@ -50,7 +66,7 @@ describe("authService.register", () => {
50
66
  it("throws if the email is already in use", async () => {
51
67
  mockedAuthRepo.findByEmail.mockResolvedValue(dbUser())
52
68
  await expect(
53
- authService.register({ name: "X", email: "a@b.com", password: "password123" })
69
+ authService.register({ name: "X", email: "a@b.com", password: "password123" }),
54
70
  ).rejects.toThrow("Email already in use")
55
71
  })
56
72
 
@@ -58,9 +74,13 @@ describe("authService.register", () => {
58
74
  mockedAuthRepo.findByEmail.mockResolvedValue(null)
59
75
  mockedAuthRepo.create.mockImplementation(async (data) => dbUser(data))
60
76
 
61
- const user = await authService.register({ name: "X", email: " A@B.com ", password: "password123" })
77
+ const user = await authService.register({
78
+ name: "X",
79
+ email: " A@B.com ",
80
+ password: "password123",
81
+ })
62
82
 
63
- expect(user).toEqual({ id: 1, name: "X", email: "a@b.com" })
83
+ expect(user).toEqual({ id: 1, name: "X", email: "a@b.com", role: "user" })
64
84
  const createdArg = mockedAuthRepo.create.mock.calls[0][0]
65
85
  expect(createdArg.email).toBe("a@b.com")
66
86
  expect(createdArg.password).not.toBe("password123")
@@ -70,24 +90,24 @@ describe("authService.register", () => {
70
90
  describe("authService.login", () => {
71
91
  it("throws on an unknown email", async () => {
72
92
  mockedAuthRepo.findByEmail.mockResolvedValue(null)
73
- await expect(authService.login({ email: "nope@example.com", password: "whatever1" })).rejects.toThrow(
74
- "Invalid credentials"
75
- )
93
+ await expect(
94
+ authService.login({ email: "nope@example.com", password: "whatever1" }),
95
+ ).rejects.toThrow("Invalid credentials")
76
96
  })
77
97
 
78
98
  it("throws on a wrong password", async () => {
79
99
  const passwordHash = await bcrypt.hash("correct-password", 4)
80
100
  mockedAuthRepo.findByEmail.mockResolvedValue(dbUser({ password: passwordHash }))
81
- await expect(authService.login({ email: "a@b.com", password: "wrong-password" })).rejects.toThrow(
82
- "Invalid credentials"
83
- )
101
+ await expect(
102
+ authService.login({ email: "a@b.com", password: "wrong-password" }),
103
+ ).rejects.toThrow("Invalid credentials")
84
104
  })
85
105
 
86
106
  it("returns the public user shape on correct credentials", async () => {
87
107
  const passwordHash = await bcrypt.hash("correct-password", 4)
88
108
  mockedAuthRepo.findByEmail.mockResolvedValue(dbUser({ password: passwordHash }))
89
109
  const user = await authService.login({ email: "a@b.com", password: "correct-password" })
90
- expect(user).toEqual({ id: 1, name: "X", email: "a@b.com" })
110
+ expect(user).toEqual({ id: 1, name: "X", email: "a@b.com", role: "user" })
91
111
  })
92
112
  })
93
113
 
@@ -104,7 +124,7 @@ describe("authService.refresh", () => {
104
124
 
105
125
  const result = await authService.refresh("old-token")
106
126
 
107
- expect(result.user).toEqual({ id: 1, name: "X", email: "a@b.com" })
127
+ expect(result.user).toEqual({ id: 1, name: "X", email: "a@b.com", role: "user" })
108
128
  expect(typeof result.accessToken).toBe("string")
109
129
  expect(typeof result.refreshToken).toBe("string")
110
130
  expect(mockedAuthRepo.createSession).toHaveBeenCalledTimes(1)
@@ -127,17 +147,29 @@ describe("authService.revokeSession", () => {
127
147
  describe("authService.verifyAccessToken", () => {
128
148
  it("round-trips a token issued by issueTokenPair", async () => {
129
149
  mockedAuthRepo.createSession.mockResolvedValue(dbSession())
130
- const { accessToken } = await authService.issueTokenPair({ id: 1, name: "X", email: "a@b.com" })
150
+ const { accessToken } = await authService.issueTokenPair({
151
+ id: 1,
152
+ name: "X",
153
+ email: "a@b.com",
154
+ role: "user",
155
+ })
131
156
  const payload = await authService.verifyAccessToken(accessToken)
132
157
  expect(payload.sub).toBe("1")
133
158
  expect(payload.email).toBe("a@b.com")
159
+ expect(payload.role).toBe("user")
134
160
  })
135
161
 
136
162
  it("rejects a tampered token", async () => {
137
163
  mockedAuthRepo.createSession.mockResolvedValue(dbSession())
138
- const { accessToken } = await authService.issueTokenPair({ id: 1, name: "X", email: "a@b.com" })
164
+ const { accessToken } = await authService.issueTokenPair({
165
+ id: 1,
166
+ name: "X",
167
+ email: "a@b.com",
168
+ role: "user",
169
+ })
139
170
  const i = accessToken.indexOf(".") + 5
140
- const tampered = accessToken.slice(0, i) + (accessToken[i] === "a" ? "b" : "a") + accessToken.slice(i + 1)
171
+ const tampered =
172
+ accessToken.slice(0, i) + (accessToken[i] === "a" ? "b" : "a") + accessToken.slice(i + 1)
141
173
  await expect(authService.verifyAccessToken(tampered)).rejects.toThrow()
142
174
  })
143
175
  })
@@ -1,16 +1,16 @@
1
- import bcrypt from "bcryptjs"
2
- import { SignJWT, jwtVerify } from "jose"
3
- import { randomBytes, createHash } from "node:crypto"
1
+ import { createHash, randomBytes } from "node:crypto"
4
2
  import type { AuthUser, LoginInput, RegisterInput } from "@restack/shared"
3
+ import bcrypt from "bcryptjs"
4
+ import { jwtVerify, SignJWT } from "jose"
5
5
  import { env } from "../../config/env.js"
6
- import { authRepo } from "./auth.repo.js"
7
6
  import { userRepo } from "../user/user.repo.js"
7
+ import { authRepo } from "./auth.repo.js"
8
8
  import type { AuthTokenPayload } from "./auth.types.js"
9
9
 
10
10
  /**
11
11
  * Service layer for the Auth domain.
12
12
  * Houses core validation and cryptographic flows (JWT token signing, refresh token generation, hashing).
13
- *
13
+ *
14
14
  * Semi-DDD rules:
15
15
  * - `.service.ts` files encapsulate business rules and complex workflows (e.g. session issuance & rotation).
16
16
  * - They must never call Drizzle ORM directly; always interact through the repo layer.
@@ -24,12 +24,17 @@ const normalizeEmail = (email: string) => email.trim().toLowerCase()
24
24
  export const ACCESS_TOKEN_TTL_SECONDS = 60 * 15 // 15 minutes
25
25
  export const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days
26
26
 
27
- const buildTokenPayload = (user: { id: number; email: string }): AuthTokenPayload => ({
27
+ const buildTokenPayload = (user: {
28
+ id: number
29
+ email: string
30
+ role: string
31
+ }): AuthTokenPayload => ({
28
32
  sub: String(user.id),
29
33
  email: user.email,
34
+ role: user.role,
30
35
  })
31
36
 
32
- const signAccessToken = (user: { id: number; email: string }) =>
37
+ const signAccessToken = (user: { id: number; email: string; role: string }) =>
33
38
  new SignJWT(buildTokenPayload(user))
34
39
  .setProtectedHeader({ alg: "HS256" })
35
40
  .setIssuedAt()
@@ -60,7 +65,7 @@ export const authService = {
60
65
 
61
66
  const password = await bcrypt.hash(data.password, BCRYPT_COST)
62
67
  const user = await authRepo.create({ ...data, email, password })
63
- return { id: user.id, name: user.name, email: user.email }
68
+ return { id: user.id, name: user.name, email: user.email, role: user.role }
64
69
  },
65
70
 
66
71
  login: async (data: LoginInput): Promise<AuthUser> => {
@@ -68,7 +73,7 @@ export const authService = {
68
73
  const valid = await bcrypt.compare(data.password, user ? user.password : DUMMY_HASH)
69
74
  if (!user || !valid) throw new Error("Invalid credentials")
70
75
 
71
- return { id: user.id, name: user.name, email: user.email }
76
+ return { id: user.id, name: user.name, email: user.email, role: user.role }
72
77
  },
73
78
 
74
79
  issueTokenPair,
@@ -83,7 +88,7 @@ export const authService = {
83
88
  const user = await userRepo.findById(session.userId)
84
89
  if (!user) throw new Error("Invalid refresh token")
85
90
 
86
- return issueTokenPair({ id: user.id, name: user.name, email: user.email })
91
+ return issueTokenPair({ id: user.id, name: user.name, email: user.email, role: user.role })
87
92
  },
88
93
 
89
94
  revokeSession: async (refreshToken: string) => {
@@ -3,5 +3,5 @@ import type { JWTPayload } from "jose"
3
3
  export interface AuthTokenPayload extends JWTPayload {
4
4
  sub: string
5
5
  email: string
6
- // Add fields here when the user model supports them, e.g. role, permissions.
6
+ role: string
7
7
  }
@@ -1,4 +1,4 @@
1
- import { pgTable, text, timestamp, serial } from "drizzle-orm/pg-core"
1
+ import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"
2
2
 
3
3
  /**
4
4
  * Users Table Schema.
@@ -10,5 +10,6 @@ export const usersTable = pgTable("users", {
10
10
  name: text("name").notNull(),
11
11
  email: text("email").notNull().unique(),
12
12
  password: text("password").notNull(),
13
+ role: text("role").default("user").notNull(),
13
14
  createdAt: timestamp("created_at").notNull().defaultNow(),
14
15
  })
@@ -1,3 +1,4 @@
1
+ import type { UpdateProfileInput } from "@restack/shared"
1
2
  import { eq } from "drizzle-orm"
2
3
  import { db } from "../../infra/db/index.js"
3
4
  import { usersTable } from "./user.model.js"
@@ -7,7 +8,7 @@ export type UserRow = typeof usersTable.$inferSelect
7
8
  /**
8
9
  * Repository layer for the User domain.
9
10
  * Encapsulates all direct database queries for users.
10
- *
11
+ *
11
12
  * Semi-DDD rules:
12
13
  * - Direct database interactions (Drizzle client) must live only inside `.repo.ts` files.
13
14
  * - Under cross-domain rules, the Auth domain can import from this repo (e.g. user lookup by ID/email),
@@ -22,7 +23,7 @@ export const userRepo = {
22
23
  return user ?? null
23
24
  },
24
25
 
25
- updateProfile: async (id: number, data: { name?: string }): Promise<UserRow | null> => {
26
+ updateProfile: async (id: number, data: UpdateProfileInput): Promise<UserRow | null> => {
26
27
  const [user] = await db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning()
27
28
  return user ?? null
28
29
  },
@@ -0,0 +1,127 @@
1
+ import { eq } from "drizzle-orm"
2
+ import fc from "fast-check"
3
+ import { testClient } from "hono/testing"
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest"
5
+ import { db } from "../../infra/db/index.js"
6
+ import { authService } from "../auth/auth.service.js"
7
+ import { usersTable } from "./user.model.js"
8
+ import { userRoutes } from "./user.routes.js"
9
+
10
+ const TEST_EMAIL = `user-routes-test-${Date.now()}@example.com`
11
+ let accessToken: string
12
+ let userId: number
13
+
14
+ const client = testClient(userRoutes)
15
+
16
+ beforeAll(async () => {
17
+ const user = await authService.register({
18
+ name: "Route Test",
19
+ email: TEST_EMAIL,
20
+ password: "password123",
21
+ })
22
+ userId = user.id
23
+ const tokens = await authService.issueTokenPair(user)
24
+ accessToken = tokens.accessToken
25
+ })
26
+
27
+ afterAll(async () => {
28
+ await db.delete(usersTable).where(eq(usersTable.id, userId))
29
+ })
30
+
31
+ const authHeaders = (token?: string) => ({
32
+ ...(token ? { Cookie: `access_token=${token}` } : {}),
33
+ })
34
+
35
+ describe("GET /me", () => {
36
+ it("returns 401 without a token", async () => {
37
+ const res = await client.me.$get()
38
+ expect(res.status).toBe(401)
39
+ })
40
+
41
+ it("returns the profile with a valid token", async () => {
42
+ const res = await client.me.$get({}, { headers: authHeaders(accessToken) })
43
+ expect(res.status).toBe(200)
44
+ const body = await res.json()
45
+ if ("error" in body) throw new Error(String(body.error))
46
+ expect(body.user.email).toBe(TEST_EMAIL)
47
+ })
48
+ })
49
+
50
+ describe("PUT /profile", () => {
51
+ it("returns 401 without a token", async () => {
52
+ const res = await client.profile.$put({ json: { name: "x" } })
53
+ expect(res.status).toBe(401)
54
+ })
55
+
56
+ it("updates the name with a valid token and body", async () => {
57
+ const res = await client.profile.$put(
58
+ { json: { name: "Updated Name" } },
59
+ { headers: authHeaders(accessToken) },
60
+ )
61
+ expect(res.status).toBe(200)
62
+ const body = await res.json()
63
+ if ("error" in body) throw new Error(String(body.error))
64
+ expect(body.user.name).toBe("Updated Name")
65
+ })
66
+
67
+ it("rejects an invalid body (empty name)", async () => {
68
+ const res = await client.profile.$put(
69
+ { json: { name: "" } },
70
+ { headers: authHeaders(accessToken) },
71
+ )
72
+ expect(res.status).toBe(400)
73
+ })
74
+
75
+ it("treats an empty body as a no-op instead of erroring", async () => {
76
+ const res = await client.profile.$put({ json: {} }, { headers: authHeaders(accessToken) })
77
+ expect(res.status).toBe(200)
78
+ const body = await res.json()
79
+ if ("error" in body) throw new Error(String(body.error))
80
+ expect(body.user.email).toBe(TEST_EMAIL)
81
+ })
82
+
83
+ it("never returns a 500 crash on arbitrary string payloads (property fuzzing)", async () => {
84
+ await fc.assert(
85
+ fc.asyncProperty(fc.string(), async (name) => {
86
+ const res = await client.profile.$put(
87
+ { json: { name } },
88
+ { headers: authHeaders(accessToken) },
89
+ )
90
+ // Must either succeed (200) or cleanly reject invalid input (400), never crash (500)
91
+ expect([200, 400]).toContain(res.status)
92
+ }),
93
+ { numRuns: 30 },
94
+ )
95
+ })
96
+ })
97
+
98
+ describe("GET /admin (RBAC Protected Route)", () => {
99
+ it("returns 401 without a token", async () => {
100
+ const res = await client.admin.$get()
101
+ expect(res.status).toBe(401)
102
+ })
103
+
104
+ it("returns 403 when user has default 'user' role", async () => {
105
+ const res = await client.admin.$get({}, { headers: authHeaders(accessToken) })
106
+ expect(res.status).toBe(403)
107
+ const body = (await res.json()) as { error?: string; message?: string }
108
+ expect(body.error).toBe("Forbidden: insufficient permissions")
109
+ })
110
+
111
+ it("returns 200 when user has 'admin' role (via Bearer token)", async () => {
112
+ const adminTokens = await authService.issueTokenPair({
113
+ id: userId,
114
+ name: "Admin User",
115
+ email: TEST_EMAIL,
116
+ role: "admin",
117
+ })
118
+ const res = await client.admin.$get(
119
+ {},
120
+ { headers: { Authorization: `Bearer ${adminTokens.accessToken}` } },
121
+ )
122
+ expect(res.status).toBe(200)
123
+ const body = await res.json()
124
+ if ("error" in body) throw new Error(String(body.error))
125
+ expect(body.message).toBe("Welcome to the admin area")
126
+ })
127
+ })
@@ -1,21 +1,26 @@
1
- import { Hono } from "hono"
2
- import type { Context } from "hono"
3
1
  import { zValidator } from "@hono/zod-validator"
4
2
  import { UpdateProfileSchema } from "@restack/shared"
3
+ import type { Context } from "hono"
4
+ import { Hono } from "hono"
5
5
  import { requireAuth } from "../../infra/middlewares/require-auth.js"
6
- import { userService, UserNotFoundError } from "./user.service.js"
7
- import { logger, type AppVariables } from "../../utils/logger.js"
6
+ import { requireRole } from "../../infra/middlewares/require-role.js"
7
+ import { type AppVariables, logger } from "../../utils/logger.js"
8
+ import { UserNotFoundError, userService } from "./user.service.js"
8
9
 
9
10
  /**
10
11
  * Routing layer for the User domain.
11
12
  * Configures the router and defines endpoints with appropriate middlewares.
12
- *
13
+ *
13
14
  * Semi-DDD rules:
14
15
  * - `.routes.ts` files define HTTP endpoints, parse inputs using schema validators (zValidator),
15
- * apply guards (requireAuth), and serialize JSON responses.
16
+ * apply guards (requireAuth, requireRole), and serialize JSON responses.
16
17
  * - Routing code should never implement business logic directly; it must delegate to the service layer.
17
18
  */
18
- const handleProfileError = (c: Context<{ Variables: AppVariables }>, e: unknown, action: string) => {
19
+ const handleProfileError = (
20
+ c: Context<{ Variables: AppVariables }>,
21
+ e: unknown,
22
+ action: string,
23
+ ) => {
19
24
  if (e instanceof UserNotFoundError) return c.json({ error: e.message }, 404)
20
25
  const reqLogger = c.get("logger") || logger
21
26
  reqLogger.error({ err: e instanceof Error ? e.message : String(e) }, action)
@@ -30,7 +35,7 @@ export const userRoutes = new Hono<{ Variables: AppVariables }>()
30
35
  const user = c.get("user")
31
36
  if (!user) return c.json({ error: "Unauthorized" }, 401)
32
37
  const profile = await userService.getProfile(Number(user.sub))
33
- return c.json({ user: profile })
38
+ return c.json({ user: profile }, 200)
34
39
  } catch (e) {
35
40
  return handleProfileError(c, e, "Get profile failed")
36
41
  }
@@ -43,8 +48,13 @@ export const userRoutes = new Hono<{ Variables: AppVariables }>()
43
48
  const user = c.get("user")
44
49
  if (!user) return c.json({ error: "Unauthorized" }, 401)
45
50
  const profile = await userService.updateProfile(Number(user.sub), body)
46
- return c.json({ user: profile })
51
+ return c.json({ user: profile }, 200)
47
52
  } catch (e) {
48
53
  return handleProfileError(c, e, "Update profile failed")
49
54
  }
50
55
  })
56
+ .get("/admin", requireAuth, requireRole("admin"), async (c) => {
57
+ const reqLogger = c.get("logger") || logger
58
+ reqLogger.info("Admin route accessed")
59
+ return c.json({ message: "Welcome to the admin area", user: c.get("user") }, 200)
60
+ })
@@ -7,16 +7,22 @@ export class UserNotFoundError extends Error {}
7
7
  * Maps database user object into the public, safe schema shape.
8
8
  * Always filters out sensitive columns like password hashes.
9
9
  */
10
- const toPublicUser = (user: { id: number; name: string; email: string }): AuthUser => ({
10
+ const toPublicUser = (user: {
11
+ id: number
12
+ name: string
13
+ email: string
14
+ role: string
15
+ }): AuthUser => ({
11
16
  id: user.id,
12
17
  name: user.name,
13
18
  email: user.email,
19
+ role: user.role,
14
20
  })
15
21
 
16
22
  /**
17
23
  * Service layer for the User domain.
18
24
  * Contains core business logic and validations.
19
- *
25
+ *
20
26
  * Semi-DDD rules:
21
27
  * - `.service.ts` encapsulates business logic, mappings, and validations.
22
28
  * - It must never interact with the database client directly; always query through the `.repo.ts` layer.