@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubase/create",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Create a new Nubase application",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "__PROJECT_NAME__-backend",
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:seed": "tsx src/db/seed.ts",
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
- "__PROJECT_NAME__-schema": "*"
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 type { Context } from "hono";
4
- import { __PROJECT_NAME_PASCAL__AuthController } from "../../auth";
5
- import { adminDb, db } from "../../db/helpers/drizzle";
6
- import { userWorkspaces, users, workspaces } from "../../db/schema";
7
-
8
- const authController = new __PROJECT_NAME_PASCAL__AuthController();
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
- async loginStart(c: Context) {
12
- const { email, password } = await c.req.json();
13
-
14
- const [user] = await db.select().from(users).where(eq(users.email, email));
15
-
16
- if (!user) {
17
- return c.json({ error: "Invalid credentials" }, 401);
18
- }
19
-
20
- const isValid = await bcrypt.compare(password, user.passwordHash);
21
- if (!isValid) {
22
- return c.json({ error: "Invalid credentials" }, 401);
23
- }
24
-
25
- // Get user's workspaces
26
- const userWs = await db
27
- .select({
28
- id: workspaces.id,
29
- slug: workspaces.slug,
30
- name: workspaces.name,
31
- })
32
- .from(userWorkspaces)
33
- .innerJoin(workspaces, eq(userWorkspaces.workspaceId, workspaces.id))
34
- .where(eq(userWorkspaces.userId, user.id));
35
-
36
- return c.json({ workspaces: userWs });
37
- },
38
-
39
- async loginComplete(c: Context) {
40
- const { email, password, workspaceId } = await c.req.json();
41
-
42
- const [user] = await db.select().from(users).where(eq(users.email, email));
43
-
44
- if (!user) {
45
- return c.json({ error: "Invalid credentials" }, 401);
46
- }
47
-
48
- const isValid = await bcrypt.compare(password, user.passwordHash);
49
- if (!isValid) {
50
- return c.json({ error: "Invalid credentials" }, 401);
51
- }
52
-
53
- const [workspace] = await db
54
- .select()
55
- .from(workspaces)
56
- .where(eq(workspaces.id, workspaceId));
57
-
58
- if (!workspace) {
59
- return c.json({ error: "Workspace not found" }, 404);
60
- }
61
-
62
- const token = authController.generateToken({
63
- userId: user.id,
64
- workspaceId: workspace.id,
65
- username: user.username,
66
- });
67
-
68
- return c.json({
69
- token,
70
- user: { id: user.id, email: user.email, username: user.username },
71
- workspace: { id: workspace.id, slug: workspace.slug, name: workspace.name },
72
- });
73
- },
74
-
75
- async login(c: Context) {
76
- const { email, password } = await c.req.json();
77
-
78
- const [user] = await db.select().from(users).where(eq(users.email, email));
79
-
80
- if (!user) {
81
- return c.json({ error: "Invalid credentials" }, 401);
82
- }
83
-
84
- const isValid = await bcrypt.compare(password, user.passwordHash);
85
- if (!isValid) {
86
- return c.json({ error: "Invalid credentials" }, 401);
87
- }
88
-
89
- // Get first workspace for simple login
90
- const [userWs] = await db
91
- .select({
92
- id: workspaces.id,
93
- slug: workspaces.slug,
94
- name: workspaces.name,
95
- })
96
- .from(userWorkspaces)
97
- .innerJoin(workspaces, eq(userWorkspaces.workspaceId, workspaces.id))
98
- .where(eq(userWorkspaces.userId, user.id));
99
-
100
- if (!userWs) {
101
- return c.json({ error: "No workspace found" }, 404);
102
- }
103
-
104
- const token = authController.generateToken({
105
- userId: user.id,
106
- workspaceId: userWs.id,
107
- username: user.username,
108
- });
109
-
110
- return c.json({
111
- token,
112
- user: { id: user.id, email: user.email, username: user.username },
113
- workspace: userWs,
114
- });
115
- },
116
-
117
- async logout(c: Context) {
118
- // Client-side token removal
119
- return c.json({ success: true });
120
- },
121
-
122
- async getMe(c: Context) {
123
- const authHeader = c.req.header("Authorization");
124
- if (!authHeader?.startsWith("Bearer ")) {
125
- return c.json({ error: "Unauthorized" }, 401);
126
- }
127
-
128
- const token = authHeader.slice(7);
129
- const payload = authController.verifyToken(token);
130
-
131
- if (!payload) {
132
- return c.json({ error: "Invalid token" }, 401);
133
- }
134
-
135
- const [user] = await db
136
- .select()
137
- .from(users)
138
- .where(eq(users.id, payload.userId));
139
-
140
- if (!user) {
141
- return c.json({ error: "User not found" }, 404);
142
- }
143
-
144
- const [workspace] = await db
145
- .select()
146
- .from(workspaces)
147
- .where(eq(workspaces.id, payload.workspaceId));
148
-
149
- return c.json({
150
- user: { id: user.id, email: user.email, username: user.username },
151
- workspace: workspace
152
- ? { id: workspace.id, slug: workspace.slug, name: workspace.name }
153
- : null,
154
- });
155
- },
156
-
157
- async signup(c: Context) {
158
- const { email, username, password, workspaceName } = await c.req.json();
159
-
160
- // Check if user exists
161
- const [existingUser] = await db
162
- .select()
163
- .from(users)
164
- .where(eq(users.email, email));
165
-
166
- if (existingUser) {
167
- return c.json({ error: "Email already registered" }, 400);
168
- }
169
-
170
- // Create user
171
- const passwordHash = await bcrypt.hash(password, 10);
172
- const [user] = await adminDb
173
- .insert(users)
174
- .values({ email, username, passwordHash })
175
- .returning();
176
-
177
- // Create or get workspace
178
- const wsSlug = workspaceName?.toLowerCase().replace(/\s+/g, "-") || "default";
179
- let [workspace] = await db
180
- .select()
181
- .from(workspaces)
182
- .where(eq(workspaces.slug, wsSlug));
183
-
184
- if (!workspace) {
185
- [workspace] = await adminDb
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({ slug: wsSlug, name: workspaceName || "Default Workspace" })
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
- // Link user to workspace
192
- await adminDb.insert(userWorkspaces).values({
193
- userId: user.id,
194
- workspaceId: workspace.id,
195
- });
196
-
197
- const token = authController.generateToken({
198
- userId: user.id,
199
- workspaceId: workspace.id,
200
- username: user.username,
201
- });
202
-
203
- return c.json({
204
- token,
205
- user: { id: user.id, email: user.email, username: user.username },
206
- workspace: { id: workspace.id, slug: workspace.slug, name: workspace.name },
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 type { Context } from "hono";
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
- async getTickets(c: Context) {
8
- const allTickets = await db.select().from(tickets);
9
- return c.json(allTickets);
10
- },
11
-
12
- async getTicket(c: Context) {
13
- const id = Number(c.req.param("id"));
14
- const [ticket] = await db.select().from(tickets).where(eq(tickets.id, id));
15
-
16
- if (!ticket) {
17
- return c.json({ error: "Ticket not found" }, 404);
18
- }
19
-
20
- return c.json(ticket);
21
- },
22
-
23
- async postTicket(c: Context) {
24
- const body = await c.req.json();
25
- const [ticket] = await db
26
- .insert(tickets)
27
- .values({
28
- workspaceId: 1, // TODO: Get from context
29
- title: body.title,
30
- description: body.description,
31
- })
32
- .returning();
33
-
34
- return c.json(ticket, 201);
35
- },
36
-
37
- async patchTicket(c: Context) {
38
- const id = Number(c.req.param("id"));
39
- const body = await c.req.json();
40
-
41
- const [ticket] = await db
42
- .update(tickets)
43
- .set({
44
- ...body,
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
- .where(eq(tickets.id, id))
48
- .returning();
49
-
50
- if (!ticket) {
51
- return c.json({ error: "Ticket not found" }, 404);
52
- }
53
-
54
- return c.json(ticket);
55
- },
56
-
57
- async deleteTicket(c: Context) {
58
- const id = Number(c.req.param("id"));
59
- await db.delete(tickets).where(eq(tickets.id, id));
60
- return c.json({ success: true });
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
- export interface TokenPayload {
5
- userId: number;
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
- const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
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
- export class __PROJECT_NAME_PASCAL__AuthController {
13
- async hashPassword(password: string): Promise<string> {
14
- return bcrypt.hash(password, 10);
61
+ // Fall back to cookie
62
+ const cookieHeader = ctx.req.header("Cookie") || "";
63
+ return getCookie(cookieHeader, COOKIE_NAME);
15
64
  }
16
65
 
17
- async verifyPassword(password: string, hash: string): Promise<boolean> {
18
- return bcrypt.compare(password, hash);
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
- generateToken(payload: TokenPayload): string {
22
- return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" });
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
- verifyToken(token: string): TokenPayload | null {
26
- try {
27
- return jwt.verify(token, JWT_SECRET) as TokenPayload;
28
- } catch {
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 { workspaceMiddleware } from "./middleware/workspace-middleware";
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: ["http://localhost:__FRONTEND_PORT__"],
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 (extracts workspace from path)
25
- app.use("/:workspace/*", workspaceMiddleware);
37
+ // Workspace middleware - handles login path (gets workspace from body)
38
+ app.use("*", createWorkspaceMiddleware());
26
39
 
27
- // Auth controller
28
- const authController = new __PROJECT_NAME_PASCAL__AuthController();
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
- // Routes
31
- app.get("/:workspace", (c) => c.json({ message: "Welcome to __PROJECT_NAME_PASCAL__ API" }));
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 type { Context, Next } from "hono";
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 async function workspaceMiddleware(c: Context, next: Next) {
4
- const workspace = c.req.param("workspace");
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
- await next();
95
+ return next();
96
+ });
11
97
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "__PROJECT_NAME__-frontend",
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
- "__PROJECT_NAME__-schema": "*"
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 "__PROJECT_NAME__-schema";
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
 
@@ -1,5 +1,5 @@
1
1
  import { createResource } from "@nubase/frontend";
2
- import { apiEndpoints } from "__PROJECT_NAME__-schema";
2
+ import { apiEndpoints } from "schema";
3
3
 
4
4
  export const ticketResource = createResource("ticket")
5
5
  .withApiEndpoints(apiEndpoints)
@@ -49,10 +49,10 @@ The application will be available at:
49
49
 
50
50
  ```
51
51
  __PROJECT_NAME__/
52
- ├── __PROJECT_NAME__-schema/ # Shared API schemas and types
53
- ├── __PROJECT_NAME__-backend/ # Node.js backend (Hono + PostgreSQL)
54
- ├── __PROJECT_NAME__-frontend/ # React frontend (Vite + Nubase)
55
- └── package.json # Root workspace configuration
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
- "__PROJECT_NAME__-schema",
8
- "__PROJECT_NAME__-backend",
9
- "__PROJECT_NAME__-frontend"
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 __PROJECT_NAME__-backend && npm run db:dev:up",
15
- "db:down": "cd __PROJECT_NAME__-backend && npm run db:dev:down",
16
- "db:kill": "cd __PROJECT_NAME__-backend && npm run db:dev:kill",
17
- "db:seed": "cd __PROJECT_NAME__-backend && npm run db:seed",
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"
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "__PROJECT_NAME__-schema",
2
+ "name": "schema",
3
3
  "version": "0.1.0",
4
4
  "description": "Schema for __PROJECT_NAME_PASCAL__",
5
5
  "type": "module",