keryx 0.12.1 → 0.12.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,43 @@
1
+ import { eq } from "drizzle-orm";
2
+ import {
3
+ api,
4
+ Connection,
5
+ ErrorType,
6
+ TypedError,
7
+ type Action,
8
+ type ActionParams,
9
+ } from "keryx";
10
+ import { HTTP_METHOD } from "keryx/classes/Action.ts";
11
+ import { z } from "zod";
12
+ import { SessionMiddleware } from "../middleware/session";
13
+ import { serializeUser } from "../ops/UserOps";
14
+ import { users } from "../schema/users";
15
+
16
+ export class MeView implements Action {
17
+ name = "me:view";
18
+ description =
19
+ "Get the currently authenticated user's profile. Requires an active session.";
20
+ web = { route: "/me", method: HTTP_METHOD.GET };
21
+ middleware = [SessionMiddleware];
22
+ inputs = z.object({});
23
+
24
+ async run(
25
+ _params: ActionParams<MeView>,
26
+ connection: Connection<{ userId?: number }>,
27
+ ) {
28
+ const [user] = await api.db.db
29
+ .select()
30
+ .from(users)
31
+ .where(eq(users.id, connection.session!.data.userId!))
32
+ .limit(1);
33
+
34
+ if (!user) {
35
+ throw new TypedError({
36
+ message: "User not found",
37
+ type: ErrorType.CONNECTION_ACTION_NOT_FOUND,
38
+ });
39
+ }
40
+
41
+ return { user: serializeUser(user) };
42
+ }
43
+ }
@@ -0,0 +1,86 @@
1
+ import { eq } from "drizzle-orm";
2
+ import {
3
+ api,
4
+ Connection,
5
+ ErrorType,
6
+ secret,
7
+ TypedError,
8
+ type Action,
9
+ type ActionParams,
10
+ } from "keryx";
11
+ import { HTTP_METHOD } from "keryx/classes/Action.ts";
12
+ import type { SessionData } from "keryx/initializers/session.ts";
13
+ import { RateLimitMiddleware } from "keryx/middleware/rateLimit.ts";
14
+ import { z } from "zod";
15
+ import { SessionMiddleware } from "../middleware/session";
16
+ import { checkPassword, serializeUser } from "../ops/UserOps";
17
+ import { users } from "../schema/users";
18
+
19
+ export type SessionImpl = { userId?: number };
20
+
21
+ export class SessionCreate implements Action {
22
+ name = "session:create";
23
+ description =
24
+ "Sign in with email and password. Creates an authenticated session and returns the user's profile. Call this before using any endpoints that require authentication.";
25
+ mcp = { enabled: false, isLoginAction: true };
26
+ middleware = [RateLimitMiddleware];
27
+ web = { route: "/session", method: HTTP_METHOD.PUT };
28
+ inputs = z.object({
29
+ email: z
30
+ .string()
31
+ .refine(
32
+ (val) => val.includes("@") && val.includes("."),
33
+ "Must be a valid email address",
34
+ )
35
+ .transform((val) => val.toLowerCase()),
36
+ password: secret(z.string().min(8, "Password must be at least 8 characters")),
37
+ });
38
+
39
+ // @ts-ignore - valid action and response type
40
+ run = async (
41
+ params: ActionParams<SessionCreate>,
42
+ connection: Connection<SessionImpl>,
43
+ ): Promise<{
44
+ user: Awaited<ReturnType<typeof serializeUser>>;
45
+ session: SessionData<SessionImpl>;
46
+ }> => {
47
+ const [user] = await api.db.db
48
+ .select()
49
+ .from(users)
50
+ .where(eq(users.email, params.email));
51
+
52
+ const passwordMatch = user
53
+ ? await checkPassword(user, params.password)
54
+ : false;
55
+
56
+ if (!user || !passwordMatch) {
57
+ throw new TypedError({
58
+ message: "Invalid email or password",
59
+ type: ErrorType.CONNECTION_ACTION_RUN,
60
+ });
61
+ }
62
+
63
+ await connection.updateSession({ userId: user.id });
64
+
65
+ return {
66
+ user: serializeUser(user),
67
+ session: connection.session!,
68
+ };
69
+ };
70
+ }
71
+
72
+ export class SessionDestroy implements Action {
73
+ name = "session:destroy";
74
+ description =
75
+ "Sign out by destroying the current authenticated session. Requires an active session.";
76
+ web = { route: "/session", method: HTTP_METHOD.DELETE };
77
+ middleware = [RateLimitMiddleware, SessionMiddleware];
78
+
79
+ async run(
80
+ _params: ActionParams<SessionDestroy>,
81
+ connection: Connection<SessionImpl>,
82
+ ) {
83
+ await api.session.destroy(connection);
84
+ return { success: true };
85
+ }
86
+ }
@@ -0,0 +1,58 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Action, api, ErrorType, secret, TypedError, type ActionParams } from "keryx";
3
+ import { HTTP_METHOD } from "keryx/classes/Action.ts";
4
+ import { RateLimitMiddleware } from "keryx/middleware/rateLimit.ts";
5
+ import { z } from "zod";
6
+ import { hashPassword, serializeUser } from "../ops/UserOps";
7
+ import { users } from "../schema/users";
8
+
9
+ export class UserCreate implements Action {
10
+ name = "user:create";
11
+ description =
12
+ "Register a new user account with a name, email, and password. The email must be unique (case-insensitive). Password must be at least 8 characters. Returns the created user's profile.";
13
+ mcp = { enabled: false, isSignupAction: true };
14
+ middleware = [RateLimitMiddleware];
15
+ web = { route: "/user", method: HTTP_METHOD.PUT };
16
+ inputs = z.object({
17
+ name: z
18
+ .string()
19
+ .min(3, "Name must be at least 3 characters")
20
+ .max(256, "Name must be less than 256 characters"),
21
+ email: z
22
+ .string()
23
+ .refine(
24
+ (val) => val.includes("@") && val.includes("."),
25
+ "Must be a valid email address",
26
+ )
27
+ .transform((val) => val.toLowerCase()),
28
+ password: secret(
29
+ z.string().min(8, "Password must be at least 8 characters"),
30
+ ),
31
+ });
32
+
33
+ async run(params: ActionParams<UserCreate>) {
34
+ const [existingUser] = await api.db.db
35
+ .select()
36
+ .from(users)
37
+ .where(eq(users.email, params.email))
38
+ .limit(1);
39
+
40
+ if (existingUser) {
41
+ throw new TypedError({
42
+ message: "A user with that email already exists",
43
+ type: ErrorType.ACTION_VALIDATION,
44
+ });
45
+ }
46
+
47
+ const [user] = await api.db.db
48
+ .insert(users)
49
+ .values({
50
+ name: params.name,
51
+ email: params.email,
52
+ password_hash: await hashPassword(params.password),
53
+ })
54
+ .returning();
55
+
56
+ return { user: serializeUser(user) };
57
+ }
58
+ }
@@ -0,0 +1,13 @@
1
+ import { Connection, ErrorType, TypedError } from "keryx";
2
+ import type { ActionMiddleware } from "keryx/classes/Action.ts";
3
+
4
+ export const SessionMiddleware: ActionMiddleware = {
5
+ runBefore: async (_params, connection: Connection<{ userId?: number }>) => {
6
+ if (!connection.session || !connection.session.data.userId) {
7
+ throw new TypedError({
8
+ message: "Session not found",
9
+ type: ErrorType.CONNECTION_SESSION_NOT_FOUND,
10
+ });
11
+ }
12
+ },
13
+ };
@@ -0,0 +1,19 @@
1
+ import { type User } from "../schema/users";
2
+
3
+ export async function hashPassword(password: string) {
4
+ return Bun.password.hash(password);
5
+ }
6
+
7
+ export async function checkPassword(user: User, password: string) {
8
+ return Bun.password.verify(password, user.password_hash);
9
+ }
10
+
11
+ export function serializeUser(user: User) {
12
+ return {
13
+ id: user.id,
14
+ name: user.name,
15
+ email: user.email,
16
+ createdAt: user.createdAt.getTime(),
17
+ updatedAt: user.updatedAt.getTime(),
18
+ };
19
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ pgTable,
3
+ serial,
4
+ text,
5
+ timestamp,
6
+ uniqueIndex,
7
+ varchar,
8
+ } from "drizzle-orm/pg-core";
9
+
10
+ export const users = pgTable(
11
+ "users",
12
+ {
13
+ id: serial("id").primaryKey(),
14
+ name: varchar("name", { length: 256 }).notNull(),
15
+ email: text("email").notNull().unique(),
16
+ password_hash: text("password_hash").notNull(),
17
+ createdAt: timestamp("created_at").notNull().defaultNow(),
18
+ updatedAt: timestamp("updated_at")
19
+ .notNull()
20
+ .defaultNow()
21
+ .$onUpdateFn(() => new Date()),
22
+ },
23
+ (users) => {
24
+ return {
25
+ nameIndex: uniqueIndex("name_idx").on(users.name),
26
+ emailIndex: uniqueIndex("email_idx").on(users.email),
27
+ };
28
+ },
29
+ );
30
+
31
+ export type User = typeof users.$inferSelect;
32
+ export type NewUser = typeof users.$inferInsert;
package/util/scaffold.ts CHANGED
@@ -197,6 +197,60 @@ export async function generateKeryxTsContents(): Promise<string> {
197
197
  return loadTemplate("keryx.ts.mustache");
198
198
  }
