create-restack-pattern 0.1.0 → 0.1.4
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/README.md +75 -48
- package/dist/index.js +42 -5
- package/package.json +5 -2
- package/template/CLAUDE.md +62 -0
- package/template/backend/.env.example +2 -1
- package/template/backend/README.md +45 -4
- package/template/backend/gitignore +4 -3
- package/template/backend/package.json +3 -0
- package/template/backend/src/config/env.ts +1 -0
- package/template/backend/src/domains/auth/auth.routes.ts +55 -54
- package/template/backend/src/domains/user/user.routes.ts +31 -29
- package/template/backend/src/hono-app.ts +34 -5
- package/template/backend/src/shared/db/index.ts +1 -1
- package/template/backend/src/shared/logger/logger.ts +34 -7
- package/template/backend/src/shared/middlewares/require-auth.ts +10 -5
- package/template/frontend/.env.example +1 -0
- package/template/frontend/README.md +30 -25
- package/template/frontend/domains/auth/services/auth.api.ts +4 -10
- package/template/frontend/domains/user/services/user.api.ts +4 -6
- package/template/frontend/gitignore +3 -2
- package/template/frontend/package.json +4 -0
- package/template/frontend/shared/components/require-auth.tsx +9 -3
- package/template/frontend/shared/lib/api-client.ts +58 -54
- package/template/gitignore +9 -1
- package/template/package.json +5 -1
- package/template/backend/src/shared/middlewares/rate-limit.ts +0 -43
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# create-restack-pattern
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
(
|
|
5
|
-
[
|
|
3
|
+
[](https://www.npmjs.com/package/create-restack-pattern)
|
|
4
|
+
[](https://www.npmjs.com/package/create-restack-pattern)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Skip the boilerplate — scaffold a full-stack Next.js + Hono + PostgreSQL monorepo with secure auth cookies, structured logging, and a type-safe Hono RPC client end-to-end, all wired up and ready to deploy.
|
|
8
|
+
|
|
9
|
+
This is the official initializer for the [Restack Pattern](https://github.com/NicholasChandraa/restack-pattern).
|
|
6
10
|
|
|
7
11
|
## Usage
|
|
8
12
|
|
|
@@ -10,63 +14,86 @@ Scaffold a new Next.js + Hono + Drizzle ORM monorepo using the Restack Pattern
|
|
|
10
14
|
npx create-restack-pattern my-app
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
Or without
|
|
14
|
-
|
|
17
|
+
Or run it without arguments to be prompted for a project name:
|
|
15
18
|
```bash
|
|
16
19
|
npx create-restack-pattern
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
### Prerequisites
|
|
23
|
+
- **Node.js**: version 20 or higher
|
|
24
|
+
- **pnpm**: package manager installed (run `npm install -g pnpm` if needed)
|
|
25
|
+
- **PostgreSQL**: a running database (either a local instance or a free hosted instance on [Neon](https://neon.tech))
|
|
20
26
|
|
|
21
|
-
|
|
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
|
+
---
|
|
27
28
|
|
|
28
|
-
##
|
|
29
|
+
## Quick Start (Next Steps)
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
- [pnpm](https://pnpm.io) — the generated project is a pnpm workspace monorepo
|
|
32
|
-
|
|
33
|
-
## After scaffolding
|
|
31
|
+
Once the CLI finishes scaffolding your project, follow these three simple steps to get started:
|
|
34
32
|
|
|
33
|
+
### 1. Configure Environment Variables
|
|
35
34
|
```bash
|
|
36
35
|
cd my-app
|
|
37
|
-
cp backend/.env.example backend/.env
|
|
38
|
-
|
|
39
|
-
pnpm --filter frontend dev # http://localhost:3000
|
|
36
|
+
cp backend/.env.example backend/.env
|
|
37
|
+
cp frontend/.env.example frontend/.env
|
|
40
38
|
```
|
|
39
|
+
- Open `backend/.env` and set:
|
|
40
|
+
- `DATABASE_URL` (format: `postgresql://user:password@localhost:5432/dbname`; you can use a local Postgres database or a free hosted instance on [Neon](https://neon.tech))
|
|
41
|
+
- `JWT_SECRET` (generate a secure 32-character key by running: `openssl rand -base64 32`)
|
|
42
|
+
- *(Optional)* Open `frontend/.env` to override `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:3001` for local Hono API).
|
|
41
43
|
|
|
42
|
-
|
|
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
|
-
|
|
44
|
+
### 2. Push Database Tables
|
|
45
|
+
This creates the required `users` and `auth_sessions` tables in your database:
|
|
65
46
|
```bash
|
|
66
|
-
pnpm --filter
|
|
47
|
+
pnpm --filter backend db:push
|
|
67
48
|
```
|
|
68
49
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
50
|
+
### 3. Start Development Servers
|
|
51
|
+
```bash
|
|
52
|
+
pnpm dev
|
|
53
|
+
```
|
|
54
|
+
- **Backend API**: runs at [http://localhost:3001](http://localhost:3001)
|
|
55
|
+
- **Frontend App**: runs at [http://localhost:3000](http://localhost:3000)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## What You Get
|
|
60
|
+
|
|
61
|
+
- **`frontend/`** — Next.js 16 (App Router)
|
|
62
|
+
- **`backend/`** — Hono + Drizzle ORM, with a working JWT + refresh-token auth flow, production-ready structured correlation logging (Pino + Hono request tracing), and an example `user` domain already wired up.
|
|
63
|
+
- **`packages/shared/`** — Zod schemas shared between frontend and backend.
|
|
64
|
+
|
|
65
|
+
For a full breakdown of the folder structure, architecture rules (Semi-DDD), dual-cookie auth implementation, and deployment verification checklists, please visit the [GitHub repository](https://github.com/NicholasChandraa/restack-pattern).
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## For Maintainers (Local Dev & Publishing)
|
|
70
|
+
|
|
71
|
+
The `template/` directory in this package is a **copy** of `frontend/`, `backend/`, and `packages/shared/` from the repo root, not a live reference — and it's gitignored.
|
|
72
|
+
|
|
73
|
+
Before testing your CLI changes locally or publishing, follow the standard workflow:
|
|
74
|
+
|
|
75
|
+
1. **Sync template**:
|
|
76
|
+
```bash
|
|
77
|
+
pnpm --filter create-restack-pattern sync-template
|
|
78
|
+
```
|
|
79
|
+
2. **Compile the CLI script**:
|
|
80
|
+
```bash
|
|
81
|
+
pnpm --filter create-restack-pattern build
|
|
82
|
+
```
|
|
83
|
+
3. **Verify the published files**:
|
|
84
|
+
```bash
|
|
85
|
+
npm pack --dry-run
|
|
86
|
+
```
|
|
87
|
+
4. **Scaffold locally to test**:
|
|
88
|
+
```bash
|
|
89
|
+
node dist/index.js test-app
|
|
90
|
+
```
|
|
91
|
+
5. **Publish to npm**:
|
|
92
|
+
Bump the `"version"` in `package.json`, then:
|
|
93
|
+
```bash
|
|
94
|
+
npm publish
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For detailed guides, workflow explanations, and lessons learned:
|
|
98
|
+
- Check out the [Development Guide](../../docs/en/development-guide.md) (Indonesian: [Panduan Kontribusi](../../docs/id/development-guide.md))
|
|
99
|
+
- Check out the [Building a Scaffolding CLI Tutorial](../../docs/tutorial.md)
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,24 @@ function renameGitignoreFilesBack(dir) {
|
|
|
26
26
|
renameSync(full, path.join(dir, ".gitignore"));
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
function isPnpmInstalled() {
|
|
30
|
+
try {
|
|
31
|
+
execSync("pnpm --version", { stdio: "ignore" });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
29
38
|
async function main() {
|
|
39
|
+
if (!isPnpmInstalled()) {
|
|
40
|
+
console.error("\n\x1b[31mError: pnpm is not installed.\x1b[0m");
|
|
41
|
+
console.error("This template is configured as a pnpm workspace monorepo and requires pnpm to run.");
|
|
42
|
+
console.error("Please install pnpm before continuing:");
|
|
43
|
+
console.error(" npm install -g pnpm");
|
|
44
|
+
console.error("Or visit https://pnpm.io for more installation options.\n");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
30
47
|
const argName = process.argv[2];
|
|
31
48
|
const projectName = argName ?? (await promptProjectName());
|
|
32
49
|
if (!projectName || !isValidProjectName(projectName)) {
|
|
@@ -59,12 +76,32 @@ async function main() {
|
|
|
59
76
|
// git not available — non-fatal, scaffolding still succeeded
|
|
60
77
|
}
|
|
61
78
|
console.log(`
|
|
62
|
-
|
|
79
|
+
\x1b[32m✔ Scaffolded project successfully!\x1b[0m
|
|
80
|
+
|
|
81
|
+
Next steps to get started:
|
|
82
|
+
|
|
83
|
+
1. Go to your project directory:
|
|
84
|
+
\x1b[36mcd ${projectName}\x1b[0m
|
|
85
|
+
|
|
86
|
+
2. Configure environment variables:
|
|
87
|
+
• For Backend:
|
|
88
|
+
\x1b[36mcp backend/.env.example backend/.env\x1b[0m
|
|
89
|
+
(Open backend/.env and set \x1b[33mDATABASE_URL\x1b[0m and a min-32-chars \x1b[33mJWT_SECRET\x1b[0m)
|
|
90
|
+
|
|
91
|
+
• For Frontend:
|
|
92
|
+
\x1b[36mcp frontend/.env.example frontend/.env\x1b[0m
|
|
93
|
+
|
|
94
|
+
3. Push database tables (creates users & auth_sessions tables):
|
|
95
|
+
\x1b[36mpnpm --filter backend db:push\x1b[0m
|
|
96
|
+
|
|
97
|
+
4. Start development servers:
|
|
98
|
+
• Backend (running at http://localhost:3001):
|
|
99
|
+
\x1b[36mpnpm --filter backend dev\x1b[0m
|
|
100
|
+
|
|
101
|
+
• Frontend (running at http://localhost:3000):
|
|
102
|
+
\x1b[36mpnpm --filter frontend dev\x1b[0m
|
|
63
103
|
|
|
64
|
-
|
|
65
|
-
cp backend/.env.example backend/.env # fill in DATABASE_URL and JWT_SECRET
|
|
66
|
-
pnpm --filter backend dev
|
|
67
|
-
pnpm --filter frontend dev
|
|
104
|
+
Enjoy building your app with the Restack Pattern!
|
|
68
105
|
`);
|
|
69
106
|
}
|
|
70
107
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-restack-pattern",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Scaffold a new Next.js + Hono + Drizzle monorepo using the Restack Pattern (Semi-DDD) template.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"private": false,
|
|
8
8
|
"bin": {
|
|
9
|
-
"create-restack-pattern": "
|
|
9
|
+
"create-restack-pattern": "dist/index.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
@@ -22,5 +22,8 @@
|
|
|
22
22
|
"@types/node": "^20.11.17",
|
|
23
23
|
"tsx": "^4.7.1",
|
|
24
24
|
"typescript": "^5.8.3"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides architecture, build, and styling guidance to Claude Code (claude.ai/code) and other AI assistants working in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Stack
|
|
6
|
+
- **Frontend**: Next.js 16 (App Router)
|
|
7
|
+
- **Backend**: Hono
|
|
8
|
+
- **ORM**: Drizzle ORM
|
|
9
|
+
- **Database**: PostgreSQL (Postgres-js driver)
|
|
10
|
+
- **Validation**: Zod (shared schemas)
|
|
11
|
+
- **Package Manager**: pnpm workspaces
|
|
12
|
+
|
|
13
|
+
## Monorepo Layout
|
|
14
|
+
- `frontend/` — Next.js 16 App Router
|
|
15
|
+
- `backend/` — Hono API
|
|
16
|
+
- `packages/shared/` — Shared Zod schemas (the single source of truth)
|
|
17
|
+
|
|
18
|
+
## Build and Dev Commands
|
|
19
|
+
All commands should be run from the repository root:
|
|
20
|
+
- `pnpm install` — Install all dependencies
|
|
21
|
+
- `pnpm --filter @restack/shared build` — Build shared Zod schemas (run first!)
|
|
22
|
+
- `pnpm --filter backend dev` — Start Hono dev server on port 3001
|
|
23
|
+
- `pnpm --filter frontend dev` — Start Next.js dev server on port 3000
|
|
24
|
+
- `pnpm --filter backend db:push` — Push Drizzle schema to PostgreSQL database
|
|
25
|
+
- `pnpm --filter backend db:studio` — Open Drizzle Studio database viewer
|
|
26
|
+
- `pnpm --filter backend test` — Run Vitest tests
|
|
27
|
+
|
|
28
|
+
## Architecture Rules: Semi-DDD (Feature-Driven)
|
|
29
|
+
Code is organized by business domain/feature, not by file type.
|
|
30
|
+
|
|
31
|
+
### Backend Layout (`backend/src/domains/[feature]/`)
|
|
32
|
+
Each domain has 4 primary files:
|
|
33
|
+
- `[feature].routes.ts` — HTTP endpoints & input validation (imports Zod schema from `@restack/shared`)
|
|
34
|
+
- `[feature].service.ts` — Pure business logic only (no direct DB access)
|
|
35
|
+
- `[feature].repo.ts` — Drizzle queries only (no business logic)
|
|
36
|
+
- `[feature].model.ts` — Drizzle table definitions
|
|
37
|
+
|
|
38
|
+
**Cross-Domain Dependency Rule**: The `auth` domain may import from `user` (e.g. `usersTable` from `user.model.ts`). The reverse (user importing from auth) is STRICTLY FORBIDDEN.
|
|
39
|
+
|
|
40
|
+
### Frontend Layout (`frontend/domains/[feature]/`)
|
|
41
|
+
- `components/` — UI components (no direct data fetching)
|
|
42
|
+
- `hooks/` — Reactive state & data fetching logic
|
|
43
|
+
- `services/` — Thin HTTP wrappers around `apiClient`
|
|
44
|
+
- `types.ts` — Local TS type declarations
|
|
45
|
+
- `frontend/app/` is strictly for layout, routing config, metadata, or entry gates. Do not implement custom JSX layouts, forms, state, or complex components here. Always delegate rendering to domain feature components.
|
|
46
|
+
|
|
47
|
+
## Critical Gotcha: Shared Package Build Requirement
|
|
48
|
+
`@restack/shared`'s `package.json` resolves to `dist/`, which is gitignored. When editing Zod schemas in `packages/shared/src/schemas/`, you MUST compile them by running:
|
|
49
|
+
`pnpm --filter @restack/shared build`
|
|
50
|
+
(or keep `pnpm --filter @restack/shared dev` watch mode running in a terminal) or the frontend and backend will fail to resolve the updated types or throw compilation errors.
|
|
51
|
+
|
|
52
|
+
## Auth & Cookie Strategy
|
|
53
|
+
- Short-lived JWT `access_token` (15m) and opaque `refresh_token` (30d) are both set as `httpOnly` cookies at `Path=/`.
|
|
54
|
+
- Access tokens are automatically refreshed by the client-side `apiClient` on a 401 response.
|
|
55
|
+
- Cookie configuration: `SameSite=None` in production (requires `Secure=true`), and `SameSite=Lax` in development (`Secure=false` since local dev runs on HTTP).
|
|
56
|
+
- There is no server-side auth gate (`middleware.ts`/`proxy.ts`) because cookies cannot be read server-side when frontend and backend deploy to unrelated domains (Topology B). Authenticated routes are protected via backend `requireAuth` middleware, and frontend pages are protected via `<RequireAuth>` or client-side checks in hooks.
|
|
57
|
+
|
|
58
|
+
## Structured & Correlation Logging
|
|
59
|
+
- Every incoming HTTP request is tagged with a unique `requestId` via Hono's `requestId` middleware.
|
|
60
|
+
- A request-scoped child logger (`logger.child({ requestId })`) is bound to Hono's context.
|
|
61
|
+
- In route handlers, always extract the request-specific child logger from Hono's context (`c.get("logger") || logger`) to log events. This ensures all logs generated during a request carry the correct `requestId` and `userId` context (which is automatically enriched by the `requireAuth` middleware).
|
|
62
|
+
|
|
@@ -5,4 +5,5 @@ PORT=3001
|
|
|
5
5
|
FRONTEND_URL=http://localhost:3000,https://myapp.vercel.app
|
|
6
6
|
# COOKIE_SAME_SITE=Lax # override default (None in production, Lax in development)
|
|
7
7
|
# COOKIE_DOMAIN=.example.com # only set if FE/BE share a domain, e.g. app.example.com + api.example.com
|
|
8
|
-
#
|
|
8
|
+
# # see docs/deployment-verification.md before deploying cross-domain
|
|
9
|
+
# LOG_FILE_PATH=logs/app.log # Optional log file path (outputs to stdout and writes to this file)
|
|
@@ -1,8 +1,49 @@
|
|
|
1
|
+
# RestackPattern - Backend API (Hono)
|
|
2
|
+
|
|
3
|
+
This is the Hono backend API for the RestackPattern monorepo. It features structured, context-scoped correlation logging (Pino), database interaction via Drizzle ORM, and a secure dual-cookie JWT refresh token authentication flow.
|
|
4
|
+
|
|
5
|
+
## Directory Structure (Semi-DDD)
|
|
6
|
+
|
|
7
|
+
Code is structured by business domain/feature:
|
|
8
|
+
|
|
1
9
|
```
|
|
2
|
-
|
|
3
|
-
|
|
10
|
+
src/
|
|
11
|
+
├── config/ # Environment variables validation (env.ts)
|
|
12
|
+
├── shared/ # Reusable utilities across domains
|
|
13
|
+
│ ├── db/ # Drizzle connection client
|
|
14
|
+
│ ├── logger/ # Pino logger instance and type definitions
|
|
15
|
+
│ └── middlewares/ # requireAuth, etc.
|
|
16
|
+
└── domains/
|
|
17
|
+
├── auth/ # Auth routes, service, repo, and model
|
|
18
|
+
└── user/ # User routes, service, repo, and model
|
|
4
19
|
```
|
|
5
20
|
|
|
21
|
+
### Domain Architecture Layer Rules:
|
|
22
|
+
- `*.routes.ts` - Handles HTTP requests, validates input schemas (from `@restack/shared`), calls service logic, and returns JSON.
|
|
23
|
+
- `*.service.ts` - Houses core business workflows, password hashing, and token issuance. Does not write SQL or run DB queries directly.
|
|
24
|
+
- `*.repo.ts` - Houses Drizzle DB query executions. No business logic.
|
|
25
|
+
- `*.model.ts` - Defines Drizzle database tables.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Development
|
|
30
|
+
|
|
31
|
+
Prerequisites: A running PostgreSQL database (e.g. [Neon](https://neon.tech) or a local instance).
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 1. Create env file and configure DATABASE_URL and JWT_SECRET
|
|
35
|
+
cp .env.example .env
|
|
36
|
+
|
|
37
|
+
# 2. Push schema to database
|
|
38
|
+
pnpm db:push
|
|
39
|
+
|
|
40
|
+
# 3. Start development watcher
|
|
41
|
+
pnpm dev
|
|
6
42
|
```
|
|
7
|
-
|
|
8
|
-
|
|
43
|
+
|
|
44
|
+
### Core Commands (Run from backend folder)
|
|
45
|
+
- `pnpm dev` - Starts development server at `http://localhost:3001` (uses tsx watch)
|
|
46
|
+
- `pnpm db:push` - Pushes Drizzle schemas to PostgreSQL database
|
|
47
|
+
- `pnpm db:generate` - Generates Drizzle SQL migrations
|
|
48
|
+
- `pnpm db:studio` - Starts Drizzle Studio GUI database viewer
|
|
49
|
+
- `pnpm test` - Runs unit and integration test suites using Vitest
|
|
@@ -17,6 +17,7 @@ const envSchema = z.object({
|
|
|
17
17
|
// the frontend's own middleware read the session cookies too.
|
|
18
18
|
COOKIE_DOMAIN: z.string().optional(),
|
|
19
19
|
FRONTEND_URL: z.string().default("http://localhost:3000"),
|
|
20
|
+
LOG_FILE_PATH: z.string().optional(),
|
|
20
21
|
})
|
|
21
22
|
|
|
22
23
|
export const env = envSchema.parse(process.env)
|
|
@@ -5,8 +5,7 @@ import { zValidator } from "@hono/zod-validator"
|
|
|
5
5
|
import { LoginSchema, RegisterSchema } from "@restack/shared"
|
|
6
6
|
import { authService, ACCESS_TOKEN_TTL_SECONDS, REFRESH_TOKEN_TTL_SECONDS } from "./auth.service.js"
|
|
7
7
|
import { env } from "../../config/env.js"
|
|
8
|
-
import { logger } from "../../shared/logger/logger.js"
|
|
9
|
-
import { rateLimit } from "../../shared/middlewares/rate-limit.js"
|
|
8
|
+
import { logger, type AppVariables } from "../../shared/logger/logger.js"
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Routing layer for the Auth domain.
|
|
@@ -37,57 +36,59 @@ const clearAuthCookies = (c: Context) => {
|
|
|
37
36
|
deleteCookie(c, "refresh_token", deleteCookieOpts("/"))
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
export const authRoutes = new Hono()
|
|
39
|
+
export const authRoutes = new Hono<{ Variables: AppVariables }>()
|
|
40
|
+
.post("/register", zValidator("json", RegisterSchema), async (c) => {
|
|
41
|
+
const reqLogger = c.get("logger") || logger
|
|
42
|
+
try {
|
|
43
|
+
reqLogger.info("Starting user registration")
|
|
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
|
+
reqLogger.error({ err: e.message }, "Register failed")
|
|
53
|
+
return c.json({ error: e.message }, 400)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.post("/login", zValidator("json", LoginSchema), async (c) => {
|
|
57
|
+
const reqLogger = c.get("logger") || logger
|
|
58
|
+
try {
|
|
59
|
+
reqLogger.info("Attempting user login")
|
|
60
|
+
const body = c.req.valid("json")
|
|
61
|
+
const user = await authService.login(body)
|
|
62
|
+
const tokens = await authService.issueTokenPair(user)
|
|
63
|
+
|
|
64
|
+
setAuthCookies(c, tokens)
|
|
65
|
+
|
|
66
|
+
return c.json({ user })
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
reqLogger.error({ err: e.message }, "Login failed")
|
|
69
|
+
return c.json({ error: e.message }, 401)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.post("/refresh", async (c) => {
|
|
73
|
+
const reqLogger = c.get("logger") || logger
|
|
74
|
+
const refreshToken = getCookie(c, "refresh_token")
|
|
75
|
+
if (!refreshToken) return c.json({ error: "Missing refresh token" }, 401)
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
reqLogger.info("Attempting token rotation")
|
|
79
|
+
const tokens = await authService.refresh(refreshToken)
|
|
80
|
+
setAuthCookies(c, tokens)
|
|
81
|
+
return c.json({ user: tokens.user })
|
|
82
|
+
} catch (e: any) {
|
|
83
|
+
clearAuthCookies(c)
|
|
84
|
+
reqLogger.error({ err: e.message }, "Refresh failed")
|
|
85
|
+
return c.json({ error: "Invalid refresh token" }, 401)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
.post("/logout", async (c) => {
|
|
89
|
+
const refreshToken = getCookie(c, "refresh_token")
|
|
90
|
+
if (refreshToken) await authService.revokeSession(refreshToken)
|
|
41
91
|
|
|
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
92
|
clearAuthCookies(c)
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
})
|
|
93
|
+
return c.json({ success: true })
|
|
94
|
+
})
|
|
@@ -2,11 +2,9 @@ import { Hono } from "hono"
|
|
|
2
2
|
import type { Context } from "hono"
|
|
3
3
|
import { zValidator } from "@hono/zod-validator"
|
|
4
4
|
import { UpdateProfileSchema } from "@restack/shared"
|
|
5
|
-
import type { AuthVariables } from "../../shared/middlewares/require-auth.js"
|
|
6
5
|
import { requireAuth } from "../../shared/middlewares/require-auth.js"
|
|
7
6
|
import { userService, UserNotFoundError } from "./user.service.js"
|
|
8
|
-
import {
|
|
9
|
-
import { logger } from "../../shared/logger/logger.js"
|
|
7
|
+
import { logger, type AppVariables } from "../../shared/logger/logger.js"
|
|
10
8
|
|
|
11
9
|
/**
|
|
12
10
|
* Routing layer for the User domain.
|
|
@@ -14,35 +12,39 @@ import { logger } from "../../shared/logger/logger.js"
|
|
|
14
12
|
*
|
|
15
13
|
* Semi-DDD rules:
|
|
16
14
|
* - `.routes.ts` files define HTTP endpoints, parse inputs using schema validators (zValidator),
|
|
17
|
-
* apply guards (requireAuth
|
|
15
|
+
* apply guards (requireAuth), and serialize JSON responses.
|
|
18
16
|
* - Routing code should never implement business logic directly; it must delegate to the service layer.
|
|
19
17
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
// Only UserNotFoundError is an expected, safe-to-expose outcome. Anything else (DB/driver
|
|
23
|
-
// errors, etc.) gets logged with detail but returned to the caller as a generic 500 —
|
|
24
|
-
// leaking raw error messages/mislabeling unexpected failures as 404 would be misleading.
|
|
25
|
-
const handleProfileError = (c: Context, e: unknown, action: string) => {
|
|
18
|
+
const handleProfileError = (c: Context<{ Variables: AppVariables }>, e: unknown, action: string) => {
|
|
26
19
|
if (e instanceof UserNotFoundError) return c.json({ error: e.message }, 404)
|
|
27
|
-
|
|
20
|
+
const reqLogger = c.get("logger") || logger
|
|
21
|
+
reqLogger.error({ err: e instanceof Error ? e.message : String(e) }, action)
|
|
28
22
|
return c.json({ error: "Internal server error" }, 500)
|
|
29
23
|
}
|
|
30
24
|
|
|
31
|
-
userRoutes
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
25
|
+
export const userRoutes = new Hono<{ Variables: AppVariables }>()
|
|
26
|
+
.get("/me", requireAuth, async (c) => {
|
|
27
|
+
const reqLogger = c.get("logger") || logger
|
|
28
|
+
try {
|
|
29
|
+
reqLogger.info("Retrieving authenticated user profile")
|
|
30
|
+
const user = c.get("user")
|
|
31
|
+
if (!user) return c.json({ error: "Unauthorized" }, 401)
|
|
32
|
+
const profile = await userService.getProfile(Number(user.sub))
|
|
33
|
+
return c.json({ user: profile })
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return handleProfileError(c, e, "Get profile failed")
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.put("/profile", requireAuth, zValidator("json", UpdateProfileSchema), async (c) => {
|
|
39
|
+
const reqLogger = c.get("logger") || logger
|
|
40
|
+
try {
|
|
41
|
+
const body = c.req.valid("json")
|
|
42
|
+
reqLogger.info("Updating user profile")
|
|
43
|
+
const user = c.get("user")
|
|
44
|
+
if (!user) return c.json({ error: "Unauthorized" }, 401)
|
|
45
|
+
const profile = await userService.updateProfile(Number(user.sub), body)
|
|
46
|
+
return c.json({ user: profile })
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return handleProfileError(c, e, "Update profile failed")
|
|
49
|
+
}
|
|
50
|
+
})
|
|
@@ -2,8 +2,9 @@ import { Hono } from "hono"
|
|
|
2
2
|
import { cors } from "hono/cors"
|
|
3
3
|
import { csrf } from "hono/csrf"
|
|
4
4
|
import { logger as honoLogger } from "hono/logger"
|
|
5
|
+
import { requestId } from "hono/request-id"
|
|
5
6
|
import { env } from "./config/env.js"
|
|
6
|
-
import { logger } from "./shared/logger/logger.js"
|
|
7
|
+
import { logger, type AppVariables } from "./shared/logger/logger.js"
|
|
7
8
|
import { authRoutes } from "./domains/auth/auth.routes.js"
|
|
8
9
|
import { userRoutes } from "./domains/user/user.routes.js"
|
|
9
10
|
|
|
@@ -13,13 +14,37 @@ import { userRoutes } from "./domains/user/user.routes.js"
|
|
|
13
14
|
* server (local dev, Railway/Render/VPS) or as a Vercel serverless function
|
|
14
15
|
* (backend/api/index.ts), which cannot call @hono/node-server's serve().
|
|
15
16
|
*/
|
|
16
|
-
export const app = new Hono()
|
|
17
|
+
export const app = new Hono<{ Variables: AppVariables }>()
|
|
17
18
|
|
|
18
19
|
const allowedOrigins = env.FRONTEND_URL
|
|
19
20
|
.split(",")
|
|
20
21
|
.map((url) => url.trim())
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
// 1. Generate unique request IDs for all API calls
|
|
24
|
+
app.use("/api/*", requestId())
|
|
25
|
+
|
|
26
|
+
// 2. Set up Pino child logger with correlation metadata per request
|
|
27
|
+
app.use("/api/*", async (c, next) => {
|
|
28
|
+
const reqId = c.get("requestId") || ""
|
|
29
|
+
const { method, url } = c.req
|
|
30
|
+
|
|
31
|
+
const reqLogger = logger.child({
|
|
32
|
+
requestId: reqId,
|
|
33
|
+
method,
|
|
34
|
+
url: new URL(url).pathname,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
c.set("logger", reqLogger)
|
|
38
|
+
await next()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// 3. Request Logging (bridge Hono's output into our Pino logger)
|
|
42
|
+
app.use(
|
|
43
|
+
"/api/*",
|
|
44
|
+
honoLogger((...args) => {
|
|
45
|
+
logger.info(args.join(" "))
|
|
46
|
+
})
|
|
47
|
+
)
|
|
23
48
|
|
|
24
49
|
app.use(
|
|
25
50
|
"/api/*",
|
|
@@ -36,7 +61,11 @@ app.use(
|
|
|
36
61
|
})
|
|
37
62
|
)
|
|
38
63
|
|
|
39
|
-
app
|
|
40
|
-
|
|
64
|
+
const routes = app
|
|
65
|
+
.route("/api/auth", authRoutes)
|
|
66
|
+
.route("/api/user", userRoutes)
|
|
41
67
|
|
|
42
68
|
app.get("/", (c) => c.json({ status: "ok" }))
|
|
69
|
+
|
|
70
|
+
export type AppType = typeof routes
|
|
71
|
+
|
|
@@ -7,5 +7,5 @@ import { env } from "../../config/env.js"
|
|
|
7
7
|
* Starts the `postgres-js` connection pool using the validated DATABASE_URL environment setting
|
|
8
8
|
* and wraps it with the Drizzle ORM client wrapper.
|
|
9
9
|
*/
|
|
10
|
-
const client = postgres(env.DATABASE_URL)
|
|
10
|
+
const client = postgres(env.DATABASE_URL, { max: 1 })
|
|
11
11
|
export const db = drizzle(client)
|
|
@@ -6,10 +6,37 @@ import { env } from "../../config/env.js"
|
|
|
6
6
|
* Configured using `pino`. Automatically toggles logging verbosity (info vs debug)
|
|
7
7
|
* and formats stdout output using `pino-pretty` during local development.
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
const level = env.NODE_ENV === "production" ? "info" : "debug"
|
|
10
|
+
|
|
11
|
+
let loggerInstance: pino.Logger
|
|
12
|
+
|
|
13
|
+
if (env.LOG_FILE_PATH) {
|
|
14
|
+
// If a log file path is provided, stream to both stdout (console) and the file
|
|
15
|
+
const streams = [
|
|
16
|
+
{ stream: process.stdout },
|
|
17
|
+
{ stream: pino.destination({ dest: env.LOG_FILE_PATH, sync: false }) }
|
|
18
|
+
]
|
|
19
|
+
loggerInstance = pino({ level }, pino.multistream(streams))
|
|
20
|
+
} else {
|
|
21
|
+
// Otherwise default to standard stdout (with pretty formatting in local development)
|
|
22
|
+
loggerInstance = pino({
|
|
23
|
+
level,
|
|
24
|
+
transport:
|
|
25
|
+
env.NODE_ENV === "production"
|
|
26
|
+
? undefined
|
|
27
|
+
: { target: "pino-pretty", options: { colorize: true } },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const logger = loggerInstance
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
import type { Logger } from "pino"
|
|
35
|
+
import type { AuthTokenPayload } from "../../domains/auth/auth.types.js"
|
|
36
|
+
|
|
37
|
+
export type AppVariables = {
|
|
38
|
+
requestId: string
|
|
39
|
+
logger: Logger
|
|
40
|
+
user?: AuthTokenPayload
|
|
41
|
+
}
|
|
42
|
+
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from "hono"
|
|
2
2
|
import { getCookie } from "hono/cookie"
|
|
3
3
|
import { authService } from "../../domains/auth/auth.service.js"
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
export type AuthVariables = { user: AuthTokenPayload }
|
|
4
|
+
import type { AppVariables } from "../logger/logger.js"
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
7
|
* Authentication guard middleware.
|
|
10
8
|
* Enforces the presence and validity of the short-lived JWT access token in request cookies.
|
|
11
|
-
* Resolves the payload
|
|
9
|
+
* Resolves the payload, binds it to Hono context variables (`c.get("user")`),
|
|
10
|
+
* and attaches the authenticated userId to the request logger context.
|
|
12
11
|
*
|
|
13
12
|
* Usage:
|
|
14
13
|
* Apply to any endpoint requiring authentication (e.g. `userRoutes.get("/me", requireAuth, ...)`).
|
|
15
14
|
*/
|
|
16
|
-
export const requireAuth: MiddlewareHandler<{ Variables:
|
|
15
|
+
export const requireAuth: MiddlewareHandler<{ Variables: AppVariables }> = async (c, next) => {
|
|
17
16
|
// Extract token from cookie (JWT)
|
|
18
17
|
const token = getCookie(c, "access_token")
|
|
19
18
|
if (!token) return c.json({ error: "Unauthorized" }, 401)
|
|
@@ -22,6 +21,12 @@ export const requireAuth: MiddlewareHandler<{ Variables: AuthVariables }> = asyn
|
|
|
22
21
|
// Decode and verify signature & expiration
|
|
23
22
|
const payload = await authService.verifyAccessToken(token)
|
|
24
23
|
c.set("user", payload)
|
|
24
|
+
|
|
25
|
+
// Enrich the child logger with the user's ID for all downstream actions
|
|
26
|
+
const reqLogger = c.get("logger")
|
|
27
|
+
if (reqLogger) {
|
|
28
|
+
c.set("logger", reqLogger.child({ userId: Number(payload.sub) }))
|
|
29
|
+
}
|
|
25
30
|
} catch {
|
|
26
31
|
// Return standard 401 response on invalid / expired signature
|
|
27
32
|
return c.json({ error: "Unauthorized" }, 401)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
NEXT_PUBLIC_API_URL=http://localhost:3001
|
|
@@ -1,36 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
# RestackPattern - Frontend App (Next.js)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This is the Next.js 16 (App Router) frontend app for the RestackPattern monorepo. It uses a Semi-DDD folder structure, vanilla CSS, and connects to Hono through its official RPC client (`hc<AppType>`), giving end-to-end type inference on every request/response without manually declaring types.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Directory Structure (Semi-DDD)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
npm run dev
|
|
9
|
-
# or
|
|
10
|
-
yarn dev
|
|
11
|
-
# or
|
|
12
|
-
pnpm dev
|
|
13
|
-
# or
|
|
14
|
-
bun dev
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
18
|
-
|
|
19
|
-
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
7
|
+
Code is structured by business domain/feature:
|
|
20
8
|
|
|
21
|
-
|
|
9
|
+
```
|
|
10
|
+
src/
|
|
11
|
+
├── app/ # Routing configs, layouts, pages (no complex JSX/state here!)
|
|
12
|
+
├── shared/ # Reusable components, client wrappers, hooks
|
|
13
|
+
│ ├── components/ui/ # Component primitives (e.g. Shadcn atoms)
|
|
14
|
+
│ └── lib/api-client.ts # Hono RPC client (hc<AppType>) + auto session refresh
|
|
15
|
+
└── domains/
|
|
16
|
+
├── auth/ # Login/Register components, auth services, hooks
|
|
17
|
+
└── user/ # User profile UI components, hooks
|
|
18
|
+
```
|
|
22
19
|
|
|
23
|
-
|
|
20
|
+
### Folder Rules:
|
|
21
|
+
- `app/` is strictly for routing and page wrappers. Do not implement complex forms, state, or layouts directly here. Always delegate rendering to domain feature components.
|
|
22
|
+
- `domains/[feature]/components` - UI components specific to the business feature.
|
|
23
|
+
- `domains/[feature]/hooks` - Handles fetching, mutations, and local state.
|
|
24
|
+
- `domains/[feature]/services` - Small wrappers making API requests.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
---
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
28
|
+
## Development
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Ensure the backend API is running at `http://localhost:3001`.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
```bash
|
|
33
|
+
# 1. Optional - configure environment variables (defaults to localhost:3001)
|
|
34
|
+
cp .env.example .env
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
# 2. Start Next.js development server
|
|
37
|
+
pnpm dev
|
|
38
|
+
```
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
- Dev server will run at [http://localhost:3000](http://localhost:3000).
|
|
41
|
+
- Runs with Turbopack enabled for lightning-fast hot reloading.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AuthUser, LoginInput, RegisterInput } from "@restack/shared"
|
|
2
|
-
import {
|
|
2
|
+
import { client, unwrap } from "@/shared/lib/api-client"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Service API layer for the Auth domain.
|
|
@@ -11,17 +11,11 @@ import { apiClient } from "@/shared/lib/api-client"
|
|
|
11
11
|
*/
|
|
12
12
|
export const authApi = {
|
|
13
13
|
register: (data: RegisterInput) =>
|
|
14
|
-
|
|
15
|
-
method: "POST",
|
|
16
|
-
body: JSON.stringify(data),
|
|
17
|
-
}),
|
|
14
|
+
unwrap<{ user: AuthUser }>(client.api.auth.register.$post({ json: data })),
|
|
18
15
|
|
|
19
16
|
login: (data: LoginInput) =>
|
|
20
|
-
|
|
21
|
-
method: "POST",
|
|
22
|
-
body: JSON.stringify(data),
|
|
23
|
-
}),
|
|
17
|
+
unwrap<{ user: AuthUser }>(client.api.auth.login.$post({ json: data })),
|
|
24
18
|
|
|
25
19
|
logout: () =>
|
|
26
|
-
|
|
20
|
+
unwrap<{ success: boolean }>(client.api.auth.logout.$post()),
|
|
27
21
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AuthUser, UpdateProfileInput } from "@restack/shared"
|
|
2
|
-
import {
|
|
2
|
+
import { client, unwrap } from "@/shared/lib/api-client"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Service API layer for the User domain.
|
|
@@ -11,11 +11,9 @@ import { apiClient } from "@/shared/lib/api-client"
|
|
|
11
11
|
* - Request and response structures are mapped using schemas shared from `@restack/shared`.
|
|
12
12
|
*/
|
|
13
13
|
export const userApi = {
|
|
14
|
-
me: () =>
|
|
14
|
+
me: () =>
|
|
15
|
+
unwrap<{ user: AuthUser }>(client.api.user.me.$get()),
|
|
15
16
|
|
|
16
17
|
updateProfile: (data: UpdateProfileInput) =>
|
|
17
|
-
|
|
18
|
-
method: "PUT",
|
|
19
|
-
body: JSON.stringify(data),
|
|
20
|
-
}),
|
|
18
|
+
unwrap<{ user: AuthUser }>(client.api.user.profile.$put({ json: data })),
|
|
21
19
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"@restack/shared": "workspace:*",
|
|
13
13
|
"class-variance-authority": "^0.7.1",
|
|
14
14
|
"clsx": "^2.1.1",
|
|
15
|
+
"hono": "^4.12.27",
|
|
15
16
|
"next": "16.2.9",
|
|
16
17
|
"radix-ui": "^1.6.1",
|
|
17
18
|
"react": "19.2.4",
|
|
@@ -31,5 +32,8 @@
|
|
|
31
32
|
"eslint-config-next": "16.2.9",
|
|
32
33
|
"tailwindcss": "^4",
|
|
33
34
|
"typescript": "^5"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
34
38
|
}
|
|
35
39
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react"
|
|
4
4
|
import { useRouter } from "next/navigation"
|
|
5
|
-
import {
|
|
5
|
+
import { client, unwrap } from "../lib/api-client"
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Client-side auth gate for pages with no data fetch of their own to piggyback
|
|
@@ -15,11 +15,17 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
|
|
|
15
15
|
const [checked, setChecked] = useState(false)
|
|
16
16
|
|
|
17
17
|
useEffect(() => {
|
|
18
|
-
|
|
18
|
+
unwrap(client.api.user.me.$get())
|
|
19
19
|
.then(() => setChecked(true))
|
|
20
20
|
.catch(() => router.replace("/login"))
|
|
21
21
|
}, [router])
|
|
22
22
|
|
|
23
|
-
if (!checked)
|
|
23
|
+
if (!checked) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
26
|
+
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
24
30
|
return <>{children}</>
|
|
25
31
|
}
|
|
@@ -1,73 +1,77 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* credentials handling (sending HTTP cookies), and base API URL resolution.
|
|
5
|
-
*/
|
|
1
|
+
import { hc } from "hono/client"
|
|
2
|
+
import type { AppType } from "../../../backend/src/hono-app"
|
|
3
|
+
|
|
6
4
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
* Custom API Error wrapping utility.
|
|
10
|
-
* Captures both the backend error message payload and the raw HTTP response status code.
|
|
11
|
-
*/
|
|
12
|
-
class ApiError extends Error {
|
|
6
|
+
export class ApiError extends Error {
|
|
13
7
|
constructor(message: string, public status: number) {
|
|
14
8
|
super(message)
|
|
15
9
|
}
|
|
16
10
|
}
|
|
17
11
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
23
|
-
const res = await fetch(`${BASE_URL}${path}`, {
|
|
24
|
-
...options,
|
|
25
|
-
headers: {
|
|
26
|
-
"Content-Type": "application/json",
|
|
27
|
-
...options?.headers,
|
|
28
|
-
},
|
|
29
|
-
credentials: "include",
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
const contentType = res.headers.get("content-type")
|
|
33
|
-
const data: unknown =
|
|
34
|
-
contentType && contentType.includes("application/json")
|
|
35
|
-
? await res.json()
|
|
36
|
-
: { error: (await res.text()) || "Request failed" }
|
|
37
|
-
|
|
38
|
-
if (!res.ok) throw new ApiError((data as ApiErrorBody).error ?? "Request failed", res.status)
|
|
39
|
-
return data as T
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// The refresh token is single-use (backend rotates it atomically), so if several
|
|
43
|
-
// requests 401 at once they must share one refresh call, not each fire their own —
|
|
44
|
-
// only the first would succeed and the rest would wrongly fail as "logged out".
|
|
45
|
-
let refreshPromise: Promise<unknown> | null = null
|
|
12
|
+
// Share a single refresh request promise to prevent TOCTOU race conditions
|
|
13
|
+
let refreshPromise: Promise<Response> | null = null
|
|
46
14
|
|
|
47
15
|
function refreshSession() {
|
|
48
16
|
if (!refreshPromise) {
|
|
49
|
-
refreshPromise =
|
|
17
|
+
refreshPromise = fetch(`${BASE_URL}/api/auth/refresh`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
credentials: "include",
|
|
20
|
+
}).finally(() => {
|
|
50
21
|
refreshPromise = null
|
|
51
22
|
})
|
|
52
23
|
}
|
|
53
24
|
return refreshPromise
|
|
54
25
|
}
|
|
55
26
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
27
|
+
const customFetch: typeof fetch = async (input, init) => {
|
|
28
|
+
const newInit = {
|
|
29
|
+
...init,
|
|
30
|
+
credentials: "include" as const,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const res = await fetch(input, newInit)
|
|
34
|
+
|
|
35
|
+
// Silently rotate access token on 401, unless it's the auth endpoints themselves
|
|
36
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
|
|
37
|
+
const isAuthRoute = url.includes("/api/auth/")
|
|
38
|
+
|
|
39
|
+
if (res.status === 401 && !isAuthRoute) {
|
|
40
|
+
const refreshRes = await refreshSession()
|
|
41
|
+
if (refreshRes.ok) {
|
|
42
|
+
// Retry the original request
|
|
43
|
+
return fetch(input, newInit)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return res
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Expose the Hono RPC client
|
|
51
|
+
export const client = hc<AppType>(BASE_URL, { fetch: customFetch })
|
|
52
|
+
|
|
53
|
+
interface ApiErrorBody {
|
|
54
|
+
error?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RpcResult {
|
|
58
|
+
ok: boolean
|
|
59
|
+
status: number
|
|
60
|
+
json(): Promise<unknown>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Unwrap Hono RPC response helper to parse JSON or throw formatted ApiError
|
|
64
|
+
export async function unwrap<T>(promise: Promise<RpcResult>): Promise<T> {
|
|
65
|
+
const res = await promise
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
let errorMsg = "Request failed"
|
|
68
|
+
try {
|
|
69
|
+
const data = (await res.json()) as ApiErrorBody
|
|
70
|
+
errorMsg = data.error ?? errorMsg
|
|
71
|
+
} catch {
|
|
72
|
+
// fallback
|
|
70
73
|
}
|
|
71
|
-
throw
|
|
74
|
+
throw new ApiError(errorMsg, res.status)
|
|
72
75
|
}
|
|
76
|
+
return res.json() as Promise<T>
|
|
73
77
|
}
|
package/template/gitignore
CHANGED
|
@@ -32,4 +32,12 @@ Thumbs.db
|
|
|
32
32
|
.idea/
|
|
33
33
|
|
|
34
34
|
# Claude Code local overrides (personal, not shared with the team)
|
|
35
|
-
.claude/settings.local.json
|
|
35
|
+
.claude/settings.local.json
|
|
36
|
+
test-project/
|
|
37
|
+
test-app/
|
|
38
|
+
|
|
39
|
+
# Exclude concerns and root CLAUDE.md from git
|
|
40
|
+
docs/concerns.md
|
|
41
|
+
CLAUDE.md
|
|
42
|
+
|
|
43
|
+
docs/tutorial.md
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "restack-pattern",
|
|
3
3
|
"private": true,
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"scripts": {
|
|
5
6
|
"dev": "pnpm --parallel dev",
|
|
6
7
|
"build": "pnpm --parallel build"
|
|
@@ -9,5 +10,8 @@
|
|
|
9
10
|
"frontend",
|
|
10
11
|
"backend",
|
|
11
12
|
"packages/*"
|
|
12
|
-
]
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
}
|
|
13
17
|
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import type { Context, MiddlewareHandler } from "hono"
|
|
2
|
-
import { getConnInfo } from "@hono/node-server/conninfo"
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* In-Memory Fixed-Window Rate Limiting Middleware.
|
|
6
|
-
*
|
|
7
|
-
* Performance/Topology Note:
|
|
8
|
-
* - This implementation stores counts in a local `Map` memory segment.
|
|
9
|
-
* - This works perfectly for single-process deployments (like a standalone Railway node).
|
|
10
|
-
* - If horizontal scaling (multiple instances) is introduced, this must be swapped
|
|
11
|
-
* for a shared datastore (e.g. Redis) to ensure limits are synchronized globally.
|
|
12
|
-
*/
|
|
13
|
-
const WINDOW_MS = 60_000
|
|
14
|
-
const MAX_REQUESTS = 10
|
|
15
|
-
|
|
16
|
-
const hits = new Map<string, { count: number; resetAt: number }>()
|
|
17
|
-
|
|
18
|
-
// getConnInfo reaches into the raw Node socket, which only exists when served via a real
|
|
19
|
-
// @hono/node-server connection — it throws in in-memory test harnesses (Hono's `.request()`)
|
|
20
|
-
// and some serverless runtimes. Fall back to a shared bucket rather than crashing the request.
|
|
21
|
-
const clientIp = (c: Context) => {
|
|
22
|
-
try {
|
|
23
|
-
return getConnInfo(c).remote.address ?? "unknown"
|
|
24
|
-
} catch {
|
|
25
|
-
return "unknown"
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const rateLimit: MiddlewareHandler = async (c, next) => {
|
|
30
|
-
const key = `${c.req.path}:${clientIp(c)}`
|
|
31
|
-
const now = Date.now()
|
|
32
|
-
const entry = hits.get(key)
|
|
33
|
-
|
|
34
|
-
if (!entry || entry.resetAt < now) {
|
|
35
|
-
hits.set(key, { count: 1, resetAt: now + WINDOW_MS })
|
|
36
|
-
} else if (entry.count >= MAX_REQUESTS) {
|
|
37
|
-
return c.json({ error: "Too many requests, try again later" }, 429)
|
|
38
|
-
} else {
|
|
39
|
-
entry.count++
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
await next()
|
|
43
|
-
}
|