create-restack-pattern 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/index.js +73 -0
  4. package/package.json +26 -0
  5. package/template/backend/.env.example +8 -0
  6. package/template/backend/README.md +8 -0
  7. package/template/backend/api/index.ts +9 -0
  8. package/template/backend/drizzle/0000_grey_lethal_legion.sql +19 -0
  9. package/template/backend/drizzle/meta/0000_snapshot.json +137 -0
  10. package/template/backend/drizzle/meta/_journal.json +13 -0
  11. package/template/backend/drizzle.config.ts +11 -0
  12. package/template/backend/gitignore +31 -0
  13. package/template/backend/package.json +34 -0
  14. package/template/backend/public/.gitkeep +0 -0
  15. package/template/backend/src/config/.gitkeep +0 -0
  16. package/template/backend/src/config/env.ts +22 -0
  17. package/template/backend/src/domains/auth/.gitkeep +0 -0
  18. package/template/backend/src/domains/auth/auth.model.ts +20 -0
  19. package/template/backend/src/domains/auth/auth.repo.ts +51 -0
  20. package/template/backend/src/domains/auth/auth.routes.ts +93 -0
  21. package/template/backend/src/domains/auth/auth.service.test.ts +143 -0
  22. package/template/backend/src/domains/auth/auth.service.ts +97 -0
  23. package/template/backend/src/domains/auth/auth.types.ts +7 -0
  24. package/template/backend/src/domains/user/.gitkeep +0 -0
  25. package/template/backend/src/domains/user/user.model.ts +14 -0
  26. package/template/backend/src/domains/user/user.repo.ts +29 -0
  27. package/template/backend/src/domains/user/user.routes.test.ts +82 -0
  28. package/template/backend/src/domains/user/user.routes.ts +48 -0
  29. package/template/backend/src/domains/user/user.service.ts +40 -0
  30. package/template/backend/src/hono-app.ts +42 -0
  31. package/template/backend/src/index.ts +13 -0
  32. package/template/backend/src/shared/db/.gitkeep +0 -0
  33. package/template/backend/src/shared/db/index.ts +11 -0
  34. package/template/backend/src/shared/logger/logger.ts +15 -0
  35. package/template/backend/src/shared/middlewares/.gitkeep +0 -0
  36. package/template/backend/src/shared/middlewares/rate-limit.ts +43 -0
  37. package/template/backend/src/shared/middlewares/require-auth.test.ts +51 -0
  38. package/template/backend/src/shared/middlewares/require-auth.ts +31 -0
  39. package/template/backend/tsconfig.json +16 -0
  40. package/template/backend/vercel.json +6 -0
  41. package/template/frontend/AGENTS.md +5 -0
  42. package/template/frontend/CLAUDE.md +1 -0
  43. package/template/frontend/README.md +36 -0
  44. package/template/frontend/app/(auth)/login/page.tsx +24 -0
  45. package/template/frontend/app/(auth)/register/page.tsx +24 -0
  46. package/template/frontend/app/dashboard/page.tsx +29 -0
  47. package/template/frontend/app/globals.css +131 -0
  48. package/template/frontend/app/icon.png +0 -0
  49. package/template/frontend/app/layout.tsx +36 -0
  50. package/template/frontend/app/page.tsx +33 -0
  51. package/template/frontend/app/profile/page.tsx +23 -0
  52. package/template/frontend/components/ui/button.tsx +67 -0
  53. package/template/frontend/components/ui/card.tsx +103 -0
  54. package/template/frontend/components/ui/input.tsx +19 -0
  55. package/template/frontend/components/ui/label.tsx +24 -0
  56. package/template/frontend/components.json +24 -0
  57. package/template/frontend/domains/auth/components/.gitkeep +0 -0
  58. package/template/frontend/domains/auth/components/login-form.tsx +75 -0
  59. package/template/frontend/domains/auth/components/logout-button.tsx +30 -0
  60. package/template/frontend/domains/auth/components/register-form.tsx +87 -0
  61. package/template/frontend/domains/auth/hooks/.gitkeep +0 -0
  62. package/template/frontend/domains/auth/hooks/use-login.ts +17 -0
  63. package/template/frontend/domains/auth/hooks/use-register.ts +17 -0
  64. package/template/frontend/domains/auth/services/.gitkeep +0 -0
  65. package/template/frontend/domains/auth/services/auth.api.ts +27 -0
  66. package/template/frontend/domains/user/components/.gitkeep +0 -0
  67. package/template/frontend/domains/user/components/profile-card.tsx +33 -0
  68. package/template/frontend/domains/user/hooks/use-profile.ts +39 -0
  69. package/template/frontend/domains/user/services/user.api.ts +21 -0
  70. package/template/frontend/eslint.config.mjs +18 -0
  71. package/template/frontend/gitignore +43 -0
  72. package/template/frontend/lib/utils.ts +11 -0
  73. package/template/frontend/next.config.ts +7 -0
  74. package/template/frontend/package.json +35 -0
  75. package/template/frontend/postcss.config.mjs +7 -0
  76. package/template/frontend/public/favicon.ico +0 -0
  77. package/template/frontend/public/logo.png +0 -0
  78. package/template/frontend/shared/components/require-auth.tsx +25 -0
  79. package/template/frontend/shared/components/ui/.gitkeep +0 -0
  80. package/template/frontend/shared/hooks/.gitkeep +0 -0
  81. package/template/frontend/shared/hooks/use-auth-mutation.ts +37 -0
  82. package/template/frontend/shared/lib/.gitkeep +0 -0
  83. package/template/frontend/shared/lib/api-client.ts +73 -0
  84. package/template/frontend/tsconfig.json +34 -0
  85. package/template/gitignore +35 -0
  86. package/template/package.json +13 -0
  87. package/template/packages/shared/package.json +24 -0
  88. package/template/packages/shared/src/index.ts +2 -0
  89. package/template/packages/shared/src/schemas/auth.schema.ts +20 -0
  90. package/template/packages/shared/src/schemas/user.schema.ts +16 -0
  91. package/template/packages/shared/tsconfig.json +51 -0
  92. package/template/pnpm-workspace.yaml +4 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicholas Chandra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # create-restack-pattern
