rename to email otp to make it more explicit

This commit is contained in:
dswbx
2025-11-07 08:48:02 +01:00
parent 68fbb6e933
commit 341eb13425
3 changed files with 40 additions and 38 deletions

View File

@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { otp } from "./otp.plugin"; import { emailOTP } from "./email-otp.plugin";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
@@ -10,7 +10,7 @@ describe("otp plugin", () => {
test("should not work if auth is not enabled", async () => { test("should not work if auth is not enabled", async () => {
const app = createApp({ const app = createApp({
options: { options: {
plugins: [otp({ showActualErrors: true })], plugins: [emailOTP({ showActualErrors: true })],
}, },
}); });
await app.build(); await app.build();
@@ -37,7 +37,7 @@ describe("otp plugin", () => {
send: async () => {}, send: async () => {},
}, },
}, },
plugins: [otp({ showActualErrors: true })], plugins: [emailOTP({ showActualErrors: true })],
}, },
}); });
await app.build(); await app.build();
@@ -69,7 +69,7 @@ describe("otp plugin", () => {
}, },
}, },
options: { options: {
plugins: [otp({ showActualErrors: true })], plugins: [emailOTP({ showActualErrors: true })],
drivers: { drivers: {
email: { email: {
send: async (to) => { send: async (to) => {
@@ -121,7 +121,7 @@ describe("otp plugin", () => {
}, },
options: { options: {
plugins: [ plugins: [
otp({ emailOTP({
showActualErrors: true, showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }), generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}), }),
@@ -179,7 +179,7 @@ describe("otp plugin", () => {
}, },
options: { options: {
plugins: [ plugins: [
otp({ emailOTP({
showActualErrors: true, showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }), generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}), }),

View File

@@ -16,7 +16,7 @@ import {
import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils";
import { Hono } from "hono"; import { Hono } from "hono";
export type OtpPluginOptions = { export type EmailOTPPluginOptions = {
/** /**
* Customize code generation. If not provided, a random 6-digit code will be generated. * Customize code generation. If not provided, a random 6-digit code will be generated.
*/ */
@@ -49,7 +49,7 @@ export type OtpPluginOptions = {
* Customize email content. If not provided, a default email will be sent. * Customize email content. If not provided, a default email will be sent.
*/ */
generateEmail?: ( generateEmail?: (
otp: OtpFieldSchema, otp: EmailOTPFieldSchema,
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
/** /**
@@ -57,6 +57,13 @@ export type OtpPluginOptions = {
* @default false * @default false
*/ */
showActualErrors?: boolean; 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;
}; };
const otpFields = { const otpFields = {
@@ -70,9 +77,14 @@ const otpFields = {
used_at: datetime(), used_at: datetime(),
}; };
export type OtpFieldSchema = FieldSchema<typeof otpFields>; export type EmailOTPFieldSchema = FieldSchema<typeof otpFields>;
export function otp({ class OTPError extends Exception {
override name = "OTPError";
override code = HttpStatus.BAD_REQUEST;
}
export function emailOTP({
generateCode: _generateCode, generateCode: _generateCode,
apiBasePath = "/api/auth/otp", apiBasePath = "/api/auth/otp",
ttl = 600, ttl = 600,
@@ -80,10 +92,11 @@ export function otp({
entityConfig, entityConfig,
generateEmail: _generateEmail, generateEmail: _generateEmail,
showActualErrors = false, showActualErrors = false,
}: OtpPluginOptions = {}): AppPlugin { allowExternalMutations = false,
}: EmailOTPPluginOptions = {}): AppPlugin {
return (app: App) => { return (app: App) => {
return { return {
name: "bknd-otp", name: "bknd-email-otp",
schema: () => schema: () =>
em( em(
{ {
@@ -112,7 +125,7 @@ export function otp({
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
const generateEmail = const generateEmail =
_generateEmail ?? _generateEmail ??
((otp: OtpFieldSchema) => ({ ((otp: EmailOTPFieldSchema) => ({
subject: "OTP Code", subject: "OTP Code",
body: `Your OTP code is: ${otp.code}`, body: `Your OTP code is: ${otp.code}`,
})); }));
@@ -205,7 +218,7 @@ export function otp({
}, },
) )
.onError((err) => { .onError((err) => {
if (showActualErrors) { if (showActualErrors || err instanceof OTPError) {
throw err; throw err;
} }
@@ -214,8 +227,9 @@ export function otp({
app.server.route(apiBasePath, hono); app.server.route(apiBasePath, hono);
// just for now, prevent mutations of the OTP entity if (allowExternalMutations !== true) {
registerListeners(app, entityName); registerListeners(app, entityName);
}
}, },
}; };
}; };
@@ -233,14 +247,14 @@ async function findUser(app: App, email: string) {
async function generateAndSendCode( async function generateAndSendCode(
app: App, app: App,
opts: Required<Pick<OtpPluginOptions, "generateCode" | "generateEmail" | "ttl" | "entity">>, opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "generateEmail" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">, user: Pick<DB["users"], "email">,
action: OtpFieldSchema["action"], action: EmailOTPFieldSchema["action"],
) { ) {
const { generateCode, generateEmail, ttl, entity: entityName } = opts; const { generateCode, generateEmail, ttl, entity: entityName } = opts;
const newCode = generateCode?.(user); const newCode = generateCode?.(user);
if (!newCode) { if (!newCode) {
throw new Exception("[OTP Plugin]: Failed to generate code"); throw new OTPError("Failed to generate code");
} }
await invalidateAllUserCodes(app, entityName, user.email, ttl); await invalidateAllUserCodes(app, entityName, user.email, ttl);
@@ -266,18 +280,18 @@ async function getValidatedCode(
entityName: string, entityName: string,
email: string, email: string,
code: string, code: string,
action: OtpFieldSchema["action"], action: EmailOTPFieldSchema["action"],
) { ) {
invariant(email, "[OTP Plugin]: Email is required"); invariant(email, "[OTP Plugin]: Email is required");
invariant(code, "[OTP Plugin]: Code is required"); invariant(code, "[OTP Plugin]: Code is required");
const em = app.em.fork(); const em = app.em.fork();
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action }); const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
if (!otpData) { if (!otpData) {
throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); throw new OTPError("Invalid code");
} }
if (otpData.expires_at < new Date()) { if (otpData.expires_at < new Date()) {
throw new Exception("Code expired", HttpStatus.GONE); throw new OTPError("Code expired");
} }
return otpData; return otpData;
@@ -298,27 +312,15 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin
function registerListeners(app: App, entityName: string) { function registerListeners(app: App, entityName: string) {
app.emgr.onAny( app.emgr.onAny(
(event) => { (event) => {
let allowed = true; if (event instanceof DatabaseEvents.MutatorInsertBefore || event instanceof DatabaseEvents.MutatorUpdateBefore) {
let action = "";
if (event instanceof DatabaseEvents.MutatorInsertBefore) {
if (event.params.entity.name === entityName) { if (event.params.entity.name === entityName) {
allowed = false; throw new OTPError("Mutations of the OTP entity are not allowed");
action = "create";
} }
} else if (event instanceof DatabaseEvents.MutatorUpdateBefore) {
if (event.params.entity.name === entityName) {
allowed = false;
action = "update";
}
}
if (!allowed) {
throw new Exception(`[OTP Plugin]: Not allowed to ${action} OTP codes manually`);
} }
}, },
{ {
mode: "sync", mode: "sync",
id: "bknd-otp", id: "bknd-email-otp",
}, },
); );
} }

View File

@@ -8,4 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { otp, type OtpPluginOptions } from "./auth/otp.plugin"; export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";