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.
- package/dist/index.js +284 -74
- package/package.json +14 -8
- package/template/{DEVELOPMENT.md → base/DEVELOPMENT.md} +73 -62
- package/template/{backend → base/backend}/package.json +13 -10
- package/template/{backend → base/backend}/src/config/env.ts +2 -2
- package/template/{backend → base/backend}/src/domains/auth/auth.model.ts +5 -3
- package/template/{backend → base/backend}/src/domains/auth/auth.repo.ts +23 -5
- package/template/{backend → base/backend}/src/domains/auth/auth.routes.ts +77 -22
- package/template/{backend → base/backend}/src/domains/auth/auth.service.test.ts +51 -19
- package/template/{backend → base/backend}/src/domains/auth/auth.service.ts +15 -10
- package/template/{backend → base/backend}/src/domains/auth/auth.types.ts +1 -1
- package/template/{backend → base/backend}/src/domains/user/user.model.ts +2 -1
- package/template/{backend → base/backend}/src/domains/user/user.repo.ts +3 -2
- package/template/base/backend/src/domains/user/user.routes.test.ts +127 -0
- package/template/{backend → base/backend}/src/domains/user/user.routes.ts +19 -9
- package/template/{backend → base/backend}/src/domains/user/user.service.ts +8 -2
- package/template/{backend → base/backend}/src/hono-app.ts +6 -11
- package/template/base/backend/src/infra/middlewares/require-auth.test.ts +91 -0
- package/template/base/backend/src/infra/middlewares/require-auth.ts +63 -0
- package/template/base/backend/src/infra/middlewares/require-role.test.ts +66 -0
- package/template/base/backend/src/infra/middlewares/require-role.ts +34 -0
- package/template/{backend → base/backend}/src/utils/logger.ts +7 -10
- package/template/base/backend/src/utils/uuid.test.ts +37 -0
- package/template/{backend → base/backend}/tsconfig.json +4 -0
- package/template/base/frontend/AGENTS.md +11 -0
- package/template/{frontend → base/frontend}/app/(auth)/login/page.tsx +1 -1
- package/template/{frontend → base/frontend}/app/(auth)/register/page.tsx +1 -1
- package/template/{frontend → base/frontend}/app/globals.css +1 -1
- package/template/{frontend → base/frontend}/app/layout.tsx +9 -12
- package/template/{frontend → base/frontend}/app/page.tsx +2 -2
- package/template/{frontend → base/frontend}/app/profile/page.tsx +3 -3
- package/template/{frontend → base/frontend}/components/ui/button.tsx +2 -2
- package/template/{frontend → base/frontend}/components/ui/card.tsx +8 -23
- package/template/{frontend → base/frontend}/components/ui/input.tsx +2 -2
- package/template/{frontend → base/frontend}/components/ui/label.tsx +3 -6
- package/template/{frontend → base/frontend}/domains/auth/components/login-form.tsx +10 -3
- package/template/{frontend → base/frontend}/domains/auth/components/logout-button.tsx +2 -2
- package/template/{frontend → base/frontend}/domains/auth/components/register-form.tsx +10 -3
- package/template/{frontend → base/frontend}/domains/auth/hooks/use-login.ts +2 -2
- package/template/{frontend → base/frontend}/domains/auth/hooks/use-register.ts +6 -3
- package/template/{frontend → base/frontend}/domains/auth/services/auth.api.ts +5 -7
- package/template/{frontend → base/frontend}/domains/user/components/profile-card.tsx +2 -4
- package/template/{frontend → base/frontend}/domains/user/services/user.api.ts +2 -3
- package/template/base/frontend/env.ts +12 -0
- package/template/{frontend → base/frontend}/hooks/use-auth-mutation.ts +6 -3
- package/template/{frontend → base/frontend}/lib/api-client.ts +40 -5
- package/template/{frontend → base/frontend}/lib/utils.ts +1 -1
- package/template/base/frontend/next.config.ts +11 -0
- package/template/{frontend → base/frontend}/package.json +15 -14
- package/template/{frontend → base/frontend}/tsconfig.json +4 -0
- package/template/{gitignore → base/gitignore} +15 -0
- package/template/base/package.json +25 -0
- package/template/{packages → base/packages}/shared/package.json +1 -1
- package/template/{packages → base/packages}/shared/src/index.ts +1 -0
- package/template/{packages → base/packages}/shared/src/schemas/auth.schema.ts +8 -0
- package/template/{packages → base/packages}/shared/src/schemas/user.schema.ts +1 -0
- package/template/base/packages/shared/src/utils/uuid.ts +40 -0
- package/template/{packages → base/packages}/shared/tsconfig.json +26 -0
- package/template/base/pnpm-workspace.yaml +12 -0
- package/template/overlays/db-postgres/backend/.env.example +6 -0
- package/template/overlays/db-postgres/backend/drizzle.config.ts +11 -0
- package/template/overlays/db-postgres/backend/scripts/db-check.js +27 -0
- package/template/overlays/db-postgres/backend/src/infra/db/index.ts +6 -0
- package/template/overlays/db-sqlite/backend/.env.example +6 -0
- package/template/overlays/db-sqlite/backend/drizzle.config.ts +11 -0
- package/template/overlays/db-sqlite/backend/scripts/db-check.js +20 -0
- package/template/overlays/db-sqlite/backend/src/infra/db/index.ts +8 -0
- package/template/overlays/id-serial/packages/shared/src/schemas/user.schema.ts +14 -0
- package/template/overlays/id-uuid/packages/shared/src/schemas/user.schema.ts +14 -0
- package/template/overlays/models/postgres-serial/backend/src/domains/auth/auth.model.ts +12 -0
- package/template/overlays/models/postgres-serial/backend/src/domains/user/user.model.ts +10 -0
- package/template/overlays/models/postgres-uuid/backend/src/domains/auth/auth.model.ts +15 -0
- package/template/overlays/models/postgres-uuid/backend/src/domains/user/user.model.ts +13 -0
- package/template/overlays/models/sqlite-serial/backend/src/domains/auth/auth.model.ts +14 -0
- package/template/overlays/models/sqlite-serial/backend/src/domains/user/user.model.ts +12 -0
- package/template/overlays/models/sqlite-uuid/backend/src/domains/auth/auth.model.ts +17 -0
- package/template/overlays/models/sqlite-uuid/backend/src/domains/user/user.model.ts +15 -0
- package/template/backend/src/domains/user/user.routes.test.ts +0 -82
- package/template/backend/src/infra/middlewares/require-auth.test.ts +0 -51
- package/template/backend/src/infra/middlewares/require-auth.ts +0 -36
- package/template/frontend/AGENTS.md +0 -5
- package/template/frontend/next.config.ts +0 -7
- package/template/package.json +0 -17
- package/template/pnpm-workspace.yaml +0 -4
- package/template/{backend → base/backend}/.env.example +0 -0
- package/template/{backend → base/backend}/README.md +0 -0
- package/template/{backend → base/backend}/api/index.ts +0 -0
- package/template/{backend → base/backend}/drizzle/0000_grey_lethal_legion.sql +0 -0
- package/template/{backend → base/backend}/drizzle/meta/0000_snapshot.json +0 -0
- package/template/{backend → base/backend}/drizzle/meta/_journal.json +0 -0
- package/template/{backend → base/backend}/drizzle.config.ts +0 -0
- package/template/{backend → base/backend}/gitignore +0 -0
- package/template/{backend → base/backend}/public/.gitkeep +0 -0
- package/template/{backend → base/backend}/scripts/db-check.js +0 -0
- package/template/{backend → base/backend}/src/config/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/domains/auth/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/domains/user/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/index.ts +1 -1
- package/template/{backend → base/backend}/src/infra/db/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/infra/db/index.ts +0 -0
- package/template/{backend → base/backend}/src/infra/middlewares/.gitkeep +0 -0
- package/template/{backend → base/backend}/vercel.json +0 -0
- package/template/{frontend → base/frontend}/.env.example +0 -0
- package/template/{frontend → base/frontend}/CLAUDE.md +0 -0
- package/template/{frontend → base/frontend}/README.md +0 -0
- package/template/{frontend → base/frontend}/app/dashboard/page.tsx +2 -2
- package/template/{frontend → base/frontend}/app/icon.png +0 -0
- package/template/{frontend → base/frontend}/components/require-auth.tsx +1 -1
- package/template/{frontend → base/frontend}/components.json +0 -0
- package/template/{frontend → base/frontend}/domains/auth/components/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/auth/hooks/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/auth/services/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/user/components/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/user/hooks/use-profile.ts +2 -2
- /package/template/{frontend → base/frontend}/eslint.config.mjs +0 -0
- /package/template/{frontend → base/frontend}/gitignore +0 -0
- /package/template/{frontend → base/frontend}/postcss.config.mjs +0 -0
- /package/template/{frontend → base/frontend}/public/favicon.ico +0 -0
- /package/template/{frontend → base/frontend}/public/logo.png +0 -0
|
@@ -4,9 +4,9 @@ import { csrf } from "hono/csrf"
|
|
|
4
4
|
import { logger as honoLogger } from "hono/logger"
|
|
5
5
|
import { requestId } from "hono/request-id"
|
|
6
6
|
import { env } from "./config/env.js"
|
|
7
|
-
import { logger, type AppVariables } from "./utils/logger.js"
|
|
8
7
|
import { authRoutes } from "./domains/auth/auth.routes.js"
|
|
9
8
|
import { userRoutes } from "./domains/user/user.routes.js"
|
|
9
|
+
import { type AppVariables, logger } from "./utils/logger.js"
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Hono application instance — routes, middleware, everything except the transport.
|
|
@@ -16,9 +16,7 @@ import { userRoutes } from "./domains/user/user.routes.js"
|
|
|
16
16
|
*/
|
|
17
17
|
export const app = new Hono<{ Variables: AppVariables }>()
|
|
18
18
|
|
|
19
|
-
const allowedOrigins = env.FRONTEND_URL
|
|
20
|
-
.split(",")
|
|
21
|
-
.map((url) => url.trim())
|
|
19
|
+
const allowedOrigins = env.FRONTEND_URL.split(",").map((url) => url.trim())
|
|
22
20
|
|
|
23
21
|
// 1. Generate unique request IDs for all API calls
|
|
24
22
|
app.use("/api/*", requestId())
|
|
@@ -43,7 +41,7 @@ app.use(
|
|
|
43
41
|
"/api/*",
|
|
44
42
|
honoLogger((...args) => {
|
|
45
43
|
logger.info(args.join(" "))
|
|
46
|
-
})
|
|
44
|
+
}),
|
|
47
45
|
)
|
|
48
46
|
|
|
49
47
|
app.use(
|
|
@@ -51,21 +49,18 @@ app.use(
|
|
|
51
49
|
cors({
|
|
52
50
|
origin: allowedOrigins,
|
|
53
51
|
credentials: true,
|
|
54
|
-
})
|
|
52
|
+
}),
|
|
55
53
|
)
|
|
56
54
|
|
|
57
55
|
app.use(
|
|
58
56
|
"/api/*",
|
|
59
57
|
csrf({
|
|
60
58
|
origin: allowedOrigins,
|
|
61
|
-
})
|
|
59
|
+
}),
|
|
62
60
|
)
|
|
63
61
|
|
|
64
|
-
const routes = app
|
|
65
|
-
.route("/api/auth", authRoutes)
|
|
66
|
-
.route("/api/user", userRoutes)
|
|
62
|
+
const routes = app.route("/api/auth", authRoutes).route("/api/user", userRoutes)
|
|
67
63
|
|
|
68
64
|
app.get("/", (c) => c.json({ status: "ok" }))
|
|
69
65
|
|
|
70
66
|
export type AppType = typeof routes
|
|
71
|
-
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import { SignJWT } from "jose"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { env } from "../../config/env.js"
|
|
5
|
+
import type { AuthVariables } from "./require-auth.js"
|
|
6
|
+
import { requireAuth } from "./require-auth.js"
|
|
7
|
+
|
|
8
|
+
const secret = new TextEncoder().encode(env.JWT_SECRET)
|
|
9
|
+
|
|
10
|
+
const signTestToken = (payload: Record<string, unknown>, expiresIn = "15m") =>
|
|
11
|
+
new SignJWT(payload)
|
|
12
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
13
|
+
.setExpirationTime(expiresIn)
|
|
14
|
+
.sign(secret)
|
|
15
|
+
|
|
16
|
+
function buildApp() {
|
|
17
|
+
const app = new Hono<{ Variables: AuthVariables }>()
|
|
18
|
+
app.use("/protected", requireAuth)
|
|
19
|
+
app.get("/protected", (c) => c.json({ user: c.get("user"), authMethod: c.get("authMethod") }))
|
|
20
|
+
return app
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("requireAuth", () => {
|
|
24
|
+
it("returns 401 when no token is present in cookie or header", async () => {
|
|
25
|
+
const res = await buildApp().request("/protected")
|
|
26
|
+
expect(res.status).toBe(401)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("returns 401 when the token is garbage", async () => {
|
|
30
|
+
const res = await buildApp().request("/protected", {
|
|
31
|
+
headers: { Cookie: "access_token=not-a-real-token" },
|
|
32
|
+
})
|
|
33
|
+
expect(res.status).toBe(401)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("returns 401 when the token is expired", async () => {
|
|
37
|
+
const expired = await signTestToken({ sub: "user-1", email: "a@b.com", role: "user" }, "-1s")
|
|
38
|
+
const res = await buildApp().request("/protected", {
|
|
39
|
+
headers: { Cookie: `access_token=${expired}` },
|
|
40
|
+
})
|
|
41
|
+
expect(res.status).toBe(401)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("calls next() and exposes payload with authMethod='cookie' when cookie token is valid", async () => {
|
|
45
|
+
const token = await signTestToken({ sub: "user-1", email: "a@b.com", role: "user" })
|
|
46
|
+
const res = await buildApp().request("/protected", {
|
|
47
|
+
headers: { Cookie: `access_token=${token}` },
|
|
48
|
+
})
|
|
49
|
+
expect(res.status).toBe(200)
|
|
50
|
+
const body = await res.json()
|
|
51
|
+
expect(body.user.sub).toBe("user-1")
|
|
52
|
+
expect(body.user.email).toBe("a@b.com")
|
|
53
|
+
expect(body.authMethod).toBe("cookie")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("calls next() and exposes payload with authMethod='bearer' when Authorization header is valid", async () => {
|
|
57
|
+
const token = await signTestToken({ sub: "user-2", email: "b@b.com", role: "admin" })
|
|
58
|
+
const res = await buildApp().request("/protected", {
|
|
59
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
60
|
+
})
|
|
61
|
+
expect(res.status).toBe(200)
|
|
62
|
+
const body = await res.json()
|
|
63
|
+
expect(body.user.sub).toBe("user-2")
|
|
64
|
+
expect(body.user.email).toBe("b@b.com")
|
|
65
|
+
expect(body.user.role).toBe("admin")
|
|
66
|
+
expect(body.authMethod).toBe("bearer")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("prefers Bearer token over cookie when both are supplied", async () => {
|
|
70
|
+
const cookieToken = await signTestToken({
|
|
71
|
+
sub: "user-cookie",
|
|
72
|
+
email: "cookie@b.com",
|
|
73
|
+
role: "user",
|
|
74
|
+
})
|
|
75
|
+
const bearerToken = await signTestToken({
|
|
76
|
+
sub: "user-bearer",
|
|
77
|
+
email: "bearer@b.com",
|
|
78
|
+
role: "admin",
|
|
79
|
+
})
|
|
80
|
+
const res = await buildApp().request("/protected", {
|
|
81
|
+
headers: {
|
|
82
|
+
Cookie: `access_token=${cookieToken}`,
|
|
83
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
expect(res.status).toBe(200)
|
|
87
|
+
const body = await res.json()
|
|
88
|
+
expect(body.user.sub).toBe("user-bearer")
|
|
89
|
+
expect(body.authMethod).toBe("bearer")
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono"
|
|
2
|
+
import { getCookie } from "hono/cookie"
|
|
3
|
+
import { authService } from "../../domains/auth/auth.service.js"
|
|
4
|
+
import type { AppVariables } from "../../utils/logger.js"
|
|
5
|
+
|
|
6
|
+
export type AuthMethod = "bearer" | "cookie"
|
|
7
|
+
|
|
8
|
+
export interface AuthVariables extends AppVariables {
|
|
9
|
+
authMethod?: AuthMethod
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Authentication guard middleware.
|
|
14
|
+
* Enforces the presence and validity of the short-lived JWT access token
|
|
15
|
+
* from Authorization: Bearer <token> header or request cookies (Hybrid Auth).
|
|
16
|
+
* Resolves the payload, binds it to Hono context variables (`c.get("user")`),
|
|
17
|
+
* and attaches the authenticated userId and authMethod to the request logger context.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* Apply to any endpoint requiring authentication (e.g. `userRoutes.get("/me", requireAuth, ...)`).
|
|
21
|
+
*/
|
|
22
|
+
export const requireAuth: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => {
|
|
23
|
+
let token: string | undefined
|
|
24
|
+
let authMethod: AuthMethod | undefined
|
|
25
|
+
|
|
26
|
+
// 1. Inspect Authorization header (Bearer token) first
|
|
27
|
+
const authHeader = c.req.header("Authorization") || c.req.header("authorization")
|
|
28
|
+
if (authHeader?.toLowerCase().startsWith("bearer ")) {
|
|
29
|
+
token = authHeader.slice(7).trim()
|
|
30
|
+
authMethod = "bearer"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Fall back to access_token cookie
|
|
34
|
+
if (!token) {
|
|
35
|
+
const cookieToken = getCookie(c, "access_token")
|
|
36
|
+
if (cookieToken) {
|
|
37
|
+
token = cookieToken
|
|
38
|
+
authMethod = "cookie"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!token || !authMethod) {
|
|
43
|
+
return c.json({ error: "Unauthorized" }, 401)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Decode and verify signature & expiration
|
|
48
|
+
const payload = await authService.verifyAccessToken(token)
|
|
49
|
+
c.set("user", payload)
|
|
50
|
+
c.set("authMethod", authMethod)
|
|
51
|
+
|
|
52
|
+
// Enrich the child logger with the user's ID and authMethod for all downstream actions
|
|
53
|
+
const reqLogger = c.get("logger")
|
|
54
|
+
if (reqLogger) {
|
|
55
|
+
c.set("logger", reqLogger.child({ userId: Number(payload.sub), authMethod }))
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Return standard 401 response on invalid / expired signature
|
|
59
|
+
return c.json({ error: "Unauthorized" }, 401)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return await next()
|
|
63
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import type { AppVariables } from "../../utils/logger.js"
|
|
4
|
+
import { requireRole } from "./require-role.js"
|
|
5
|
+
|
|
6
|
+
function buildApp(
|
|
7
|
+
allowedRoles: string[],
|
|
8
|
+
initialUser?: { sub: string; email: string; role: string },
|
|
9
|
+
) {
|
|
10
|
+
const app = new Hono<{ Variables: AppVariables }>()
|
|
11
|
+
|
|
12
|
+
if (initialUser) {
|
|
13
|
+
app.use("/admin/*", async (c, next) => {
|
|
14
|
+
c.set("user", initialUser)
|
|
15
|
+
await next()
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
app.use("/admin/dashboard", requireRole(...allowedRoles))
|
|
20
|
+
app.get("/admin/dashboard", (c) => c.json({ access: "granted", role: c.get("user")?.role }))
|
|
21
|
+
|
|
22
|
+
return app
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("requireRole middleware", () => {
|
|
26
|
+
it("returns 401 when user is not authenticated in context", async () => {
|
|
27
|
+
const app = buildApp(["admin"])
|
|
28
|
+
const res = await app.request("/admin/dashboard")
|
|
29
|
+
|
|
30
|
+
expect(res.status).toBe(401)
|
|
31
|
+
const body = await res.json()
|
|
32
|
+
expect(body.error).toBe("Unauthorized")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("returns 403 when user role does not match required role", async () => {
|
|
36
|
+
const app = buildApp(["admin"], { sub: "1", email: "user@test.com", role: "user" })
|
|
37
|
+
const res = await app.request("/admin/dashboard")
|
|
38
|
+
|
|
39
|
+
expect(res.status).toBe(403)
|
|
40
|
+
const body = await res.json()
|
|
41
|
+
expect(body.error).toBe("Forbidden: insufficient permissions")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("returns 200 when user has the exact required role", async () => {
|
|
45
|
+
const app = buildApp(["admin"], { sub: "1", email: "admin@test.com", role: "admin" })
|
|
46
|
+
const res = await app.request("/admin/dashboard")
|
|
47
|
+
|
|
48
|
+
expect(res.status).toBe(200)
|
|
49
|
+
const body = await res.json()
|
|
50
|
+
expect(body.access).toBe("granted")
|
|
51
|
+
expect(body.role).toBe("admin")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("returns 200 when user matches one of several permitted roles", async () => {
|
|
55
|
+
const app = buildApp(["editor", "admin", "superadmin"], {
|
|
56
|
+
sub: "2",
|
|
57
|
+
email: "editor@test.com",
|
|
58
|
+
role: "editor",
|
|
59
|
+
})
|
|
60
|
+
const res = await app.request("/admin/dashboard")
|
|
61
|
+
|
|
62
|
+
expect(res.status).toBe(200)
|
|
63
|
+
const body = await res.json()
|
|
64
|
+
expect(body.access).toBe("granted")
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono"
|
|
2
|
+
import type { AppVariables } from "../../utils/logger.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Role-Based Access Control (RBAC) guard middleware.
|
|
6
|
+
* Verifies that the authenticated user possesses one of the allowed roles.
|
|
7
|
+
* Must be mounted AFTER `requireAuth` in the middleware chain.
|
|
8
|
+
*
|
|
9
|
+
* @param allowedRoles List of roles permitted to access the route (e.g. "admin", "moderator")
|
|
10
|
+
* @returns 401 if user is not authenticated, 403 if user lacks required role, otherwise calls next()
|
|
11
|
+
*/
|
|
12
|
+
export const requireRole = (
|
|
13
|
+
...allowedRoles: string[]
|
|
14
|
+
): MiddlewareHandler<{ Variables: AppVariables }> => {
|
|
15
|
+
return async (c, next) => {
|
|
16
|
+
const user = c.get("user")
|
|
17
|
+
if (!user) {
|
|
18
|
+
return c.json({ error: "Unauthorized" }, 401)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!allowedRoles.includes(user.role)) {
|
|
22
|
+
const reqLogger = c.get("logger")
|
|
23
|
+
if (reqLogger) {
|
|
24
|
+
reqLogger.warn(
|
|
25
|
+
{ userId: user.sub, userRole: user.role, requiredRoles: allowedRoles },
|
|
26
|
+
"Access forbidden: insufficient role permissions",
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return c.json({ error: "Forbidden: insufficient permissions" }, 403)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return await next()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -14,23 +14,20 @@ if (env.LOG_FILE_PATH) {
|
|
|
14
14
|
// If a log file path is provided, stream to both stdout (console) and the file
|
|
15
15
|
const streams = [
|
|
16
16
|
{ stream: process.stdout },
|
|
17
|
-
{ stream: pino.destination({ dest: env.LOG_FILE_PATH, sync: false }) }
|
|
17
|
+
{ stream: pino.destination({ dest: env.LOG_FILE_PATH, sync: false }) },
|
|
18
18
|
]
|
|
19
19
|
loggerInstance = pino({ level }, pino.multistream(streams))
|
|
20
20
|
} else {
|
|
21
21
|
// Otherwise default to standard stdout (with pretty formatting in local development)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
transport:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
: { target: "pino-pretty", options: { colorize: true } },
|
|
28
|
-
})
|
|
22
|
+
const options: pino.LoggerOptions = { level }
|
|
23
|
+
if (env.NODE_ENV !== "production") {
|
|
24
|
+
options.transport = { target: "pino-pretty", options: { colorize: true } }
|
|
25
|
+
}
|
|
26
|
+
loggerInstance = pino(options)
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export const logger = loggerInstance
|
|
32
30
|
|
|
33
|
-
|
|
34
31
|
import type { Logger } from "pino"
|
|
35
32
|
import type { AuthTokenPayload } from "../domains/auth/auth.types.js"
|
|
36
33
|
|
|
@@ -38,5 +35,5 @@ export type AppVariables = {
|
|
|
38
35
|
requestId: string
|
|
39
36
|
logger: Logger
|
|
40
37
|
user?: AuthTokenPayload
|
|
38
|
+
authMethod?: "bearer" | "cookie"
|
|
41
39
|
}
|
|
42
|
-
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isUuidv7, uuidv7 } from "@restack/shared"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
describe("uuidv7 generator", () => {
|
|
5
|
+
it("generates a valid UUIDv7 string", () => {
|
|
6
|
+
const id = uuidv7()
|
|
7
|
+
expect(isUuidv7(id)).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("conforms to version 7 and RFC 9562 variant format", () => {
|
|
11
|
+
const id = uuidv7()
|
|
12
|
+
const parts = id.split("-")
|
|
13
|
+
expect(parts).toHaveLength(5)
|
|
14
|
+
// 3rd part starts with '7' (version 7)
|
|
15
|
+
expect(parts[2]?.charAt(0)).toBe("7")
|
|
16
|
+
// 4th part starts with 8, 9, a, or b (variant 1)
|
|
17
|
+
expect(["8", "9", "a", "b"]).toContain(parts[3]?.charAt(0).toLowerCase())
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("produces chronologically ordered identifiers", async () => {
|
|
21
|
+
const ids: string[] = []
|
|
22
|
+
for (let i = 0; i < 20; i++) {
|
|
23
|
+
ids.push(uuidv7())
|
|
24
|
+
// short delay to test monotonic sorting
|
|
25
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sorted = [...ids].sort()
|
|
29
|
+
expect(ids).toEqual(sorted)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("isUuidv7 rejects invalid UUID formats", () => {
|
|
33
|
+
expect(isUuidv7("not-a-uuid")).toBe(false)
|
|
34
|
+
// UUIDv4 (version nibble is 4, not 7)
|
|
35
|
+
expect(isUuidv7("550e8400-e29b-41d4-a716-446655440000")).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
"target": "ESNext",
|
|
4
4
|
"module": "NodeNext",
|
|
5
5
|
"strict": true,
|
|
6
|
+
"noUncheckedIndexedAccess": true,
|
|
7
|
+
"exactOptionalPropertyTypes": true,
|
|
8
|
+
"noImplicitReturns": true,
|
|
9
|
+
"noFallthroughCasesInSwitch": true,
|
|
6
10
|
"verbatimModuleSyntax": true,
|
|
7
11
|
"skipLibCheck": true,
|
|
8
12
|
"types": [
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
# This is NOT the Next.js you know
|
|
3
|
+
|
|
4
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
5
|
+
|
|
6
|
+
## Guardrails
|
|
7
|
+
- **Typed Routes Active**: All internal links `<Link href="...">` are strictly checked at compile time. Never use untyped arbitrary string paths.
|
|
8
|
+
- **Typed Environment**: Always import `env` from `@/env` (`@t3-oss/env-nextjs`). Never read raw `process.env`.
|
|
9
|
+
- **Semi-DDD Boundary**: Keep `app/` strictly for routing/metadata only. Delegate all rendering to `domains/[feature]/components`.
|
|
10
|
+
<!-- END:nextjs-agent-rules -->
|
|
11
|
+
|
|
@@ -4,7 +4,7 @@ import logo from "@/public/logo.png"
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Next.js Router Page: Login
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
8
|
* Semi-DDD rules:
|
|
9
9
|
* - Files in `frontend/app/` are strictly for layout, routing config, metadata, or entry gates.
|
|
10
10
|
* - Do not implement custom JSX layouts, forms, state, or complex components here.
|
|
@@ -4,7 +4,7 @@ import logo from "@/public/logo.png"
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Next.js Router Page: Register
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
8
|
* Semi-DDD rules:
|
|
9
9
|
* - Files in `frontend/app/` are strictly for layout, routing config, metadata, or entry gates.
|
|
10
10
|
* - Do not implement custom JSX layouts, forms, state, or complex components here.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type { Metadata } from "next"
|
|
2
|
-
import { Geist, Geist_Mono } from "next/font/google"
|
|
3
|
-
import "./globals.css"
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google"
|
|
3
|
+
import "./globals.css"
|
|
4
4
|
|
|
5
5
|
const geistSans = Geist({
|
|
6
6
|
variable: "--font-geist-sans",
|
|
7
7
|
subsets: ["latin"],
|
|
8
|
-
})
|
|
8
|
+
})
|
|
9
9
|
|
|
10
10
|
const geistMono = Geist_Mono({
|
|
11
11
|
variable: "--font-geist-mono",
|
|
12
12
|
subsets: ["latin"],
|
|
13
|
-
})
|
|
13
|
+
})
|
|
14
14
|
|
|
15
15
|
export const metadata: Metadata = {
|
|
16
16
|
title: "RestackPattern",
|
|
@@ -18,19 +18,16 @@ export const metadata: Metadata = {
|
|
|
18
18
|
icons: {
|
|
19
19
|
icon: "/favicon.ico",
|
|
20
20
|
},
|
|
21
|
-
}
|
|
21
|
+
}
|
|
22
22
|
|
|
23
23
|
export default function RootLayout({
|
|
24
24
|
children,
|
|
25
25
|
}: Readonly<{
|
|
26
|
-
children: React.ReactNode
|
|
26
|
+
children: React.ReactNode
|
|
27
27
|
}>) {
|
|
28
28
|
return (
|
|
29
|
-
<html
|
|
30
|
-
lang="en"
|
|
31
|
-
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
32
|
-
>
|
|
29
|
+
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
|
33
30
|
<body className="min-h-full flex flex-col">{children}</body>
|
|
34
31
|
</html>
|
|
35
|
-
)
|
|
32
|
+
)
|
|
36
33
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import Link from "next/link"
|
|
2
1
|
import Image from "next/image"
|
|
2
|
+
import Link from "next/link"
|
|
3
3
|
import { Button } from "@/components/ui/button"
|
|
4
4
|
import logo from "@/public/logo.png"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Next.js Router Page: Landing Page (root)
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
9
|
* Semi-DDD rules:
|
|
10
10
|
* - Files in `frontend/app/` are strictly for layout, routing config, metadata, or entry gates.
|
|
11
11
|
* - Do not implement custom JSX layouts, forms, state, or complex components here.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { Button } from "@/components/ui/button"
|
|
2
3
|
import { ProfileCard } from "@/domains/user/components/profile-card"
|
|
3
|
-
import Link from "next/link";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Next.js Router Page: Profile
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
8
|
* Semi-DDD rules:
|
|
9
9
|
* - Files in `frontend/app/` are strictly for layout, routing config, metadata, or entry gates.
|
|
10
10
|
* - Do not implement custom JSX layouts, forms, state, or complex components here.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as React from "react"
|
|
2
1
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
2
|
import { Slot } from "radix-ui"
|
|
3
|
+
import type * as React from "react"
|
|
4
4
|
|
|
5
5
|
import { cn } from "@/lib/utils"
|
|
6
6
|
|
|
@@ -38,7 +38,7 @@ const buttonVariants = cva(
|
|
|
38
38
|
variant: "default",
|
|
39
39
|
size: "default",
|
|
40
40
|
},
|
|
41
|
-
}
|
|
41
|
+
},
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
function Button({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as React from "react"
|
|
1
|
+
import type * as React from "react"
|
|
2
2
|
|
|
3
3
|
import { cn } from "@/lib/utils"
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ function Card({
|
|
|
13
13
|
data-size={size}
|
|
14
14
|
className={cn(
|
|
15
15
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
|
16
|
-
className
|
|
16
|
+
className,
|
|
17
17
|
)}
|
|
18
18
|
{...props}
|
|
19
19
|
/>
|
|
@@ -26,7 +26,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
26
26
|
data-slot="card-header"
|
|
27
27
|
className={cn(
|
|
28
28
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
|
29
|
-
className
|
|
29
|
+
className,
|
|
30
30
|
)}
|
|
31
31
|
{...props}
|
|
32
32
|
/>
|
|
@@ -39,7 +39,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
39
39
|
data-slot="card-title"
|
|
40
40
|
className={cn(
|
|
41
41
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
|
42
|
-
className
|
|
42
|
+
className,
|
|
43
43
|
)}
|
|
44
44
|
{...props}
|
|
45
45
|
/>
|
|
@@ -60,10 +60,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
60
60
|
return (
|
|
61
61
|
<div
|
|
62
62
|
data-slot="card-action"
|
|
63
|
-
className={cn(
|
|
64
|
-
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
65
|
-
className
|
|
66
|
-
)}
|
|
63
|
+
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
|
67
64
|
{...props}
|
|
68
65
|
/>
|
|
69
66
|
)
|
|
@@ -71,11 +68,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
71
68
|
|
|
72
69
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
73
70
|
return (
|
|
74
|
-
<div
|
|
75
|
-
data-slot="card-content"
|
|
76
|
-
className={cn("px-(--card-spacing)", className)}
|
|
77
|
-
{...props}
|
|
78
|
-
/>
|
|
71
|
+
<div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />
|
|
79
72
|
)
|
|
80
73
|
}
|
|
81
74
|
|
|
@@ -85,19 +78,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
85
78
|
data-slot="card-footer"
|
|
86
79
|
className={cn(
|
|
87
80
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
|
88
|
-
className
|
|
81
|
+
className,
|
|
89
82
|
)}
|
|
90
83
|
{...props}
|
|
91
84
|
/>
|
|
92
85
|
)
|
|
93
86
|
}
|
|
94
87
|
|
|
95
|
-
export {
|
|
96
|
-
Card,
|
|
97
|
-
CardHeader,
|
|
98
|
-
CardFooter,
|
|
99
|
-
CardTitle,
|
|
100
|
-
CardAction,
|
|
101
|
-
CardDescription,
|
|
102
|
-
CardContent,
|
|
103
|
-
}
|
|
88
|
+
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as React from "react"
|
|
1
|
+
import type * as React from "react"
|
|
2
2
|
|
|
3
3
|
import { cn } from "@/lib/utils"
|
|
4
4
|
|
|
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
|
9
9
|
data-slot="input"
|
|
10
10
|
className={cn(
|
|
11
11
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
12
|
-
className
|
|
12
|
+
className,
|
|
13
13
|
)}
|
|
14
14
|
{...props}
|
|
15
15
|
/>
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import * as React from "react"
|
|
4
3
|
import { Label as LabelPrimitive } from "radix-ui"
|
|
4
|
+
import type * as React from "react"
|
|
5
5
|
|
|
6
6
|
import { cn } from "@/lib/utils"
|
|
7
7
|
|
|
8
|
-
function Label({
|
|
9
|
-
className,
|
|
10
|
-
...props
|
|
11
|
-
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
8
|
+
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
12
9
|
return (
|
|
13
10
|
<LabelPrimitive.Root
|
|
14
11
|
data-slot="label"
|
|
15
12
|
className={cn(
|
|
16
13
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
17
|
-
className
|
|
14
|
+
className,
|
|
18
15
|
)}
|
|
19
16
|
{...props}
|
|
20
17
|
/>
|