Compare commits

...

24 Commits

Author SHA1 Message Date
dswbx 5c3d3d763e move sort plugin to data plugins 2025-11-26 10:26:58 +01:00
dswbx 407d103a5f fix: remove test.only 2025-11-26 10:12:39 +01:00
dswbx 2fd8b9f9a0 feat: add sort plugin
- Introduced a new sort plugin that allows sorting of entities based on a specified field.
- Added API endpoints for reordering items and recalculating sort order.
- Implemented tests to ensure correct functionality of sorting and reordering features.
2025-11-26 10:08:30 +01:00
dswbx 8f4de33a76 use nullish coalescing and fix mode plugin pick up 2025-11-26 10:04:24 +01:00
dswbx 43dbc856ce fix SystemController config read permission 2025-11-26 07:41:19 +01:00
dswbx 5a8f2b4894 upgrade bun and set linker isolated 2025-11-25 16:28:11 +01:00
dswbx 2627213de7 Merge pull request #292 from bknd-io/feat/add-otp-plugin
OTP plugin
2025-11-25 16:24:20 +01:00
dswbx 3fac740771 Merge branch 'release/0.20' into feat/add-otp-plugin 2025-11-21 20:03:30 +01:00
dswbx 2b5e1771de refactor: enhance email OTP responses and improve data structure
Updated the email OTP plugin to return a structured response containing the email, action, and expiration details. Adjusted the tests to validate the new response format. Increased password length for user creation
2025-11-14 21:59:06 +01:00
dswbx a16e017e39 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-14 09:01:36 +01:00
dswbx ba3d11edab Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-10 13:25:01 +01:00
dswbx b6717f0237 otp: update docs on permissions, only require email driver if sendEmail is not false 2025-11-10 10:25:33 +01:00
dswbx c57f3e8070 otp: added missing tests 2025-11-10 10:17:16 +01:00
dswbx c2f4f92d1a refactor otp listeners 2025-11-10 09:40:47 +01:00
dswbx 6eb8525656 otp: add sendEmail option to disable sending for debugging 2025-11-10 09:30:24 +01:00
dswbx 793c214e6d Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:58:48 +01:00
dswbx 4094004b83 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:50:33 +01:00
dswbx 7e399830e5 add validation for used OTP codes 2025-11-07 09:12:58 +01:00
dswbx ee2ab982df docs: added emailOTP plugin docs 2025-11-07 09:11:13 +01:00
dswbx 5be55a6fa6 clean up error, it's prefixed with the plugin name already 2025-11-07 08:51:44 +01:00
dswbx 341eb13425 rename to email otp to make it more explicit 2025-11-07 08:48:02 +01:00
dswbx 68fbb6e933 fix events reference for instance checks 2025-11-06 20:47:19 +01:00
dswbx 8b36985252 otp: add entity config, reduce result 2025-11-06 20:38:16 +01:00
dswbx ff86240b0e feat: add OTP plugin 2025-11-06 20:06:51 +01:00
18 changed files with 2464 additions and 11 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.3.2"
bun-version: "1.3.3"
- name: Install dependencies
working-directory: ./app
+1 -1
View File
@@ -13,7 +13,7 @@
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.3.3",
"engines": {
"node": ">=22.13"
},
+2 -1
View File
@@ -1,3 +1,4 @@
import type { MaybePromise } from "bknd";
import type { Event } from "./Event";
import type { EventClass } from "./EventManager";
@@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event<any, any>> = (
event: E,
slug: string,
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
) => E extends Event<any, infer R> ? MaybePromise<R | void> : never;
export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async";
+16
View File
@@ -77,3 +77,19 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
return true;
}
}
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
try {
await fn;
return false;
} catch (e) {
if (instance) {
if (e instanceof instance) {
return true;
}
// if instance given but not what expected, throw
throw e;
}
return true;
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
switch (context) {
case "submit":
return Number.parseInt(value);
return Number.parseInt(value, 10);
}
return value;
+1 -1
View File
@@ -28,7 +28,7 @@ export function getChangeSet(
const value = _value === "" ? null : _value;
// normalize to null if undefined
const newValue = field.getValue(value, "submit") || null;
const newValue = field.getValue(value, "submit") ?? null;
// @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) {
acc[key] = newValue;
+1 -1
View File
@@ -87,7 +87,7 @@ export async function makeModeConfig<
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
const isProd = config.isProduction ?? _isProd();
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
const syncSchemaOptions =
typeof config.syncSchema === "object"
+6 -1
View File
@@ -125,7 +125,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
const hono = this.create();
if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager;
@@ -317,6 +317,11 @@ export class SystemController extends Controller {
summary: "Get the config for a module",
tags: ["system"],
}),
permission(SystemPermissions.configRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
mcpTool("system_config", {
annotations: {
readOnlyHint: true,
@@ -0,0 +1,683 @@
import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test";
import { emailOTP } from "./email-otp.plugin";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("otp plugin", () => {
test("should not work if auth is not enabled", async () => {
const app = createApp({
options: {
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
});
test("should require email driver if sendEmail is true", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP()],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
{
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
}
});
test("should prevent mutations of the OTP entity", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
drivers: {
email: {
send: async () => {},
},
},
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const payload = {
email: "test@test.com",
code: "123456",
action: "login",
created_at: new Date(),
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24),
used_at: null,
};
expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow();
expect(
await app
.getApi()
.data.createOne("users_otp", payload)
.then((r) => r.ok),
).toBe(false);
});
test("should generate a token", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ showActualErrors: true })],
drivers: {
email: {
send: async (to) => {
expect(to).toBe("test@test.com");
called();
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("login");
expect(data.data.expires_at).toBeDefined();
{
const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" });
expect(data?.code).toBeDefined();
expect(data?.code?.length).toBe(6);
expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe(
true,
);
expect(data?.email).toBe("test@test.com");
}
expect(called).toHaveBeenCalled();
});
test("should login with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should register with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("register");
expect(data.data.expires_at).toBeDefined();
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should not send email if sendEmail is false", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
drivers: {
email: {
send: async () => {
called();
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
expect(called).not.toHaveBeenCalled();
});
test("should reject invalid codes", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// First send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Try to use an invalid code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: "999999" }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
});
test("should reject code reuse", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (_to, _subject, body) => {
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Use the code successfully
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
}
// Try to use the same code again
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should reject expired codes", async () => {
// Set a fixed system time
const baseTime = Date.now();
setSystemTime(new Date(baseTime));
try {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
ttl: 1, // 1 second TTL
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
const sendRes = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(sendRes.status).toBe(201);
// Get the code from the database
const { data: otpData } = await app.em
.fork()
.repo("users_otp")
.findOne({ email: "test@test.com" });
expect(otpData?.code).toBeDefined();
// Advance system time by more than 1 second to expire the code
setSystemTime(new Date(baseTime + 1100));
// Try to use the expired code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: otpData?.code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
} finally {
// Reset system time
setSystemTime();
}
});
test("should reject codes with different actions", async () => {
let loginCode = "";
let registerCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a login code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the login code
const { data: loginOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } });
loginCode = loginOtp?.code || "";
// Send a register code
await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the register code
const { data: registerOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } });
registerCode = registerOtp?.code || "";
// Try to use login code for register
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: loginCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// Try to use register code for login
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: registerCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should invalidate previous codes when sending new code", async () => {
let firstCode = "";
let secondCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const em = app.em.fork();
// Send first code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the first code
const { data: firstOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
firstCode = firstOtp?.code || "";
expect(firstCode).toBeDefined();
// Send second code (should invalidate the first)
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the second code
const { data: secondOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
secondCode = secondOtp?.code || "";
expect(secondCode).toBeDefined();
expect(secondCode).not.toBe(firstCode);
// Try to use the first code (should fail as it's been invalidated)
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: firstCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// The second code should work
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: secondCode }),
});
expect(res.status).toBe(200);
}
});
});
+387
View File
@@ -0,0 +1,387 @@
import {
datetime,
em,
entity,
enumm,
Exception,
text,
type App,
type AppPlugin,
type DB,
type FieldSchema,
type MaybePromise,
type EntityConfig,
DatabaseEvents,
} from "bknd";
import {
invariant,
s,
jsc,
HttpStatus,
threwAsync,
randomString,
$console,
pickKeys,
} from "bknd/utils";
import { Hono } from "hono";
export type EmailOTPPluginOptions = {
/**
* Customize code generation. If not provided, a random 6-digit code will be generated.
*/
generateCode?: (user: Pick<DB["users"], "email">) => string;
/**
* The base path for the API endpoints.
* @default "/api/auth/otp"
*/
apiBasePath?: string;
/**
* The TTL for the OTP tokens in seconds.
* @default 600 (10 minutes)
*/
ttl?: number;
/**
* The name of the OTP entity.
* @default "users_otp"
*/
entity?: string;
/**
* The config for the OTP entity.
*/
entityConfig?: EntityConfig;
/**
* Customize email content. If not provided, a default email will be sent.
*/
generateEmail?: (
otp: EmailOTPFieldSchema,
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
/**
* Enable debug mode for error messages.
* @default false
*/
showActualErrors?: boolean;
/**
* Allow direct mutations (create/update) of OTP codes outside of this plugin,
* e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows.
* @default false
*/
allowExternalMutations?: boolean;
/**
* Whether to send the email with the OTP code.
* @default true
*/
sendEmail?: boolean;
};
const otpFields = {
action: enumm({
enum: ["login", "register"],
}),
code: text().required(),
email: text().required(),
created_at: datetime(),
expires_at: datetime().required(),
used_at: datetime(),
};
export type EmailOTPFieldSchema = FieldSchema<typeof otpFields>;
class OTPError extends Exception {
override name = "OTPError";
override code = HttpStatus.BAD_REQUEST;
}
export function emailOTP({
generateCode: _generateCode,
apiBasePath = "/api/auth/otp",
ttl = 600,
entity: entityName = "users_otp",
entityConfig,
generateEmail: _generateEmail,
showActualErrors = false,
allowExternalMutations = false,
sendEmail = true,
}: EmailOTPPluginOptions = {}): AppPlugin {
return (app: App) => {
return {
name: "email-otp",
schema: () =>
em(
{
[entityName]: entity(
entityName,
otpFields,
{
name: "Users OTP",
sort_dir: "desc",
primary_format: app.module.data.config.default_primary_format,
...entityConfig,
},
"generated",
),
},
({ index }, schema) => {
const otp = schema[entityName]!;
index(otp).on(["email", "expires_at", "code"]);
},
),
onBuilt: async () => {
const auth = app.module.auth;
invariant(auth && auth.enabled === true, "Auth is not enabled");
invariant(!sendEmail || app.drivers?.email, "Email driver is not registered");
const generateCode =
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
const generateEmail =
_generateEmail ??
((otp: EmailOTPFieldSchema) => ({
subject: "OTP Code",
body: `Your OTP code is: ${otp.code}`,
}));
const em = app.em.fork();
const hono = new Hono()
.post(
"/login",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
const user = await findUser(app, email);
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"login",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
user,
"login",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.post(
"/register",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
// throw if user exists
if (!(await threwAsync(findUser(app, email)))) {
throw new Exception("User already exists", HttpStatus.BAD_REQUEST);
}
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"register",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const user = await app.createUser({
email,
password: randomString(32, true),
});
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
{ email },
"register",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.onError((err) => {
if (showActualErrors || err instanceof OTPError) {
throw err;
}
throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST);
});
app.server.route(apiBasePath, hono);
if (allowExternalMutations !== true) {
registerListeners(app, entityName);
}
},
};
};
}
async function findUser(app: App, email: string) {
const user_entity = app.module.auth.config.entity_name as "users";
const { data: user } = await app.em.repo(user_entity).findOne({ email });
if (!user) {
throw new Exception("User not found", HttpStatus.BAD_REQUEST);
}
return user;
}
async function invalidateAndGenerateCode(
app: App,
opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
action: EmailOTPFieldSchema["action"],
) {
const { generateCode, ttl, entity: entityName } = opts;
const newCode = generateCode?.(user);
if (!newCode) {
throw new OTPError("Failed to generate code");
}
await invalidateAllUserCodes(app, entityName, user.email, ttl);
const { data: otpData } = await app.em
.fork()
.mutator(entityName)
.insertOne({
code: newCode,
email: user.email,
action,
created_at: new Date(),
expires_at: new Date(Date.now() + ttl * 1000),
});
$console.log("[OTP Code]", newCode);
return otpData;
}
async function sendCode(
app: App,
otpData: EmailOTPFieldSchema,
opts: Required<Pick<EmailOTPPluginOptions, "generateEmail">>,
) {
const { generateEmail } = opts;
const { subject, body } = await generateEmail(otpData);
await app.drivers?.email?.send(otpData.email, subject, body);
}
async function getValidatedCode(
app: App,
entityName: string,
email: string,
code: string,
action: EmailOTPFieldSchema["action"],
) {
invariant(email, "[OTP Plugin]: Email is required");
invariant(code, "[OTP Plugin]: Code is required");
const em = app.em.fork();
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
if (!otpData) {
throw new OTPError("Invalid code");
}
if (otpData.expires_at < new Date()) {
throw new OTPError("Code expired");
}
if (otpData.used_at) {
throw new OTPError("Code already used");
}
return otpData;
}
async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) {
invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0");
invariant(email, "[OTP Plugin]: Email is required");
const em = app.em.fork();
await em
.mutator(entityName)
.updateWhere(
{ expires_at: new Date(Date.now() - 1000) },
{ email, used_at: { $isnull: true } },
);
}
function registerListeners(app: App, entityName: string) {
[DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => {
app.emgr.onEvent(
event,
(e: { params: { entity: { name: string } } }) => {
if (e.params.entity.name === entityName) {
throw new OTPError("Mutations of the OTP entity are not allowed");
}
},
{
mode: "sync",
id: "bknd-email-otp",
},
);
});
}
+858
View File
@@ -0,0 +1,858 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { sort } from "./sort.plugin";
import { em, entity, text, number } from "bknd";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("sort plugin", () => {
test("should add sort field to configured entities", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const taskEntity = app.em.entity("tasks");
expect(taskEntity).toBeDefined();
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
expect(taskEntity?.field("position")?.type).toBe("number");
});
test("should auto-assign sort values on insert (starting from 0)", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// insert first item
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
expect(task1.position).toBe(0);
// insert second item
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
expect(task2.position).toBe(1);
// insert third item
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
expect(task3.position).toBe(2);
});
test("should preserve manually set sort values on insert", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// insert with explicit position
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
expect(task.position).toBe(10);
});
test("should reorder items via API endpoint", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
// move task3 (position 2) to position 0
const res = await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: task3.id, position: 0 }),
});
expect(res.status).toBe(200);
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
expect(updatedTask3.position).toBe(0); // moved to position 0
expect(updatedTask1.position).toBe(1); // shifted down
expect(updatedTask2.position).toBe(2); // shifted down
});
test("should automatically reorder when updating sort field directly", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task4 (position 3) to position 1 by updating directly
await mutator.updateOne(task4.id, { position: 1 });
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(0); // unchanged
expect(updatedTask2.position).toBe(2); // shifted down
expect(updatedTask3.position).toBe(3); // shifted down
expect(updatedTask4.position).toBe(1); // moved to position 1
});
test("should automatically reorder when moving items up", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task1 (position 0) to position 2 by updating directly
await mutator.updateOne(task1.id, { position: 2 });
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(2); // moved to position 2
expect(updatedTask2.position).toBe(0); // shifted up
expect(updatedTask3.position).toBe(1); // shifted up
expect(updatedTask4.position).toBe(3); // unchanged
});
test("should support scoped sorting", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1
const { data: p1t1 } = await mutator.insertOne({
title: "P1 Task 1",
project_id: 1,
});
const { data: p1t2 } = await mutator.insertOne({
title: "P1 Task 2",
project_id: 1,
});
// create tasks in project 2
const { data: p2t1 } = await mutator.insertOne({
title: "P2 Task 1",
project_id: 2,
});
const { data: p2t2 } = await mutator.insertOne({
title: "P2 Task 2",
project_id: 2,
});
// positions should be scoped per project
expect(p1t1.position).toBe(0);
expect(p1t2.position).toBe(1);
expect(p2t1.position).toBe(0); // resets for new scope
expect(p2t2.position).toBe(1);
});
test("should reorder only within scope", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1
const { data: p1t1 } = await mutator.insertOne({
title: "P1 Task 1",
project_id: 1,
});
const { data: p1t2 } = await mutator.insertOne({
title: "P1 Task 2",
project_id: 1,
});
const { data: p1t3 } = await mutator.insertOne({
title: "P1 Task 3",
project_id: 1,
});
// create tasks in project 2
const { data: p2t1 } = await mutator.insertOne({
title: "P2 Task 1",
project_id: 2,
});
const { data: p2t2 } = await mutator.insertOne({
title: "P2 Task 2",
project_id: 2,
});
// move p1t3 to position 0 (should only affect project 1)
await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: p1t3.id, position: 0 }),
});
// verify project 1 tasks
const repo = app.em.repo("tasks");
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
expect(updatedP1t3.position).toBe(0); // moved to position 0
expect(updatedP1t1.position).toBe(1); // shifted
expect(updatedP1t2.position).toBe(2); // shifted
// verify project 2 tasks are unchanged
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
expect(updatedP2t1.position).toBe(0); // unchanged
expect(updatedP2t2.position).toBe(1); // unchanged
});
test("should recalculate all positions", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks with irregular positions
await mutator.insertOne({ title: "Task 1", position: 5 });
await mutator.insertOne({ title: "Task 2", position: 10 });
await mutator.insertOne({ title: "Task 3", position: 15 });
await mutator.insertOne({ title: "Task 4", position: 100 });
// recalculate
const res = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
});
expect(res.status).toBe(200);
// verify positions are now 0, 1, 2, 3
const { data: tasks } = await app.em.repo("tasks").findMany({
orderBy: [{ position: "asc" }],
});
expect(tasks.length).toBe(4);
expect(tasks[0].position).toBe(0);
expect(tasks[1].position).toBe(1);
expect(tasks[2].position).toBe(2);
expect(tasks[3].position).toBe(3);
});
test("should recalculate positions within scope only", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1 with irregular positions
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
// create tasks in project 2 with irregular positions
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
// recalculate only project 1
const res = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ scope: 1 }),
});
expect(res.status).toBe(200);
// verify project 1 tasks are recalculated
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
where: { project_id: 1 },
orderBy: [{ position: "asc" }],
});
expect(p1Tasks.length).toBe(2);
expect(p1Tasks[0].position).toBe(0);
expect(p1Tasks[1].position).toBe(1);
// verify project 2 tasks are unchanged
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
where: { project_id: 2 },
orderBy: [{ position: "asc" }],
});
expect(p2Tasks.length).toBe(2);
expect(p2Tasks[0].position).toBe(10); // unchanged
expect(p2Tasks[1].position).toBe(20); // unchanged
});
test("should handle moving items to the end", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task1 to the end (position 3)
await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: task1.id, position: 3 }),
});
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(3); // moved to end
expect(updatedTask2.position).toBe(0); // shifted up
expect(updatedTask3.position).toBe(1); // shifted up
expect(updatedTask4.position).toBe(2); // shifted up
});
test("should return 400 for invalid item id", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const res = await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: 999999, position: 0 }),
});
expect(res.status).toBe(400);
});
test("should handle multiple entities with different configurations", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
categories: entity("categories", {
name: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
categories: {
field: "order",
},
},
}),
],
},
});
await app.build();
// verify both entities have their sort fields
const taskEntity = app.em.entity("tasks");
const categoryEntity = app.em.entity("categories");
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
// create items in both entities
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
expect(task.position).toBe(0);
expect(category.order).toBe(0);
// verify both endpoints exist
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const catRes = await app.server.request("/api/sort/categories/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(taskRes.status).toBe(200);
expect(catRes.status).toBe(200);
});
test("should not trigger reorder when updating other fields", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
// update title only (should not trigger reordering)
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
// verify positions are unchanged
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
expect(updatedTask1.position).toBe(0);
expect(updatedTask2.position).toBe(1);
expect(updatedTask2.title).toBe("Task 2 Updated");
expect(updatedTask3.position).toBe(2);
});
test("should handle null sort values", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create task with null position (bypass listener by using kysely directly)
await app.connection.kysely
.insertInto("tasks")
.values({ title: "Task with null", position: null })
.execute();
// create normal task
await mutator.insertOne({ title: "Task 2" });
// update the null task to have a position
const nullTask = await app.connection.kysely
.selectFrom("tasks")
.selectAll()
.where("title", "=", "Task with null")
.executeTakeFirst();
await mutator.updateOne(nullTask!.id, { position: 0 });
// verify both tasks have proper positions
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks.length).toBe(2);
expect(tasks[0].position).toBe(0);
expect(tasks[1].position).toBe(1);
});
test("should not create duplicates when moving to an occupied position", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create two tasks at positions 0 and 1
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
expect(task1.position).toBe(0);
expect(task2.position).toBe(1);
// move task2 (at position 1) to position 0
await mutator.updateOne(task2.id, { position: 0 });
// verify no duplicates
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks.length).toBe(2);
expect(tasks[0].id).toBe(task2.id);
expect(tasks[0].position).toBe(0);
expect(tasks[1].id).toBe(task1.id);
expect(tasks[1].position).toBe(1);
// verify no tasks have the same position
const positions = tasks.map((t) => t.position);
const uniquePositions = new Set(positions);
expect(uniquePositions.size).toBe(positions.length);
});
test("should preserve order when recalculating", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks with specific positions
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
// recalculate
await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
// verify order is preserved (B, A, C based on original positions)
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
expect(tasks[0].position).toBe(0);
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
expect(tasks[1].position).toBe(1);
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
expect(tasks[2].position).toBe(2);
});
});
+423
View File
@@ -0,0 +1,423 @@
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
import { Hono } from "hono";
import { sql } from "kysely";
const DEFAULT_BATCH_SIZE = 1000;
export type SortPluginOptions = {
/**
* The base path for the API endpoints.
* @default "/api/sort"
*/
apiBasePath?: string;
/**
* Configuration for entities that should have sorting enabled.
* Key is the entity name, value is the configuration.
*/
entities: Record<
string,
{
/**
* The name of the sort property (must be a number field).
*/
field: string;
/**
* Optional scope field name. If provided, sorting will only happen within the same scope.
* For example, if scope is "category_id", items will only be sorted within items
* that have the same category_id value.
*/
scope?: string;
}
>;
/**
* The batch size for recalculating sort order.
* @default 1000
*/
recalculateBatchSize?: number;
};
class SortError extends Exception {
override name = "SortError";
override code = HttpStatus.BAD_REQUEST;
}
export function sort({
apiBasePath = "/api/sort",
entities,
recalculateBatchSize = DEFAULT_BATCH_SIZE,
}: SortPluginOptions): AppPlugin {
return (app: App) => {
return {
name: "sort",
schema: () => {
return em(
Object.fromEntries(
Object.entries(entities).map(([entityName, config]) => [
entityName,
entity(entityName, {
[config.field]: number({ default_value: 0 }),
}),
]),
),
({ index }, schema) => {
for (const [entityName, config] of Object.entries(entities)) {
const indexed = app.em.getIndexedFields(entityName);
if (!indexed.some((f) => f.name === config.field)) {
index(schema[entityName]!).on([config.field]);
}
}
},
);
},
onBuilt: async () => {
invariant(
entities && Object.keys(entities).length > 0,
"At least one entity must be configured",
);
// validate entities exist and have the configured fields
for (const [entityName, config] of Object.entries(entities)) {
const entity = app.em.entity(entityName);
invariant(entity, `Entity "${entityName}" not found in schema`);
const sortFieldSchema = entity.field(config.field)!;
invariant(
sortFieldSchema,
`Sort field "${config.field}" not found in entity "${entityName}"`,
);
invariant(
sortFieldSchema.type === "number",
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
);
if (config.scope) {
const scopeFieldSchema = entity.field(config.scope);
invariant(
scopeFieldSchema,
`Scope field "${config.scope}" not found in entity "${entityName}"`,
);
}
}
const hono = new Hono();
// register recalculate endpoints for each entity
for (const [entityName, config] of Object.entries(entities)) {
hono.post(
`/${entityName}/recalculate`,
jsc(
"json",
s
.object({
scope: s.any().optional(),
})
.optional(),
),
async (c) => {
const body = c.req.valid("json");
const scope = body?.scope;
await recalculateSortOrder(
app,
entityName,
config,
recalculateBatchSize,
scope,
);
return c.json({ success: true, message: "Sort order recalculated" });
},
);
hono.post(
`/${entityName}/reorder`,
jsc(
"json",
s.object({
id: s.any(),
position: s.number(),
}),
),
async (c) => {
const { id, position } = c.req.valid("json");
await reorderItem(app, entityName, config, id, position);
return c.json({ success: true, message: "Item reordered" });
},
);
}
app.server.route(apiBasePath, hono);
// register listeners for automatic reordering
registerListeners(app, entities);
},
};
};
}
async function reorderItem(
app: App,
entityName: string,
config: SortPluginOptions["entities"][string],
id: any,
newPosition: number,
) {
const { field: sortField, scope: scopeField } = config;
const em = app.em.fork();
const kysely = em.connection.kysely;
// get the item
const { data: item } = await em.repo(entityName).findOne({ id });
if (!item) {
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
}
const oldPosition = item[sortField] as number | null | undefined;
const scopeValue = scopeField ? item[scopeField] : undefined;
// update the item with new position (using forked em, so no listeners are triggered)
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
// shift other items using kysely
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
if (newPosition < oldPosition) {
// moving up: increment items between newPosition and oldPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where(sortField as any, "<", oldPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
} else {
// moving down: decrement items between oldPosition and newPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
.where(sortField as any, ">", oldPosition)
.where(sortField as any, "<=", newPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
} else if (oldPosition === undefined || oldPosition === null) {
// new item, shift everything at or after this position
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
}
async function recalculateSortOrder(
app: App,
entityName: string,
config: SortPluginOptions["entities"][string],
batchSize: number,
scope?: any,
) {
const { field: sortField, scope: scopeField } = config;
const db = app.connection.kysely;
const { count } = (await db
.selectFrom(entityName)
.select((eb) => eb.fn.count<number>("id").as("count"))
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
eb.where(scopeField as any, "=", scope),
)
.$castTo<{ count: number }>()
.executeTakeFirst()) ?? { count: 0 };
const batches = Math.ceil(count / batchSize);
for (let i = 0; i < batches; i++) {
// get all items in scope, ordered by current sort value
const items = await db
.selectFrom(entityName)
.select(["id", sortField])
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
eb.where(scopeField as any, "=", scope),
)
.orderBy(sortField, "asc")
.limit(batchSize)
.offset(i * batchSize)
.execute();
const newQbs = items.map((item, index) =>
db
.updateTable(entityName)
.set({ [sortField]: index })
.where("id", "=", item.id),
);
await app.connection.executeQueries(...newQbs);
$console.log(
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
);
}
}
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
const kysely = app.connection.kysely;
// handle insert events
app.emgr.onEvent(
DatabaseEvents.MutatorInsertBefore,
async (e) => {
const entityName = e.params.entity.name;
const config = entities[entityName];
if (!config) return e.params.data;
const { field: sortField, scope: scopeField } = config;
const data = e.params.data;
const scopeValue = scopeField ? data[scopeField] : undefined;
// if no position provided, set to max + 1
if (data[sortField] === undefined || data[sortField] === null) {
const query = kysely
.selectFrom(entityName)
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
// add scope filter if needed
.$if(Boolean(scopeField && scopeValue), (eb) =>
eb.where(scopeField as any, "=", scopeValue as any),
);
const result = await query.executeTakeFirst();
const max = result?.max ?? -1;
return {
...data,
[sortField]: max + 1,
};
}
// if position is provided, shift other items at or after that position
const newPosition = data[sortField] as number;
let shiftQuery = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition);
if (scopeField && scopeValue !== undefined) {
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
}
await shiftQuery.execute();
return data;
},
{
mode: "sync",
id: "bknd-sort-insert",
},
);
// handle update events
app.emgr.onEvent(
DatabaseEvents.MutatorUpdateBefore,
async (e) => {
const entityName = e.params.entity.name;
const config = entities[entityName];
if (!config) return e.params.data;
const { field: sortField, scope: scopeField } = config;
const data = e.params.data;
// only handle if sort field is being updated
if (!(sortField in data)) return e.params.data;
const newPosition = data[sortField] as number;
const id = e.params.entityId;
// get the current item to know its old position and scope
const item = await kysely
.selectFrom(entityName)
.selectAll()
.where("id" as any, "=", id)
.executeTakeFirst();
if (!item) return data;
const oldPosition = item[sortField] as number | null | undefined;
const scopeValue = scopeField ? item[scopeField] : undefined;
// if oldPosition is null or undefined, treat as inserting at newPosition
if (oldPosition === null || oldPosition === undefined) {
// shift items at or after the new position
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
return data;
}
// shift other items using kysely
if (oldPosition !== newPosition) {
if (newPosition < oldPosition) {
// moving up: increment items between newPosition and oldPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where(sortField as any, "<", oldPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
} else {
// moving down: decrement items between oldPosition and newPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
.where(sortField as any, ">", oldPosition)
.where(sortField as any, "<=", newPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
}
return data;
},
{
mode: "sync",
id: "bknd-sort-update",
},
);
}
+2
View File
@@ -8,3 +8,5 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
export { sort, type SortPluginOptions } from "./data/sort.plugin";
+3
View File
@@ -20,6 +20,9 @@
"css": {
"formatter": {
"indentWidth": 3
},
"parser": {
"tailwindDirectives": true
}
},
"json": {
+3 -2
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "bknd",
@@ -3896,7 +3897,7 @@
"@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"@bknd/plasmic/@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
"@bknd/plasmic/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
@@ -4794,7 +4795,7 @@
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="],
"@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
"@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
+1 -1
View File
@@ -1,2 +1,2 @@
[install]
#linker = "hoisted"
linker = "isolated"
@@ -261,3 +261,77 @@ export default {
```
### `emailOTP`
<Callout type="warning">
Make sure to setup proper permissions to restrict reading from the OTP entity. Also, this plugin requires the `email` driver to be registered.
</Callout>
A plugin that adds email OTP functionality to your app. It will add two endpoints to your app:
- `POST /api/auth/otp/login` to login a user with an OTP code
- `POST /api/auth/otp/register` to register a user with an OTP code
Both endpoints accept a JSON body with `email` (required) and `code` (optional). If `code` is provided, the OTP code will be validated and the user will be logged in or registered. If `code` is not provided, a new OTP code will be generated and sent to the user's email.
For example, to login an existing user with an OTP code, two requests are needed. The first one only with the email to generate and send the OTP code, and the second to send the users' email along with the OTP code. The last request will authenticate the user.
```http title="Generate OTP code to login"
POST /api/auth/otp/login
Content-Type: application/json
{
"email": "test@example.com"
}
```
If the user exists, an email will be sent with the OTP code, and the response will be a `201 Created`.
```http title="Login with OTP code"
POST /api/auth/otp/login
Content-Type: application/json
{
"email": "test@example.com",
"code": "123456"
}
```
If the code is valid, the user will be authenticated by sending a `Set-Cookie` header and a body property `token` with the JWT token (equally to the login endpoint).
```typescript title="bknd.config.ts"
import { emailOTP } from "bknd/plugins";
import { resendEmail } from "bknd";
export default {
options: {
drivers: {
// an email driver is required
email: resendEmail({ /* ... */}),
},
plugins: [
// all options are optional
emailOTP({
// the base path for the API endpoints
apiBasePath: "/api/auth/otp",
// the TTL for the OTP tokens in seconds
ttl: 600,
// the name of the OTP entity
entity: "users_otp",
// customize the email content
generateEmail: (otp) => ({
subject: "OTP Code",
body: `Your OTP code is: ${otp.code}`,
}),
// customize the code generation
generateCode: (user) => {
return Math.floor(100000 + Math.random() * 900000).toString();
},
})
],
},
} satisfies BkndConfig;
```
<AutoTypeTable path="../app/src/plugins/auth/email-otp.plugin.ts" name="EmailOTPPluginOptions" />
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"sideEffects": false,
"type": "module",
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.3.3",
"engines": {
"node": ">=22.13"
},