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 +1 -1
- package/templates/scaffold/actions-me.ts.mustache +43 -0
- package/templates/scaffold/actions-session.ts.mustache +86 -0
- package/templates/scaffold/actions-user.ts.mustache +58 -0
- package/templates/scaffold/middleware-session.ts.mustache +13 -0
- package/templates/scaffold/ops-user.ts.mustache +19 -0
- package/templates/scaffold/schema-users.ts.mustache +32 -0
- package/util/scaffold.ts +75 -4
package/package.json
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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;
|