2
+
3
+ Scaffold a new Next.js + Hono + Drizzle ORM monorepo using the Restack Pattern
4
+ (Semi-DDD / Feature-Driven) architecture — the same template built out in
5
+ [the main repo](../../README.md), bundled as a copy-and-go generator.
6
+
7
+ ## Usage
8
+
9
+ ```bash
10
+ npx create-restack-pattern my-app
11
+ ```
12
+
13
+ Or without an argument, it'll prompt for a project name:
14
+
15
+ ```bash
16
+ npx create-restack-pattern
17
+ ```
18
+
19
+ This will:
20
+
21
+ 1. Copy the template into `./my-app`
22
+ 2. Set `my-app` as the generated project's root `package.json` name
23
+ 3. Run `pnpm install`
24
+ 4. Build `@restack/shared` (required once before `pnpm dev` works — see the
25
+ generated project's gotcha notes)
26
+ 5. Run `git init`
27
+
28
+ ## Prerequisites
29
+
30
+ - Node.js 20+
31
+ - [pnpm](https://pnpm.io) — the generated project is a pnpm workspace monorepo
32
+
33
+ ## After scaffolding
34
+
35
+ ```bash
36
+ cd my-app
37
+ cp backend/.env.example backend/.env # fill in DATABASE_URL and JWT_SECRET
38
+ pnpm --filter backend dev # http://localhost:3001
39
+ pnpm --filter frontend dev # http://localhost:3000
40
+ ```
41
+
42
+ ## What you get
43
+
44
+ - `frontend/` — Next.js 16 (App Router)
45
+ - `backend/` — Hono + Drizzle ORM, with a working JWT + refresh-token auth
46
+ flow, rate limiting, and an example `user` domain already wired up
47
+ - `packages/shared/` — Zod schemas shared between frontend and backend
48
+
49
+ ## Current scope (MVP)
50
+
51
+ This is the first, deliberately minimal version of the CLI. It does not yet
52
+ ask about package manager, database, or auth-provider choices — it always
53
+ scaffolds the exact pnpm + Postgres + Drizzle stack from the template as-is.
54
+ Those choices are tracked as follow-up work in the main repo's
55
+ `docs/concerns.md`.
56
+
57
+ ## For maintainers: keeping the template in sync
58
+
59
+ The `template/` directory in this package is a **copy** of `frontend/`,
60
+ `backend/`, and `packages/shared/` from the repo root, not a live reference —
61
+ and it's gitignored, same as `dist/`. It's regenerated automatically before
62
+ publish (`prepublishOnly` runs `sync-template` then `build`), so it never
63
+ needs to be committed. For local testing, run it manually first:
64
+
65
+ ```bash
66
+ pnpm --filter create-restack-pattern sync-template
67
+ ```
68
+
69
+ This overwrites `template/` from the current state of the root template
70
+ (excluding `node_modules`, build output, `.env*` except `.env.example`, and
71
+ this repo's own internal planning docs like `docs/` and root `CLAUDE.md`,
72
+ which aren't relevant to a generated project).
package/dist/index.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, readFileSync, writeFileSync, readdirSync, renameSync } from "node:fs";
3
+ import { execSync } from "node:child_process";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const templateDir = path.resolve(__dirname, "..", "template");
9
+ async function promptProjectName() {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ const answer = await rl.question("Project name: ");
12
+ rl.close();
13
+ return answer.trim();
14
+ }
15
+ function isValidProjectName(name) {
16
+ return /^[a-z0-9-_]+$/i.test(name);
17
+ }
18
+ // Undoes sync-template.mjs's .gitignore -> gitignore rename (npm strips
19
+ // files literally named .gitignore from the published tarball).
20
+ function renameGitignoreFilesBack(dir) {
21
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
22
+ const full = path.join(dir, entry.name);
23
+ if (entry.isDirectory())
24
+ renameGitignoreFilesBack(full);
25
+ else if (entry.name === "gitignore")
26
+ renameSync(full, path.join(dir, ".gitignore"));
27
+ }
28
+ }
29
+ async function main() {
30
+ const argName = process.argv[2];
31
+ const projectName = argName ?? (await promptProjectName());
32
+ if (!projectName || !isValidProjectName(projectName)) {
33
+ console.error("Invalid project name. Use letters, numbers, hyphens, or underscores only.");
34
+ process.exit(1);
35
+ }
36
+ const targetDir = path.resolve(process.cwd(), projectName);
37
+ if (existsSync(targetDir)) {
38
+ console.error(`Directory "${projectName}" already exists.`);
39
+ process.exit(1);
40
+ }
41
+ console.log(`Scaffolding into ${targetDir} ...`);
42
+ cpSync(templateDir, targetDir, { recursive: true });
43
+ renameGitignoreFilesBack(targetDir);
44
+ const rootPkgPath = path.join(targetDir, "package.json");
45
+ const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf-8"));
46
+ rootPkg.name = projectName;
47
+ writeFileSync(rootPkgPath, JSON.stringify(rootPkg, null, 2) + "\n");
48
+ console.log("Installing dependencies (pnpm install) ...");
49
+ execSync("pnpm install", { cwd: targetDir, stdio: "inherit" });
50
+ // packages/shared ships no dist/ (gitignored) — backend/frontend resolve
51
+ // @restack/shared to compiled output, so it must be built once up front
52
+ // or the very first `pnpm dev` fails to resolve the import.
53
+ console.log("Building @restack/shared ...");
54
+ execSync("pnpm --filter @restack/shared build", { cwd: targetDir, stdio: "inherit" });
55
+ try {
56
+ execSync("git init", { cwd: targetDir, stdio: "ignore" });
57
+ }
58
+ catch {
59
+ // git not available — non-fatal, scaffolding still succeeded
60
+ }
61
+ console.log(`
62
+ Done! Next steps:
63
+
64
+ cd ${projectName}
65
+ cp backend/.env.example backend/.env # fill in DATABASE_URL and JWT_SECRET
66
+ pnpm --filter backend dev
67
+ pnpm --filter frontend dev
68
+ `);
69
+ }
70
+ main().catch((err) => {
71
+ console.error(err);
72
+ process.exit(1);
73
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "create-restack-pattern",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a new Next.js + Hono + Drizzle monorepo using the Restack Pattern (Semi-DDD) template.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "create-restack-pattern": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "template"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsx src/index.ts",
18
+ "sync-template": "node scripts/sync-template.mjs",
19
+ "prepublishOnly": "pnpm sync-template && pnpm build"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.11.17",
23
+ "tsx": "^4.7.1",
24
+ "typescript": "^5.8.3"
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ DATABASE_URL=postgresql://user:password@localhost:5432/restack_pattern
2
+ JWT_SECRET=your-secret-key-min-32-chars
3
+ NODE_ENV=development
4
+ PORT=3001
5
+ FRONTEND_URL=http://localhost:3000,https://myapp.vercel.app
6
+ # COOKIE_SAME_SITE=Lax # override default (None in production, Lax in development)
7
+ # COOKIE_DOMAIN=.example.com # only set if FE/BE share a domain, e.g. app.example.com + api.example.com
8
+ # # see docs/deployment-verification.md before deploying cross-domain
@@ -0,0 +1,8 @@
1
+ ```
2
+ npm install
3
+ npm run dev
4
+ ```
5
+
6
+ ```
7
+ open http://localhost:3000
8
+ ```
@@ -0,0 +1,9 @@
1
+ import { app } from "../src/hono-app.js"
2
+
3
+ /**
4
+ * Vercel serverless entry point. Hono's `app` already implements the Web
5
+ * Fetch API shape Vercel Functions expect ({ fetch(request): Response }),
6
+ * so no adapter package is needed — see backend/vercel.json for the
7
+ * catch-all rewrite that routes every path here.
8
+ */
9
+ export default app
@@ -0,0 +1,19 @@
1
+ CREATE TABLE "auth_sessions" (
2
+ "id" serial PRIMARY KEY NOT NULL,
3
+ "user_id" integer NOT NULL,
4
+ "refresh_token_hash" text NOT NULL,
5
+ "expires_at" timestamp NOT NULL,
6
+ "created_at" timestamp DEFAULT now() NOT NULL,
7
+ CONSTRAINT "auth_sessions_refresh_token_hash_unique" UNIQUE("refresh_token_hash")
8
+ );
9
+ --> statement-breakpoint
10
+ CREATE TABLE "users" (
11
+ "id" serial PRIMARY KEY NOT NULL,
12
+ "name" text NOT NULL,
13
+ "email" text NOT NULL,
14
+ "password" text NOT NULL,
15
+ "created_at" timestamp DEFAULT now() NOT NULL,
16
+ CONSTRAINT "users_email_unique" UNIQUE("email")
17
+ );
18
+ --> statement-breakpoint
19
+ ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,137 @@
1
+ {
2
+ "id": "0f794287-130a-477c-a13b-37f45569e468",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.auth_sessions": {
8
+ "name": "auth_sessions",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "serial",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "user_id": {
18
+ "name": "user_id",
19
+ "type": "integer",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "refresh_token_hash": {
24
+ "name": "refresh_token_hash",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "expires_at": {
30
+ "name": "expires_at",
31
+ "type": "timestamp",
32
+ "primaryKey": false,
33
+ "notNull": true
34
+ },
35
+ "created_at": {
36
+ "name": "created_at",
37
+ "type": "timestamp",
38
+ "primaryKey": false,
39
+ "notNull": true,
40
+ "default": "now()"
41
+ }
42
+ },
43
+ "indexes": {},
44
+ "foreignKeys": {
45
+ "auth_sessions_user_id_users_id_fk": {
46
+ "name": "auth_sessions_user_id_users_id_fk",
47
+ "tableFrom": "auth_sessions",
48
+ "tableTo": "users",
49
+ "columnsFrom": [
50
+ "user_id"
51
+ ],
52
+ "columnsTo": [
53
+ "id"
54
+ ],
55
+ "onDelete": "cascade",
56
+ "onUpdate": "no action"
57
+ }
58
+ },
59
+ "compositePrimaryKeys": {},
60
+ "uniqueConstraints": {
61
+ "auth_sessions_refresh_token_hash_unique": {
62
+ "name": "auth_sessions_refresh_token_hash_unique",
63
+ "nullsNotDistinct": false,
64
+ "columns": [
65
+ "refresh_token_hash"
66
+ ]
67
+ }
68
+ },
69
+ "policies": {},
70
+ "checkConstraints": {},
71
+ "isRLSEnabled": false
72
+ },
73
+ "public.users": {
74
+ "name": "users",
75
+ "schema": "",
76
+ "columns": {
77
+ "id": {
78
+ "name": "id",
79
+ "type": "serial",
80
+ "primaryKey": true,
81
+ "notNull": true
82
+ },
83
+ "name": {
84
+ "name": "name",
85
+ "type": "text",
86
+ "primaryKey": false,
87
+ "notNull": true
88
+ },
89
+ "email": {
90
+ "name": "email",
91
+ "type": "text",
92
+ "primaryKey": false,
93
+ "notNull": true
94
+ },
95
+ "password": {
96
+ "name": "password",
97
+ "type": "text",
98
+ "primaryKey": false,
99
+ "notNull": true
100
+ },
101
+ "created_at": {
102
+ "name": "created_at",
103
+ "type": "timestamp",
104
+ "primaryKey": false,
105
+ "notNull": true,
106
+ "default": "now()"
107
+ }
108
+ },
109
+ "indexes": {},
110
+ "foreignKeys": {},
111
+ "compositePrimaryKeys": {},
112
+ "uniqueConstraints": {
113
+ "users_email_unique": {
114
+ "name": "users_email_unique",
115
+ "nullsNotDistinct": false,
116
+ "columns": [
117
+ "email"
118
+ ]
119
+ }
120
+ },
121
+ "policies": {},
122
+ "checkConstraints": {},
123
+ "isRLSEnabled": false
124
+ }
125
+ },
126
+ "enums": {},
127
+ "schemas": {},
128
+ "sequences": {},
129
+ "roles": {},
130
+ "policies": {},
131
+ "views": {},
132
+ "_meta": {
133
+ "columns": {},
134
+ "schemas": {},
135
+ "tables": {}
136
+ }
137
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1783107540765,
9
+ "tag": "0000_grey_lethal_legion",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "drizzle-kit"
2
+ import "dotenv/config"
3
+
4
+ export default defineConfig({
5
+ schema: "./src/domains/*/*.model.ts",
6
+ out: "./drizzle",
7
+ dialect: "postgresql",
8
+ dbCredentials: {
9
+ url: process.env.DATABASE_URL!,
10
+ },
11
+ })
@@ -0,0 +1,31 @@
1
+ # dev
2
+ .yarn/
3
+ !.yarn/releases
4
+ .vscode/*
5
+ !.vscode/launch.json
6
+ !.vscode/*.code-snippets
7
+ .idea/workspace.xml
8
+ .idea/usage.statistics.xml
9
+ .idea/shelf
10
+
11
+ # deps
12
+ node_modules/
13
+
14
+ # env
15
+ .env
16
+ .env.production
17
+
18
+ # logs
19
+ logs/
20
+ *.log
21
+ npm-debug.log*
22
+ yarn-debug.log*
23
+ yarn-error.log*
24
+ pnpm-debug.log*
25
+ lerna-debug.log*
26
+
27
+ # misc
28
+ .DS_Store
29
+
30
+ .vercel
31
+ .env*
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "backend",
3
+ "type": "module",
4
+ "scripts": {
5
+ "dev": "tsx watch src/index.ts",
6
+ "build": "tsc",
7
+ "start": "node dist/index.js",
8
+ "db:generate": "drizzle-kit generate",
9
+ "db:push": "drizzle-kit push",
10
+ "db:studio": "drizzle-kit studio",
11
+ "test": "vitest run"
12
+ },
13
+ "dependencies": {
14
+ "@hono/node-server": "^2.0.6",
15
+ "@hono/zod-validator": "^0.8.0",
16
+ "@restack/shared": "workspace:*",
17
+ "bcryptjs": "^3.0.3",
18
+ "dotenv": "^17.4.2",
19
+ "drizzle-orm": "^0.45.2",
20
+ "hono": "^4.12.27",
21
+ "jose": "^6.2.3",
22
+ "pino": "^10.3.1",
23
+ "postgres": "^3.4.9",
24
+ "zod": "^4.4.3"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.11.17",
28
+ "drizzle-kit": "^0.31.10",
29
+ "pino-pretty": "^13.1.3",
30
+ "tsx": "^4.7.1",
31
+ "typescript": "^5.8.3",
32
+ "vitest": "^3.0.0"
33
+ }
34
+ }
File without changes
File without changes
@@ -0,0 +1,22 @@
1
+ import "dotenv/config"
2
+ import { z } from "zod";
3
+
4
+ const envSchema = z.object({
5
+ // Memastikan DATABASE_URL ada dan berformat URL valid
6
+ DATABASE_URL: z.string().url("DATABASE_URL harus berupa URL yang valid"),
7
+ // Minimal 32 karakter supaya HS256 secret tidak mudah di-brute-force
8
+ JWT_SECRET: z.string().min(32, "JWT_SECRET wajib diisi, minimal 32 karakter"),
9
+ // Validasi enum dengan nilai default "development"
10
+ NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
11
+ // Mengubah string dari process.env menjadi Number secara otomatis
12
+ PORT: z.coerce.number().default(3001),
13
+ // Validasi cookie samesite dengan nilai default "Lax"
14
+ COOKIE_SAME_SITE: z.enum(["Lax", "None", "Strict", ""]).default(""),
15
+ // Set only when FE and BE share a registrable domain via subdomains
16
+ // (e.g. ".example.com" for app.example.com + api.example.com) — lets
17
+ // the frontend's own middleware read the session cookies too.
18
+ COOKIE_DOMAIN: z.string().optional(),
19
+ FRONTEND_URL: z.string().default("http://localhost:3000"),
20
+ })
21
+
22
+ export const env = envSchema.parse(process.env)
File without changes
@@ -0,0 +1,20 @@
1
+ import { pgTable, serial, integer, text, timestamp } from "drizzle-orm/pg-core"
2
+ import { usersTable } from "../user/user.model.js"
3
+
4
+ /**
5
+ * Auth Sessions Table Schema.
6
+ * Tracks active, refreshable user login sessions.
7
+ *
8
+ * Security rules:
9
+ * - We only store the SHA-256 hash of the refresh token (`refreshTokenHash`),
10
+ * protecting the user session from being compromised if the database leaks.
11
+ * - Cascade delete is used so removing a user automatically purges all their active sessions.
12
+ */
13
+ export const authSessionsTable = pgTable("auth_sessions", {
14
+ id: serial("id").primaryKey(),
15
+ userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
16
+ // sha256 of the opaque refresh token — never store the raw token.
17
+ refreshTokenHash: text("refresh_token_hash").notNull().unique(),
18
+ expiresAt: timestamp("expires_at").notNull(),
19
+ createdAt: timestamp("created_at").notNull().defaultNow(),
20
+ })
@@ -0,0 +1,51 @@
1
+ import { and, eq, gt } from "drizzle-orm"
2
+ import { db } from "../../shared/db/index.js"
3
+ import { usersTable } from "../user/user.model.js"
4
+ import { authSessionsTable } from "./auth.model.js"
5
+
6
+ type UserRow = typeof usersTable.$inferSelect
7
+ type SessionRow = typeof authSessionsTable.$inferSelect
8
+
9
+ /**
10
+ * Repository layer for the Auth domain.
11
+ * Encapsulates sessions management and credential-based queries.
12
+ *
13
+ * Semi-DDD rules:
14
+ * - Direct database interactions (Drizzle client) must live only inside `.repo.ts` files.
15
+ * - Under cross-domain rules, the Auth domain can query the User domain's tables (e.g. `usersTable`)
16
+ * directly for login resolution, ensuring that dependencies flow one way (auth -> user).
17
+ */
18
+ export const authRepo = {
19
+ // Explicit return type matters: without noUncheckedIndexedAccess, TS infers array
20
+ // destructuring as always-defined, so `user ?? null` silently loses the null branch
21
+ // and callers' `if (!user)` checks type-check as always-false without this annotation.
22
+ findByEmail: async (email: string): Promise<UserRow | null> => {
23
+ const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email))
24
+ return user ?? null
25
+ },
26
+
27
+ create: async (data: { name: string; email: string; password: string }) => {
28
+ const [user] = await db.insert(usersTable).values(data).returning()
29
+ return user
30
+ },
31
+
32
+ createSession: async (data: { userId: number; refreshTokenHash: string; expiresAt: Date }) => {
33
+ const [session] = await db.insert(authSessionsTable).values(data).returning()
34
+ return session
35
+ },
36
+
37
+ // Atomic find-and-delete: a single DELETE...RETURNING so concurrent refresh
38
+ // calls presenting the same token can't both pass a separate SELECT check
39
+ // before either DELETE lands (TOCTOU) — only one caller ever gets a row back.
40
+ consumeSessionByHash: async (refreshTokenHash: string): Promise<SessionRow | null> => {
41
+ const [session] = await db
42
+ .delete(authSessionsTable)
43
+ .where(and(eq(authSessionsTable.refreshTokenHash, refreshTokenHash), gt(authSessionsTable.expiresAt, new Date())))
44
+ .returning()
45
+ return session ?? null
46
+ },
47
+
48
+ deleteSessionByHash: async (refreshTokenHash: string) => {
49
+ await db.delete(authSessionsTable).where(eq(authSessionsTable.refreshTokenHash, refreshTokenHash))
50
+ },
51
+ }
@@ -0,0 +1,93 @@
1
+ import { Hono } from "hono"
2
+ import type { Context } from "hono"
3
+ import { setCookie, deleteCookie, getCookie } from "hono/cookie"
4
+ import { zValidator } from "@hono/zod-validator"
5
+ import { LoginSchema, RegisterSchema } from "@restack/shared"
6
+ import { authService, ACCESS_TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS } from "./auth.service.js"
7
+ import { env } from "../../config/env.js"
8
+ import { logger } from "../../shared/logger/logger.js"
9
+ import { rateLimit } from "../../shared/middlewares/rate-limit.js"
10
+
11
+ /**
12
+ * Routing layer for the Auth domain.
13
+ * Defines the public endpoints for login, registration, logout, and token rotation.
14
+ *
15
+ * Semi-DDD rules:
16
+ * - `.routes.ts` files define HTTP endpoints, validate request payloads, and apply CORS/CSRF configurations.
17
+ * - Endpoints should delegate business flow execution directly to the service layer.
18
+ */
19
+ const isProduction = env.NODE_ENV === "production"
20
+ const sameSite = (env.COOKIE_SAME_SITE || (isProduction ? "None" : "Lax")) as "Lax" | "None" | "Strict"
21
+ const secure = sameSite === "None" ? true : isProduction
22
+
23
+ const domainOpt = env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}
24
+ const deleteCookieOpts = (path: string) => ({ path, secure, sameSite, ...domainOpt })
25
+ const cookieOpts = (path: string, maxAge: number) => ({ ...deleteCookieOpts(path), httpOnly: true, maxAge })
26
+
27
+ const accessTokenCookieOptions = cookieOpts("/", ACCESS_TOKEN_TTL_SECONDS)
28
+ const refreshTokenCookieOptions = cookieOpts("/", REFRESH_TOKEN_TTL_SECONDS)
29
+
30
+ const setAuthCookies = (c: Context, tokens: { accessToken: string; refreshToken: string }) => {
31
+ setCookie(c, "access_token", tokens.accessToken, accessTokenCookieOptions)
32
+ setCookie(c, "refresh_token", tokens.refreshToken, refreshTokenCookieOptions)
33
+ }
34
+
35
+ const clearAuthCookies = (c: Context) => {
36
+ deleteCookie(c, "access_token", deleteCookieOpts("/"))
37
+ deleteCookie(c, "refresh_token", deleteCookieOpts("/"))
38
+ }
39
+
40
+ export const authRoutes = new Hono()
41
+
42
+ authRoutes.post("/register", rateLimit, zValidator("json", RegisterSchema), async (c) => {
43
+ try {
44
+ const body = c.req.valid("json")
45
+ const user = await authService.register(body)
46
+ const tokens = await authService.issueTokenPair(user)
47
+
48
+ setAuthCookies(c, tokens)
49
+
50
+ return c.json({ user }, 201)
51
+ } catch (e: any) {
52
+ logger.error({ err: e.message }, "Register failed")
53
+ return c.json({ error: e.message }, 400)
54
+ }
55
+ })
56
+
57
+ authRoutes.post("/login", rateLimit, zValidator("json", LoginSchema), async (c) => {
58
+ try {
59
+ const body = c.req.valid("json")
60
+ const user = await authService.login(body)
61
+ const tokens = await authService.issueTokenPair(user)
62
+
63
+ setAuthCookies(c, tokens)
64
+
65
+ return c.json({ user })
66
+ } catch (e: any) {
67
+ logger.error({ err: e.message }, "Login failed")
68
+ return c.json({ error: e.message }, 401)
69
+ }
70
+ })
71
+
72
+ authRoutes.post("/refresh", rateLimit, async (c) => {
73
+ const refreshToken = getCookie(c, "refresh_token")
74
+ if (!refreshToken) return c.json({ error: "Missing refresh token" }, 401)
75
+
76
+ try {
77
+ const tokens = await authService.refresh(refreshToken)
78
+ setAuthCookies(c, tokens)
79
+ return c.json({ user: tokens.user })
80
+ } catch (e: any) {
81
+ clearAuthCookies(c)
82
+ logger.error({ err: e.message }, "Refresh failed")
83
+ return c.json({ error: "Invalid refresh token" }, 401)
84
+ }
85
+ })
86
+
87
+ authRoutes.post("/logout", async (c) => {
88
+ const refreshToken = getCookie(c, "refresh_token")
89
+ if (refreshToken) await authService.revokeSession(refreshToken)
90
+
91
+ clearAuthCookies(c)
92
+ return c.json({ success: true })
93
+ })