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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/index.js +73 -0
- package/package.json +26 -0
- package/template/backend/.env.example +8 -0
- package/template/backend/README.md +8 -0
- package/template/backend/api/index.ts +9 -0
- package/template/backend/drizzle/0000_grey_lethal_legion.sql +19 -0
- package/template/backend/drizzle/meta/0000_snapshot.json +137 -0
- package/template/backend/drizzle/meta/_journal.json +13 -0
- package/template/backend/drizzle.config.ts +11 -0
- package/template/backend/gitignore +31 -0
- package/template/backend/package.json +34 -0
- package/template/backend/public/.gitkeep +0 -0
- package/template/backend/src/config/.gitkeep +0 -0
- package/template/backend/src/config/env.ts +22 -0
- package/template/backend/src/domains/auth/.gitkeep +0 -0
- package/template/backend/src/domains/auth/auth.model.ts +20 -0
- package/template/backend/src/domains/auth/auth.repo.ts +51 -0
- package/template/backend/src/domains/auth/auth.routes.ts +93 -0
- package/template/backend/src/domains/auth/auth.service.test.ts +143 -0
- package/template/backend/src/domains/auth/auth.service.ts +97 -0
- package/template/backend/src/domains/auth/auth.types.ts +7 -0
- package/template/backend/src/domains/user/.gitkeep +0 -0
- package/template/backend/src/domains/user/user.model.ts +14 -0
- package/template/backend/src/domains/user/user.repo.ts +29 -0
- package/template/backend/src/domains/user/user.routes.test.ts +82 -0
- package/template/backend/src/domains/user/user.routes.ts +48 -0
- package/template/backend/src/domains/user/user.service.ts +40 -0
- package/template/backend/src/hono-app.ts +42 -0
- package/template/backend/src/index.ts +13 -0
- package/template/backend/src/shared/db/.gitkeep +0 -0
- package/template/backend/src/shared/db/index.ts +11 -0
- package/template/backend/src/shared/logger/logger.ts +15 -0
- package/template/backend/src/shared/middlewares/.gitkeep +0 -0
- package/template/backend/src/shared/middlewares/rate-limit.ts +43 -0
- package/template/backend/src/shared/middlewares/require-auth.test.ts +51 -0
- package/template/backend/src/shared/middlewares/require-auth.ts +31 -0
- package/template/backend/tsconfig.json +16 -0
- package/template/backend/vercel.json +6 -0
- package/template/frontend/AGENTS.md +5 -0
- package/template/frontend/CLAUDE.md +1 -0
- package/template/frontend/README.md +36 -0
- package/template/frontend/app/(auth)/login/page.tsx +24 -0
- package/template/frontend/app/(auth)/register/page.tsx +24 -0
- package/template/frontend/app/dashboard/page.tsx +29 -0
- package/template/frontend/app/globals.css +131 -0
- package/template/frontend/app/icon.png +0 -0
- package/template/frontend/app/layout.tsx +36 -0
- package/template/frontend/app/page.tsx +33 -0
- package/template/frontend/app/profile/page.tsx +23 -0
- package/template/frontend/components/ui/button.tsx +67 -0
- package/template/frontend/components/ui/card.tsx +103 -0
- package/template/frontend/components/ui/input.tsx +19 -0
- package/template/frontend/components/ui/label.tsx +24 -0
- package/template/frontend/components.json +24 -0
- package/template/frontend/domains/auth/components/.gitkeep +0 -0
- package/template/frontend/domains/auth/components/login-form.tsx +75 -0
- package/template/frontend/domains/auth/components/logout-button.tsx +30 -0
- package/template/frontend/domains/auth/components/register-form.tsx +87 -0
- package/template/frontend/domains/auth/hooks/.gitkeep +0 -0
- package/template/frontend/domains/auth/hooks/use-login.ts +17 -0
- package/template/frontend/domains/auth/hooks/use-register.ts +17 -0
- package/template/frontend/domains/auth/services/.gitkeep +0 -0
- package/template/frontend/domains/auth/services/auth.api.ts +27 -0
- package/template/frontend/domains/user/components/.gitkeep +0 -0
- package/template/frontend/domains/user/components/profile-card.tsx +33 -0
- package/template/frontend/domains/user/hooks/use-profile.ts +39 -0
- package/template/frontend/domains/user/services/user.api.ts +21 -0
- package/template/frontend/eslint.config.mjs +18 -0
- package/template/frontend/gitignore +43 -0
- package/template/frontend/lib/utils.ts +11 -0
- package/template/frontend/next.config.ts +7 -0
- package/template/frontend/package.json +35 -0
- package/template/frontend/postcss.config.mjs +7 -0
- package/template/frontend/public/favicon.ico +0 -0
- package/template/frontend/public/logo.png +0 -0
- package/template/frontend/shared/components/require-auth.tsx +25 -0
- package/template/frontend/shared/components/ui/.gitkeep +0 -0
- package/template/frontend/shared/hooks/.gitkeep +0 -0
- package/template/frontend/shared/hooks/use-auth-mutation.ts +37 -0
- package/template/frontend/shared/lib/.gitkeep +0 -0
- package/template/frontend/shared/lib/api-client.ts +73 -0
- package/template/frontend/tsconfig.json +34 -0
- package/template/gitignore +35 -0
- package/template/package.json +13 -0
- package/template/packages/shared/package.json +24 -0
- package/template/packages/shared/src/index.ts +2 -0
- package/template/packages/shared/src/schemas/auth.schema.ts +20 -0
- package/template/packages/shared/src/schemas/user.schema.ts +16 -0
- package/template/packages/shared/tsconfig.json +51 -0
- 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,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,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
|
+
})
|