199
199
 
200
+ /**
201
+ * Generate auth scaffold file contents: schema, ops, middleware, and actions
202
+ * for a working sign-up / sign-in / sign-out / me flow.
203
+ * Returns a Map of relativePath → content.
204
+ */
205
+ export async function generateAuthScaffoldContents(): Promise<
206
+ Map<string, string>
207
+ > {
208
+ const result = new Map<string, string>();
209
+
210
+ const files: [string, string][] = [
211
+ ["schema/users.ts", "schema-users.ts.mustache"],
212
+ ["ops/UserOps.ts", "ops-user.ts.mustache"],
213
+ ["middleware/session.ts", "middleware-session.ts.mustache"],
214
+ ["actions/user.ts", "actions-user.ts.mustache"],
215
+ ["actions/session.ts", "actions-session.ts.mustache"],
216
+ ["actions/me.ts", "actions-me.ts.mustache"],
217
+ ];
218
+
219
+ for (const [filePath, templateName] of files) {
220
+ const content = await loadTemplate(templateName);
221
+ result.set(filePath, content);
222
+ }
223
+
224
+ // Pre-generated migration for the users table so the project works out of the box
225
+ result.set(
226
+ "drizzle/0000_users.sql",
227
+ `CREATE TABLE IF NOT EXISTS "users" (\n\t"id" serial PRIMARY KEY NOT NULL,\n\t"name" varchar(256) NOT NULL,\n\t"email" text NOT NULL,\n\t"password_hash" text NOT NULL,\n\t"created_at" timestamp DEFAULT now() NOT NULL,\n\t"updated_at" timestamp DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX IF NOT EXISTS "name_idx" ON "users" ("name");--> statement-breakpoint\nCREATE UNIQUE INDEX IF NOT EXISTS "email_idx" ON "users" ("email");\n`,
228
+ );
229
+
230
+ result.set(
231
+ "drizzle/meta/_journal.json",
232
+ JSON.stringify(
233
+ {
234
+ version: "5",
235
+ dialect: "pg",
236
+ entries: [
237
+ {
238
+ idx: 0,
239
+ version: "5",
240
+ when: 1711324460394,
241
+ tag: "0000_users",
242
+ breakpoints: true,
243
+ },
244
+ ],
245
+ },
246
+ null,
247
+ 2,
248
+ ) + "\n",
249
+ );
250
+
251
+ return result;
252
+ }
253
+
200
254
  export async function scaffoldProject(
201
255
  projectName: string,
202
256
  targetDir: string,
@@ -290,8 +344,16 @@ export async function scaffoldProject(
290
344
 
291
345
  if (options.includeDb) {
292
346
  await writeTemplate("migrations.ts", "migrations.ts.mustache");
293
- await write("schema/.gitkeep", "");
294
- await write("drizzle/meta/_journal.json", JSON.stringify({ entries: [] }));
347
+
348
+ // Auth example replaces the .gitkeep placeholders with real files below;
349
+ // only write placeholders when not including auth.
350
+ if (!options.includeExample) {
351
+ await write("schema/.gitkeep", "");
352
+ await write(
353
+ "drizzle/meta/_journal.json",
354
+ JSON.stringify({ entries: [] }),
355
+ );
356
+ }
295
357
  }
296
358
 
297
359
  // --- Built-in actions (always included) ---
@@ -306,10 +368,19 @@ export async function scaffoldProject(
306
368
  await write(filePath, content);
307
369
  }
308
370
 
309
- // --- Example action ---
371
+ // --- Example: auth actions (when db) or hello action (when no db) ---
310
372
 
311
373
  if (options.includeExample) {
312
- await writeTemplate("actions/hello.ts", "hello-action.ts.mustache");
374
+ if (options.includeDb) {
375
+ // Full auth starter: sign up, sign in, sign out, and a protected /me endpoint
376
+ const authFiles = await generateAuthScaffoldContents();
377
+ for (const [filePath, content] of authFiles) {
378
+ await write(filePath, content);
379
+ }
380
+ } else {
381
+ // No DB — fall back to the simple hello action
382
+ await writeTemplate("actions/hello.ts", "hello-action.ts.mustache");
383
+ }
313
384
  }
314
385
 
315
386
  return createdFiles;