@nubase/create 0.1.2 → 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/dist/index.js +1 -4
- package/package.json +1 -1
- package/templates/backend/package.json +6 -5
- package/templates/backend/src/api/routes/auth.ts +378 -203
- package/templates/backend/src/api/routes/ticket.ts +104 -55
- package/templates/backend/src/auth/index.ts +208 -14
- package/templates/backend/src/index.ts +28 -23
- package/templates/backend/src/middleware/workspace-middleware.ts +92 -6
- package/templates/frontend/package.json +2 -2
- package/templates/frontend/src/config.tsx +1 -1
- package/templates/frontend/src/resources/ticket.ts +1 -1
- package/templates/root/README.md +4 -4
- package/templates/root/package.json +7 -7
- package/templates/schema/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -107,10 +107,7 @@ Creating project in ${chalk.bold(targetDir)}...
|
|
|
107
107
|
if (template === "root") {
|
|
108
108
|
copyTemplateDir(templatePath, targetDir, options);
|
|
109
109
|
} else {
|
|
110
|
-
const destPath = path.join(
|
|
111
|
-
targetDir,
|
|
112
|
-
`${toKebabCase(projectName)}-${template}`
|
|
113
|
-
);
|
|
110
|
+
const destPath = path.join(targetDir, template);
|
|
114
111
|
copyTemplateDir(templatePath, destPath, options);
|
|
115
112
|
}
|
|
116
113
|
console.log(chalk.green(` \u2713 Created ${template}`));
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "backend",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
"start": "node dist/index.js",
|
|
10
10
|
"db:dev:up": "docker compose -f docker/dev/docker-compose.yml up -d",
|
|
11
11
|
"db:dev:down": "docker compose -f docker/dev/docker-compose.yml down",
|
|
12
|
-
"db:dev:kill": "docker compose -f docker/dev/docker-compose.yml down -v",
|
|
12
|
+
"db:dev:kill": "docker compose -f docker/dev/docker-compose.yml down -v && rm -rf docker/dev/postgresql-data",
|
|
13
13
|
"db:dev:seed": "NODE_ENV=development tsx src/db/seed.ts",
|
|
14
|
+
"db:dev:reset": "npm run db:dev:kill && npm run db:schema-sync && npm run db:dev:up && sleep 2 && npm run db:dev:seed",
|
|
14
15
|
"db:test:up": "docker compose -f docker/test/docker-compose.yml up -d",
|
|
15
16
|
"db:test:down": "docker compose -f docker/test/docker-compose.yml down",
|
|
16
|
-
"db:test:kill": "docker compose -f docker/test/docker-compose.yml down -v",
|
|
17
|
-
"db:
|
|
17
|
+
"db:test:kill": "docker compose -f docker/test/docker-compose.yml down -v && rm -rf docker/test/postgresql-data",
|
|
18
|
+
"db:test:reset": "npm run db:test:kill && npm run db:schema-sync && npm run db:test:up",
|
|
18
19
|
"db:schema-sync": "cp db/schema.sql docker/dev/postgresql-init/dump.sql && cp db/schema.sql docker/test/postgresql-init/dump.sql",
|
|
19
20
|
"typecheck": "tsc --noEmit",
|
|
20
21
|
"lint": "biome check .",
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"hono": "^4.6.14",
|
|
32
33
|
"jsonwebtoken": "^9.0.2",
|
|
33
34
|
"pg": "^8.13.1",
|
|
34
|
-
"
|
|
35
|
+
"schema": "*"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@faker-js/faker": "^9.3.0",
|
|
@@ -1,209 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHttpHandler,
|
|
3
|
+
getAuthController,
|
|
4
|
+
HttpError,
|
|
5
|
+
} from "@nubase/backend";
|
|
1
6
|
import bcrypt from "bcrypt";
|
|
2
|
-
import { eq } from "drizzle-orm";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
8
|
+
import { apiEndpoints } from "schema";
|
|
9
|
+
import jwt from "jsonwebtoken";
|
|
10
|
+
import type { __PROJECT_NAME_PASCAL__User } from "../../auth";
|
|
11
|
+
import { db, adminDb } from "../../db/helpers/drizzle";
|
|
12
|
+
import { users, userWorkspaces, workspaces } from "../../db/schema";
|
|
13
|
+
|
|
14
|
+
// Short-lived secret for login tokens (in production, use a proper secret)
|
|
15
|
+
const LOGIN_TOKEN_SECRET =
|
|
16
|
+
process.env.LOGIN_TOKEN_SECRET ||
|
|
17
|
+
"nubase-login-token-secret-change-in-production";
|
|
18
|
+
const LOGIN_TOKEN_EXPIRY = "5m"; // 5 minutes to complete workspace selection
|
|
19
|
+
|
|
20
|
+
interface LoginTokenPayload {
|
|
21
|
+
userId: number;
|
|
22
|
+
username: string;
|
|
23
|
+
}
|
|
9
24
|
|
|
10
25
|
export const authHandlers = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Login Start handler - Step 1 of two-step auth.
|
|
28
|
+
* Validates credentials and returns list of workspaces.
|
|
29
|
+
*/
|
|
30
|
+
loginStart: createHttpHandler({
|
|
31
|
+
endpoint: apiEndpoints.loginStart,
|
|
32
|
+
handler: async ({ body }) => {
|
|
33
|
+
// Find user by username
|
|
34
|
+
const [user] = await adminDb
|
|
35
|
+
.select()
|
|
36
|
+
.from(users)
|
|
37
|
+
.where(eq(users.username, body.username));
|
|
38
|
+
|
|
39
|
+
if (!user) {
|
|
40
|
+
throw new HttpError(401, "Invalid username or password");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Verify password
|
|
44
|
+
const isValidPassword = await bcrypt.compare(
|
|
45
|
+
body.password,
|
|
46
|
+
user.passwordHash,
|
|
47
|
+
);
|
|
48
|
+
if (!isValidPassword) {
|
|
49
|
+
throw new HttpError(401, "Invalid username or password");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Get all workspaces this user belongs to
|
|
53
|
+
const userWorkspaceRows = await adminDb
|
|
54
|
+
.select()
|
|
55
|
+
.from(userWorkspaces)
|
|
56
|
+
.where(eq(userWorkspaces.userId, user.id));
|
|
57
|
+
|
|
58
|
+
if (userWorkspaceRows.length === 0) {
|
|
59
|
+
throw new HttpError(401, "User has no workspace access");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch workspace details
|
|
63
|
+
const workspaceIds = userWorkspaceRows.map((uw) => uw.workspaceId);
|
|
64
|
+
const workspaceList = await adminDb
|
|
65
|
+
.select()
|
|
66
|
+
.from(workspaces)
|
|
67
|
+
.where(inArray(workspaces.id, workspaceIds));
|
|
68
|
+
|
|
69
|
+
// Create a short-lived login token
|
|
70
|
+
const loginToken = jwt.sign(
|
|
71
|
+
{
|
|
72
|
+
userId: user.id,
|
|
73
|
+
username: body.username,
|
|
74
|
+
} satisfies LoginTokenPayload,
|
|
75
|
+
LOGIN_TOKEN_SECRET,
|
|
76
|
+
{ expiresIn: LOGIN_TOKEN_EXPIRY },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
loginToken,
|
|
81
|
+
username: body.username,
|
|
82
|
+
workspaces: workspaceList.map((w) => ({
|
|
83
|
+
id: w.id,
|
|
84
|
+
slug: w.slug,
|
|
85
|
+
name: w.name,
|
|
86
|
+
})),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Login Complete handler - Step 2 of two-step auth.
|
|
93
|
+
* Validates the login token and selected workspace.
|
|
94
|
+
*/
|
|
95
|
+
loginComplete: createHttpHandler({
|
|
96
|
+
endpoint: apiEndpoints.loginComplete,
|
|
97
|
+
handler: async ({ body, ctx }) => {
|
|
98
|
+
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
99
|
+
|
|
100
|
+
// Verify the login token
|
|
101
|
+
let decoded: LoginTokenPayload;
|
|
102
|
+
try {
|
|
103
|
+
decoded = jwt.verify(
|
|
104
|
+
body.loginToken,
|
|
105
|
+
LOGIN_TOKEN_SECRET,
|
|
106
|
+
) as LoginTokenPayload;
|
|
107
|
+
} catch {
|
|
108
|
+
throw new HttpError(401, "Invalid or expired login token");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Look up the selected workspace
|
|
112
|
+
const [workspace] = await adminDb
|
|
113
|
+
.select()
|
|
114
|
+
.from(workspaces)
|
|
115
|
+
.where(eq(workspaces.slug, body.workspace));
|
|
116
|
+
|
|
117
|
+
if (!workspace) {
|
|
118
|
+
throw new HttpError(404, `Workspace not found: ${body.workspace}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Verify user has access to this workspace
|
|
122
|
+
const [access] = await adminDb
|
|
123
|
+
.select()
|
|
124
|
+
.from(userWorkspaces)
|
|
125
|
+
.where(
|
|
126
|
+
and(
|
|
127
|
+
eq(userWorkspaces.userId, decoded.userId),
|
|
128
|
+
eq(userWorkspaces.workspaceId, workspace.id),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (!access) {
|
|
133
|
+
throw new HttpError(403, "You do not have access to this workspace");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fetch the user
|
|
137
|
+
const [dbUser] = await adminDb
|
|
138
|
+
.select()
|
|
139
|
+
.from(users)
|
|
140
|
+
.where(eq(users.id, decoded.userId));
|
|
141
|
+
|
|
142
|
+
if (!dbUser) {
|
|
143
|
+
throw new HttpError(401, "User not found");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Create user object for token
|
|
147
|
+
const user: __PROJECT_NAME_PASCAL__User = {
|
|
148
|
+
id: dbUser.id,
|
|
149
|
+
email: dbUser.email,
|
|
150
|
+
username: dbUser.username,
|
|
151
|
+
workspaceId: workspace.id,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Create and set the auth token
|
|
155
|
+
const token = await authController.createToken(user);
|
|
156
|
+
authController.setTokenInResponse(ctx, token);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
user: {
|
|
160
|
+
id: user.id,
|
|
161
|
+
email: user.email,
|
|
162
|
+
username: user.username,
|
|
163
|
+
},
|
|
164
|
+
workspace: {
|
|
165
|
+
id: workspace.id,
|
|
166
|
+
slug: workspace.slug,
|
|
167
|
+
name: workspace.name,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Legacy Login handler - validates credentials and sets HttpOnly cookie.
|
|
175
|
+
* @deprecated Use loginStart and loginComplete for two-step flow
|
|
176
|
+
*/
|
|
177
|
+
login: createHttpHandler({
|
|
178
|
+
endpoint: apiEndpoints.login,
|
|
179
|
+
handler: async ({ body, ctx }) => {
|
|
180
|
+
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
181
|
+
|
|
182
|
+
// Look up workspace
|
|
183
|
+
const [workspace] = await adminDb
|
|
184
|
+
.select()
|
|
185
|
+
.from(workspaces)
|
|
186
|
+
.where(eq(workspaces.slug, body.workspace));
|
|
187
|
+
|
|
188
|
+
if (!workspace) {
|
|
189
|
+
throw new HttpError(404, `Workspace not found: ${body.workspace}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Find user by username
|
|
193
|
+
const [dbUser] = await adminDb
|
|
194
|
+
.select()
|
|
195
|
+
.from(users)
|
|
196
|
+
.where(eq(users.username, body.username));
|
|
197
|
+
|
|
198
|
+
if (!dbUser) {
|
|
199
|
+
throw new HttpError(401, "Invalid username or password");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Verify password
|
|
203
|
+
const isValidPassword = await bcrypt.compare(
|
|
204
|
+
body.password,
|
|
205
|
+
dbUser.passwordHash,
|
|
206
|
+
);
|
|
207
|
+
if (!isValidPassword) {
|
|
208
|
+
throw new HttpError(401, "Invalid username or password");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Verify user has access to this workspace
|
|
212
|
+
const [access] = await adminDb
|
|
213
|
+
.select()
|
|
214
|
+
.from(userWorkspaces)
|
|
215
|
+
.where(
|
|
216
|
+
and(
|
|
217
|
+
eq(userWorkspaces.userId, dbUser.id),
|
|
218
|
+
eq(userWorkspaces.workspaceId, workspace.id),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (!access) {
|
|
223
|
+
throw new HttpError(403, "You do not have access to this workspace");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create user object for token
|
|
227
|
+
const user: __PROJECT_NAME_PASCAL__User = {
|
|
228
|
+
id: dbUser.id,
|
|
229
|
+
email: dbUser.email,
|
|
230
|
+
username: dbUser.username,
|
|
231
|
+
workspaceId: workspace.id,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Create and set token
|
|
235
|
+
const token = await authController.createToken(user);
|
|
236
|
+
authController.setTokenInResponse(ctx, token);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
user: {
|
|
240
|
+
id: user.id,
|
|
241
|
+
email: user.email,
|
|
242
|
+
username: user.username,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
|
|
248
|
+
/** Logout handler - clears the auth cookie. */
|
|
249
|
+
logout: createHttpHandler({
|
|
250
|
+
endpoint: apiEndpoints.logout,
|
|
251
|
+
handler: async ({ ctx }) => {
|
|
252
|
+
const authController = getAuthController(ctx);
|
|
253
|
+
authController.clearTokenFromResponse(ctx);
|
|
254
|
+
return { success: true };
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
|
|
258
|
+
/** Get current user handler. */
|
|
259
|
+
getMe: createHttpHandler<
|
|
260
|
+
typeof apiEndpoints.getMe,
|
|
261
|
+
"optional",
|
|
262
|
+
__PROJECT_NAME_PASCAL__User
|
|
263
|
+
>({
|
|
264
|
+
endpoint: apiEndpoints.getMe,
|
|
265
|
+
auth: "optional",
|
|
266
|
+
handler: async ({ user }) => {
|
|
267
|
+
if (!user) {
|
|
268
|
+
return { user: undefined };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
user: {
|
|
273
|
+
id: user.id,
|
|
274
|
+
email: user.email,
|
|
275
|
+
username: user.username,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
|
|
281
|
+
/** Signup handler - creates a new workspace and admin user. */
|
|
282
|
+
signup: createHttpHandler({
|
|
283
|
+
endpoint: apiEndpoints.signup,
|
|
284
|
+
handler: async ({ body, ctx }) => {
|
|
285
|
+
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
286
|
+
|
|
287
|
+
// Validate workspace slug format
|
|
288
|
+
if (!/^[a-z0-9-]+$/.test(body.workspace)) {
|
|
289
|
+
throw new HttpError(
|
|
290
|
+
400,
|
|
291
|
+
"Workspace slug must be lowercase and contain only letters, numbers, and hyphens",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check if workspace slug already exists
|
|
296
|
+
const [existingWorkspace] = await adminDb
|
|
297
|
+
.select()
|
|
298
|
+
.from(workspaces)
|
|
299
|
+
.where(eq(workspaces.slug, body.workspace));
|
|
300
|
+
|
|
301
|
+
if (existingWorkspace) {
|
|
302
|
+
throw new HttpError(409, "Organization slug is already taken");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check if username already exists
|
|
306
|
+
const [existingUser] = await adminDb
|
|
307
|
+
.select()
|
|
308
|
+
.from(users)
|
|
309
|
+
.where(eq(users.username, body.username));
|
|
310
|
+
|
|
311
|
+
if (existingUser) {
|
|
312
|
+
throw new HttpError(409, "Username is already taken");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Check if email already exists
|
|
316
|
+
const [existingEmail] = await adminDb
|
|
317
|
+
.select()
|
|
318
|
+
.from(users)
|
|
319
|
+
.where(eq(users.email, body.email));
|
|
320
|
+
|
|
321
|
+
if (existingEmail) {
|
|
322
|
+
throw new HttpError(409, "Email is already registered");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Validate password length
|
|
326
|
+
if (body.password.length < 8) {
|
|
327
|
+
throw new HttpError(400, "Password must be at least 8 characters long");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Create the workspace
|
|
331
|
+
const [newWorkspace] = await adminDb
|
|
186
332
|
.insert(workspaces)
|
|
187
|
-
.values({
|
|
333
|
+
.values({
|
|
334
|
+
slug: body.workspace,
|
|
335
|
+
name: body.workspaceName,
|
|
336
|
+
})
|
|
337
|
+
.returning();
|
|
338
|
+
|
|
339
|
+
// Hash the password
|
|
340
|
+
const passwordHash = await bcrypt.hash(body.password, 10);
|
|
341
|
+
|
|
342
|
+
// Create the admin user
|
|
343
|
+
const [newUser] = await adminDb
|
|
344
|
+
.insert(users)
|
|
345
|
+
.values({
|
|
346
|
+
email: body.email,
|
|
347
|
+
username: body.username,
|
|
348
|
+
passwordHash,
|
|
349
|
+
})
|
|
188
350
|
.returning();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
token
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
351
|
+
|
|
352
|
+
// Link user to workspace
|
|
353
|
+
await adminDb.insert(userWorkspaces).values({
|
|
354
|
+
userId: newUser.id,
|
|
355
|
+
workspaceId: newWorkspace.id,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Create user object for token
|
|
359
|
+
const user: __PROJECT_NAME_PASCAL__User = {
|
|
360
|
+
id: newUser.id,
|
|
361
|
+
email: newUser.email,
|
|
362
|
+
username: newUser.username,
|
|
363
|
+
workspaceId: newWorkspace.id,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Create and set the auth token
|
|
367
|
+
const token = await authController.createToken(user);
|
|
368
|
+
authController.setTokenInResponse(ctx, token);
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
user: {
|
|
372
|
+
id: user.id,
|
|
373
|
+
email: user.email,
|
|
374
|
+
username: user.username,
|
|
375
|
+
},
|
|
376
|
+
workspace: {
|
|
377
|
+
id: newWorkspace.id,
|
|
378
|
+
slug: newWorkspace.slug,
|
|
379
|
+
name: newWorkspace.name,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
209
384
|
};
|
|
@@ -1,62 +1,111 @@
|
|
|
1
|
+
import { createHttpHandler, HttpError } from "@nubase/backend";
|
|
1
2
|
import { eq } from "drizzle-orm";
|
|
2
|
-
import
|
|
3
|
+
import { apiEndpoints } from "schema";
|
|
3
4
|
import { db } from "../../db/helpers/drizzle";
|
|
4
5
|
import { tickets } from "../../db/schema";
|
|
5
6
|
|
|
6
7
|
export const ticketHandlers = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
8
|
+
getTickets: createHttpHandler({
|
|
9
|
+
endpoint: apiEndpoints.getTickets,
|
|
10
|
+
handler: async () => {
|
|
11
|
+
const allTickets = await db.select().from(tickets);
|
|
12
|
+
return allTickets.map((ticket) => ({
|
|
13
|
+
id: ticket.id,
|
|
14
|
+
title: ticket.title,
|
|
15
|
+
description: ticket.description ?? undefined,
|
|
16
|
+
}));
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
|
|
20
|
+
getTicket: createHttpHandler({
|
|
21
|
+
endpoint: apiEndpoints.getTicket,
|
|
22
|
+
handler: async ({ params }) => {
|
|
23
|
+
const [ticket] = await db
|
|
24
|
+
.select()
|
|
25
|
+
.from(tickets)
|
|
26
|
+
.where(eq(tickets.id, params.id));
|
|
27
|
+
|
|
28
|
+
if (!ticket) {
|
|
29
|
+
throw new HttpError(404, "Ticket not found");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id: ticket.id,
|
|
34
|
+
title: ticket.title,
|
|
35
|
+
description: ticket.description ?? undefined,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
postTicket: createHttpHandler({
|
|
41
|
+
endpoint: apiEndpoints.postTicket,
|
|
42
|
+
handler: async ({ body }) => {
|
|
43
|
+
const [ticket] = await db
|
|
44
|
+
.insert(tickets)
|
|
45
|
+
.values({
|
|
46
|
+
workspaceId: 1, // TODO: Get from context
|
|
47
|
+
title: body.title,
|
|
48
|
+
description: body.description,
|
|
49
|
+
})
|
|
50
|
+
.returning();
|
|
51
|
+
|
|
52
|
+
if (!ticket) {
|
|
53
|
+
throw new HttpError(500, "Failed to create ticket");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: ticket.id,
|
|
58
|
+
title: ticket.title,
|
|
59
|
+
description: ticket.description ?? undefined,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
patchTicket: createHttpHandler({
|
|
65
|
+
endpoint: apiEndpoints.patchTicket,
|
|
66
|
+
handler: async ({ params, body }) => {
|
|
67
|
+
const updateData: { title?: string; description?: string; updatedAt: Date } = {
|
|
45
68
|
updatedAt: new Date(),
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (body.title !== undefined) {
|
|
72
|
+
updateData.title = body.title;
|
|
73
|
+
}
|
|
74
|
+
if (body.description !== undefined) {
|
|
75
|
+
updateData.description = body.description;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [ticket] = await db
|
|
79
|
+
.update(tickets)
|
|
80
|
+
.set(updateData)
|
|
81
|
+
.where(eq(tickets.id, params.id))
|
|
82
|
+
.returning();
|
|
83
|
+
|
|
84
|
+
if (!ticket) {
|
|
85
|
+
throw new HttpError(404, "Ticket not found");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: ticket.id,
|
|
90
|
+
title: ticket.title,
|
|
91
|
+
description: ticket.description ?? undefined,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
|
|
96
|
+
deleteTicket: createHttpHandler({
|
|
97
|
+
endpoint: apiEndpoints.deleteTicket,
|
|
98
|
+
handler: async ({ params }) => {
|
|
99
|
+
const [deleted] = await db
|
|
100
|
+
.delete(tickets)
|
|
101
|
+
.where(eq(tickets.id, params.id))
|
|
102
|
+
.returning();
|
|
103
|
+
|
|
104
|
+
if (!deleted) {
|
|
105
|
+
throw new HttpError(404, "Ticket not found");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { success: true };
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
62
111
|
};
|
|
@@ -1,32 +1,226 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BackendAuthController,
|
|
3
|
+
BackendUser,
|
|
4
|
+
TokenPayload,
|
|
5
|
+
VerifyTokenResult,
|
|
6
|
+
} from "@nubase/backend";
|
|
7
|
+
import { getCookie } from "@nubase/backend";
|
|
1
8
|
import bcrypt from "bcrypt";
|
|
9
|
+
import { and, eq } from "drizzle-orm";
|
|
10
|
+
import type { Context } from "hono";
|
|
2
11
|
import jwt from "jsonwebtoken";
|
|
12
|
+
import { adminDb } from "../db/helpers/drizzle";
|
|
13
|
+
import { users, userWorkspaces } from "../db/schema";
|
|
3
14
|
|
|
4
|
-
|
|
5
|
-
|
|
15
|
+
/**
|
|
16
|
+
* User type for __PROJECT_NAME_PASCAL__ application.
|
|
17
|
+
*/
|
|
18
|
+
export interface __PROJECT_NAME_PASCAL__User extends BackendUser {
|
|
19
|
+
id: number;
|
|
20
|
+
email: string;
|
|
21
|
+
username: string;
|
|
6
22
|
workspaceId: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Token payload for __PROJECT_NAME_PASCAL__ JWTs.
|
|
27
|
+
*/
|
|
28
|
+
export interface __PROJECT_NAME_PASCAL__TokenPayload extends TokenPayload {
|
|
29
|
+
userId: number;
|
|
7
30
|
username: string;
|
|
31
|
+
workspaceId: number;
|
|
8
32
|
}
|
|
9
33
|
|
|
10
|
-
|
|
34
|
+
// Configuration
|
|
35
|
+
const JWT_SECRET =
|
|
36
|
+
process.env.JWT_SECRET || "nubase-dev-secret-change-in-production";
|
|
37
|
+
const JWT_EXPIRY = "1h";
|
|
38
|
+
const COOKIE_NAME = "nubase_auth";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* __PROJECT_NAME_PASCAL__-specific implementation of BackendAuthController.
|
|
42
|
+
* Handles JWT-based authentication using HttpOnly cookies.
|
|
43
|
+
*/
|
|
44
|
+
export class __PROJECT_NAME_PASCAL__AuthController
|
|
45
|
+
implements
|
|
46
|
+
BackendAuthController<
|
|
47
|
+
__PROJECT_NAME_PASCAL__User,
|
|
48
|
+
__PROJECT_NAME_PASCAL__TokenPayload
|
|
49
|
+
>
|
|
50
|
+
{
|
|
51
|
+
/**
|
|
52
|
+
* Extract the authentication token from the request.
|
|
53
|
+
*/
|
|
54
|
+
extractToken(ctx: Context): string | null {
|
|
55
|
+
// Check Authorization header first (Bearer token)
|
|
56
|
+
const authHeader = ctx.req.header("Authorization");
|
|
57
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
58
|
+
return authHeader.slice(7);
|
|
59
|
+
}
|
|
11
60
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
61
|
+
// Fall back to cookie
|
|
62
|
+
const cookieHeader = ctx.req.header("Cookie") || "";
|
|
63
|
+
return getCookie(cookieHeader, COOKIE_NAME);
|
|
15
64
|
}
|
|
16
65
|
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Verify a JWT token and return the authenticated user.
|
|
68
|
+
*/
|
|
69
|
+
async verifyToken(
|
|
70
|
+
token: string,
|
|
71
|
+
): Promise<VerifyTokenResult<__PROJECT_NAME_PASCAL__User>> {
|
|
72
|
+
try {
|
|
73
|
+
// Verify JWT signature and expiration
|
|
74
|
+
const decoded = jwt.verify(
|
|
75
|
+
token,
|
|
76
|
+
JWT_SECRET,
|
|
77
|
+
) as __PROJECT_NAME_PASCAL__TokenPayload;
|
|
78
|
+
|
|
79
|
+
// Fetch user from database to ensure they still exist
|
|
80
|
+
const [dbUser] = await adminDb
|
|
81
|
+
.select()
|
|
82
|
+
.from(users)
|
|
83
|
+
.where(eq(users.id, decoded.userId));
|
|
84
|
+
|
|
85
|
+
if (!dbUser) {
|
|
86
|
+
return { valid: false, error: "User not found" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Verify user still has access to the workspace in the token
|
|
90
|
+
const [access] = await adminDb
|
|
91
|
+
.select()
|
|
92
|
+
.from(userWorkspaces)
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(userWorkspaces.userId, decoded.userId),
|
|
96
|
+
eq(userWorkspaces.workspaceId, decoded.workspaceId),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (!access) {
|
|
101
|
+
return {
|
|
102
|
+
valid: false,
|
|
103
|
+
error: "User no longer has access to this workspace",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
valid: true,
|
|
109
|
+
user: {
|
|
110
|
+
id: dbUser.id,
|
|
111
|
+
email: dbUser.email,
|
|
112
|
+
username: dbUser.username,
|
|
113
|
+
workspaceId: decoded.workspaceId,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
118
|
+
return { valid: false, error: "Token expired" };
|
|
119
|
+
}
|
|
120
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
121
|
+
return { valid: false, error: "Invalid token" };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
valid: false,
|
|
125
|
+
error:
|
|
126
|
+
error instanceof Error ? error.message : "Token verification failed",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
19
129
|
}
|
|
20
130
|
|
|
21
|
-
|
|
22
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Create a JWT token for a user.
|
|
133
|
+
*/
|
|
134
|
+
async createToken(
|
|
135
|
+
user: __PROJECT_NAME_PASCAL__User,
|
|
136
|
+
additionalPayload?: Partial<__PROJECT_NAME_PASCAL__TokenPayload>,
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
const payload: __PROJECT_NAME_PASCAL__TokenPayload = {
|
|
139
|
+
userId: user.id,
|
|
140
|
+
username: user.username,
|
|
141
|
+
workspaceId: user.workspaceId,
|
|
142
|
+
...additionalPayload,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
|
23
146
|
}
|
|
24
147
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Set the authentication token in an HttpOnly cookie.
|
|
150
|
+
*/
|
|
151
|
+
setTokenInResponse(ctx: Context, token: string): void {
|
|
152
|
+
ctx.header(
|
|
153
|
+
"Set-Cookie",
|
|
154
|
+
`${COOKIE_NAME}=${token}; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=3600`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Clear the authentication cookie.
|
|
160
|
+
*/
|
|
161
|
+
clearTokenFromResponse(ctx: Context): void {
|
|
162
|
+
ctx.header(
|
|
163
|
+
"Set-Cookie",
|
|
164
|
+
`${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=None; Secure; Max-Age=0`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Validate user credentials during login.
|
|
170
|
+
*/
|
|
171
|
+
async validateCredentials(
|
|
172
|
+
username: string,
|
|
173
|
+
password: string,
|
|
174
|
+
workspaceId?: number,
|
|
175
|
+
): Promise<__PROJECT_NAME_PASCAL__User | null> {
|
|
176
|
+
if (workspaceId === undefined) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"workspaceId is required for multi-workspace authentication",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Find user by username
|
|
183
|
+
const [dbUser] = await adminDb
|
|
184
|
+
.select()
|
|
185
|
+
.from(users)
|
|
186
|
+
.where(eq(users.username, username));
|
|
187
|
+
|
|
188
|
+
if (!dbUser) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Verify password
|
|
193
|
+
const isValidPassword = await bcrypt.compare(password, dbUser.passwordHash);
|
|
194
|
+
if (!isValidPassword) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check if user has access to the workspace
|
|
199
|
+
const [access] = await adminDb
|
|
200
|
+
.select()
|
|
201
|
+
.from(userWorkspaces)
|
|
202
|
+
.where(
|
|
203
|
+
and(
|
|
204
|
+
eq(userWorkspaces.userId, dbUser.id),
|
|
205
|
+
eq(userWorkspaces.workspaceId, workspaceId),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (!access) {
|
|
29
210
|
return null;
|
|
30
211
|
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
id: dbUser.id,
|
|
215
|
+
email: dbUser.email,
|
|
216
|
+
username: dbUser.username,
|
|
217
|
+
workspaceId: workspaceId,
|
|
218
|
+
};
|
|
31
219
|
}
|
|
32
220
|
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Singleton instance of the auth controller.
|
|
224
|
+
*/
|
|
225
|
+
export const __PROJECT_NAME_CAMEL__AuthController =
|
|
226
|
+
new __PROJECT_NAME_PASCAL__AuthController();
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { serve } from "@hono/node-server";
|
|
2
|
+
import { createAuthMiddleware, registerHandlers } from "@nubase/backend";
|
|
2
3
|
import { Hono } from "hono";
|
|
3
4
|
import { cors } from "hono/cors";
|
|
4
5
|
import { ticketHandlers } from "./api/routes/ticket";
|
|
5
6
|
import { authHandlers } from "./api/routes/auth";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createPostAuthWorkspaceMiddleware,
|
|
9
|
+
createWorkspaceMiddleware,
|
|
10
|
+
} from "./middleware/workspace-middleware";
|
|
7
11
|
import { __PROJECT_NAME_PASCAL__AuthController } from "./auth";
|
|
8
12
|
import { loadEnv } from "./helpers/env";
|
|
9
13
|
|
|
@@ -12,38 +16,39 @@ loadEnv();
|
|
|
12
16
|
|
|
13
17
|
const app = new Hono();
|
|
14
18
|
|
|
19
|
+
// Auth controller
|
|
20
|
+
const authController = new __PROJECT_NAME_PASCAL__AuthController();
|
|
21
|
+
|
|
15
22
|
// CORS configuration
|
|
16
23
|
app.use(
|
|
17
24
|
"*",
|
|
18
25
|
cors({
|
|
19
|
-
origin:
|
|
26
|
+
origin: (origin) => {
|
|
27
|
+
// Allow localhost origins
|
|
28
|
+
if (origin?.match(/^http:\/\/localhost(:\d+)?$/)) {
|
|
29
|
+
return origin;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
},
|
|
20
33
|
credentials: true,
|
|
21
34
|
}),
|
|
22
35
|
);
|
|
23
36
|
|
|
24
|
-
// Workspace middleware (
|
|
25
|
-
app.use("
|
|
37
|
+
// Workspace middleware - handles login path (gets workspace from body)
|
|
38
|
+
app.use("*", createWorkspaceMiddleware());
|
|
26
39
|
|
|
27
|
-
// Auth
|
|
28
|
-
|
|
40
|
+
// Auth middleware - extracts and verifies JWT, sets user in context
|
|
41
|
+
app.use("*", createAuthMiddleware({ controller: authController }));
|
|
42
|
+
|
|
43
|
+
// Post-auth workspace middleware - sets context from authenticated user's workspace
|
|
44
|
+
app.use("*", createPostAuthWorkspaceMiddleware());
|
|
45
|
+
|
|
46
|
+
// Root route
|
|
47
|
+
app.get("/", (c) => c.json({ message: "Welcome to __PROJECT_NAME_PASCAL__ API" }));
|
|
29
48
|
|
|
30
|
-
//
|
|
31
|
-
app
|
|
32
|
-
|
|
33
|
-
// Auth routes
|
|
34
|
-
app.post("/:workspace/auth/login/start", authHandlers.loginStart);
|
|
35
|
-
app.post("/:workspace/auth/login/complete", authHandlers.loginComplete);
|
|
36
|
-
app.post("/:workspace/auth/login", authHandlers.login);
|
|
37
|
-
app.post("/:workspace/auth/logout", authHandlers.logout);
|
|
38
|
-
app.get("/:workspace/auth/me", authHandlers.getMe);
|
|
39
|
-
app.post("/:workspace/auth/signup", authHandlers.signup);
|
|
40
|
-
|
|
41
|
-
// Ticket routes
|
|
42
|
-
app.get("/:workspace/tickets", ticketHandlers.getTickets);
|
|
43
|
-
app.get("/:workspace/tickets/:id", ticketHandlers.getTicket);
|
|
44
|
-
app.post("/:workspace/tickets", ticketHandlers.postTicket);
|
|
45
|
-
app.patch("/:workspace/tickets/:id", ticketHandlers.patchTicket);
|
|
46
|
-
app.delete("/:workspace/tickets/:id", ticketHandlers.deleteTicket);
|
|
49
|
+
// Register all handlers - path and method extracted from endpoint metadata
|
|
50
|
+
registerHandlers(app, authHandlers);
|
|
51
|
+
registerHandlers(app, ticketHandlers);
|
|
47
52
|
|
|
48
53
|
const port = Number(process.env.PORT) || __BACKEND_PORT__;
|
|
49
54
|
|
|
@@ -1,11 +1,97 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { createMiddleware } from "hono/factory";
|
|
3
|
+
import { db } from "../db/helpers/drizzle";
|
|
4
|
+
import { workspaces } from "../db/schema";
|
|
2
5
|
|
|
3
|
-
export
|
|
4
|
-
|
|
6
|
+
export interface Workspace {
|
|
7
|
+
id: number;
|
|
8
|
+
slug: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Paths that don't require a workspace to exist (for bootstrapping and health checks)
|
|
13
|
+
const WORKSPACE_BYPASS_PATHS = ["/"];
|
|
14
|
+
|
|
15
|
+
// Paths where workspace comes from request body (login) instead of JWT
|
|
16
|
+
const WORKSPACE_FROM_BODY_PATHS = ["/auth/login"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Workspace middleware for path-based multi-workspace.
|
|
20
|
+
*
|
|
21
|
+
* For most authenticated requests, the workspace is identified from the JWT token.
|
|
22
|
+
* For login requests, the workspace slug is provided in the request body.
|
|
23
|
+
*/
|
|
24
|
+
export function createWorkspaceMiddleware() {
|
|
25
|
+
return createMiddleware<{ Variables: { workspace: Workspace } }>(
|
|
26
|
+
async (c, next) => {
|
|
27
|
+
const path = c.req.path;
|
|
28
|
+
|
|
29
|
+
// Allow certain paths to bypass workspace check (for bootstrapping)
|
|
30
|
+
if (WORKSPACE_BYPASS_PATHS.includes(path)) {
|
|
31
|
+
return next();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// For login, workspace will be handled by the login handler itself
|
|
35
|
+
if (WORKSPACE_FROM_BODY_PATHS.includes(path)) {
|
|
36
|
+
return next();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// For other paths, workspace will be set from JWT by auth middleware
|
|
40
|
+
return next();
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Post-auth middleware that sets context based on authenticated user's workspace.
|
|
47
|
+
* This should run after the auth middleware.
|
|
48
|
+
*/
|
|
49
|
+
export function createPostAuthWorkspaceMiddleware() {
|
|
50
|
+
return createMiddleware<{
|
|
51
|
+
Variables: { workspace: Workspace; user: { workspaceId: number } | null };
|
|
52
|
+
}>(async (c, next) => {
|
|
53
|
+
const path = c.req.path;
|
|
54
|
+
|
|
55
|
+
// Skip for bypass paths and login (already handled)
|
|
56
|
+
if (
|
|
57
|
+
WORKSPACE_BYPASS_PATHS.includes(path) ||
|
|
58
|
+
WORKSPACE_FROM_BODY_PATHS.includes(path)
|
|
59
|
+
) {
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// If workspace is already set, skip
|
|
64
|
+
const existingWorkspace = c.get("workspace");
|
|
65
|
+
if (existingWorkspace) {
|
|
66
|
+
return next();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Get user from auth middleware
|
|
70
|
+
const user = c.get("user");
|
|
71
|
+
|
|
72
|
+
if (!user || !user.workspaceId) {
|
|
73
|
+
// No authenticated user - proceed without workspace context
|
|
74
|
+
return next();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Look up workspace from user's workspaceId
|
|
78
|
+
const workspaceRows = await db
|
|
79
|
+
.select()
|
|
80
|
+
.from(workspaces)
|
|
81
|
+
.where(eq(workspaces.id, user.workspaceId));
|
|
82
|
+
|
|
83
|
+
if (workspaceRows.length === 0) {
|
|
84
|
+
return c.json({ error: "User's workspace not found" }, 500);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const workspace = {
|
|
88
|
+
id: workspaceRows[0].id,
|
|
89
|
+
slug: workspaceRows[0].slug,
|
|
90
|
+
name: workspaceRows[0].name,
|
|
91
|
+
};
|
|
5
92
|
|
|
6
|
-
if (workspace) {
|
|
7
93
|
c.set("workspace", workspace);
|
|
8
|
-
}
|
|
9
94
|
|
|
10
|
-
|
|
95
|
+
return next();
|
|
96
|
+
});
|
|
11
97
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "frontend",
|
|
3
3
|
"private": true,
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"react": "^19.0.0",
|
|
20
20
|
"react-dom": "^19.0.0",
|
|
21
21
|
"tailwindcss": "^4.1.18",
|
|
22
|
-
"
|
|
22
|
+
"schema": "*"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/react": "^19.0.10",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { NubaseFrontendConfig } from "@nubase/frontend";
|
|
2
2
|
import { defaultKeybindings, resourceLink } from "@nubase/frontend";
|
|
3
3
|
import { Home, TicketIcon } from "lucide-react";
|
|
4
|
-
import { apiEndpoints } from "
|
|
4
|
+
import { apiEndpoints } from "schema";
|
|
5
5
|
import { __PROJECT_NAME_PASCAL__AuthController } from "./auth/__PROJECT_NAME_PASCAL__AuthController";
|
|
6
6
|
import { ticketResource } from "./resources/ticket";
|
|
7
7
|
|
package/templates/root/README.md
CHANGED
|
@@ -49,10 +49,10 @@ The application will be available at:
|
|
|
49
49
|
|
|
50
50
|
```
|
|
51
51
|
__PROJECT_NAME__/
|
|
52
|
-
├──
|
|
53
|
-
├──
|
|
54
|
-
├──
|
|
55
|
-
└── package.json
|
|
52
|
+
├── schema/ # Shared API schemas and types
|
|
53
|
+
├── backend/ # Node.js backend (Hono + PostgreSQL)
|
|
54
|
+
├── frontend/ # React frontend (Vite + Nubase)
|
|
55
|
+
└── package.json # Root workspace configuration
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
## Available Scripts
|
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
7
|
+
"schema",
|
|
8
|
+
"backend",
|
|
9
|
+
"frontend"
|
|
10
10
|
],
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "turbo run dev",
|
|
13
13
|
"build": "turbo run build",
|
|
14
|
-
"db:up": "cd
|
|
15
|
-
"db:down": "cd
|
|
16
|
-
"db:
|
|
17
|
-
"db:
|
|
14
|
+
"db:up": "cd backend && npm run db:dev:up",
|
|
15
|
+
"db:down": "cd backend && npm run db:dev:down",
|
|
16
|
+
"db:seed": "cd backend && npm run db:dev:seed",
|
|
17
|
+
"db:reset": "cd backend && npm run db:dev:reset",
|
|
18
18
|
"typecheck": "turbo run typecheck",
|
|
19
19
|
"lint": "turbo run lint",
|
|
20
20
|
"lint:fix": "turbo run lint:fix"
|