mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge remote-tracking branch 'origin/release/0.20' into feat/postgres-fc
# Conflicts: # app/src/core/utils/runtime.ts
This commit is contained in:
@@ -5,7 +5,6 @@ import type { em as prototypeEm } from "data/prototype";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
type ModuleConfigs,
|
||||
type Modules,
|
||||
ModuleManager,
|
||||
@@ -381,8 +380,10 @@ export class App<
|
||||
if (results.length > 0) {
|
||||
for (const { name, result } of results) {
|
||||
if (result) {
|
||||
$console.log(`[Plugin:${name}] schema`);
|
||||
ctx.helper.ensureSchema(result);
|
||||
if (ctx.flags.sync_required) {
|
||||
$console.log(`[Plugin:${name}] schema, sync required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createHandler<Env = BunEnv>(
|
||||
|
||||
export function serve<Env = BunEnv>(
|
||||
{
|
||||
app,
|
||||
distPath,
|
||||
connection,
|
||||
config: _config,
|
||||
@@ -62,6 +63,7 @@ export function serve<Env = BunEnv>(
|
||||
port,
|
||||
fetch: createHandler(
|
||||
{
|
||||
app,
|
||||
connection,
|
||||
config: _config,
|
||||
options,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { MaybePromise } from "bknd";
|
||||
import type { App, MaybePromise } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||
@@ -55,8 +55,12 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
// compatiblity
|
||||
export const getFresh = createApp;
|
||||
|
||||
let app: App | undefined;
|
||||
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
serveOptions?: (args: Env) => {
|
||||
warm?: boolean;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
@@ -92,8 +96,11 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
}
|
||||
}
|
||||
|
||||
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||
const app = await createApp(config, context);
|
||||
const { warm } = serveOptions?.(env) ?? {};
|
||||
if (!app || warm !== true) {
|
||||
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||
app = await createApp(config, context);
|
||||
}
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
|
||||
@@ -65,37 +65,31 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
beforeBuild: async (app, registries) => {
|
||||
if (!use_proxy) return;
|
||||
const env = await getEnv();
|
||||
registerMedia(env, registries as any);
|
||||
await config?.beforeBuild?.(app, registries);
|
||||
},
|
||||
bindings: async (env) => {
|
||||
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||
},
|
||||
// @ts-ignore
|
||||
app: async (_env) => {
|
||||
const env = await getEnv(_env);
|
||||
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
|
||||
const appConfig = typeof config.app === "function" ? await config.app(env) : config;
|
||||
const connection =
|
||||
use_proxy && binding
|
||||
? d1Sqlite({
|
||||
binding: binding.value as any,
|
||||
})
|
||||
: appConfig.connection;
|
||||
|
||||
if (config?.app === undefined && use_proxy && binding) {
|
||||
return {
|
||||
connection: d1Sqlite({
|
||||
binding: binding.value,
|
||||
}),
|
||||
};
|
||||
} else if (typeof config?.app === "function") {
|
||||
const appConfig = await config?.app(env);
|
||||
if (binding) {
|
||||
appConfig.connection = d1Sqlite({
|
||||
binding: binding.value,
|
||||
}) as any;
|
||||
}
|
||||
return appConfig;
|
||||
}
|
||||
return config?.app || {};
|
||||
return {
|
||||
...appConfig,
|
||||
beforeBuild: async (app, registries) => {
|
||||
if (!use_proxy) return;
|
||||
const env = await getEnv();
|
||||
registerMedia(env, registries as any);
|
||||
await config?.beforeBuild?.(app, registries);
|
||||
},
|
||||
bindings: async (env) => {
|
||||
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||
},
|
||||
connection,
|
||||
};
|
||||
},
|
||||
} satisfies CloudflareBkndConfig<Env>;
|
||||
}
|
||||
|
||||
@@ -14,14 +14,15 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
import type { Manifest } from "vite";
|
||||
|
||||
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||
CreateAppConfig & {
|
||||
app?:
|
||||
| Merge<Omit<BkndConfig, "app"> & Additional>
|
||||
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
} & Additional
|
||||
CreateAppConfig &
|
||||
Omit<Additional, "app"> & {
|
||||
app?:
|
||||
| Omit<BkndConfig<Args, Additional>, "app">
|
||||
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
}
|
||||
>;
|
||||
|
||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||
|
||||
@@ -19,7 +19,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
||||
if (!cleanRequest) return req;
|
||||
|
||||
const url = new URL(req.url);
|
||||
cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
|
||||
cleanRequest?.searchParams?.forEach((k) => {
|
||||
url.searchParams.delete(k);
|
||||
});
|
||||
|
||||
if (isNode()) {
|
||||
return new Request(url.toString(), {
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function createApp<Env = NodeEnv>(
|
||||
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
|
||||
);
|
||||
if (relativeDistPath) {
|
||||
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||
$console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||
}
|
||||
|
||||
registerLocalMediaAdapter();
|
||||
|
||||
@@ -67,7 +67,10 @@ export async function startServer(
|
||||
$console.info("Server listening on", url);
|
||||
|
||||
if (options.open) {
|
||||
await open(url);
|
||||
const p = await open(url, { wait: false });
|
||||
p.on("error", () => {
|
||||
$console.warn("Couldn't open url in browser");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
app/src/core/drivers/email/plunk.spec.ts
Normal file
55
app/src/core/drivers/email/plunk.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { plunkEmail } from "./plunk";
|
||||
|
||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||
|
||||
describe.skipIf(ALL_TESTS)("plunk", () => {
|
||||
it("should throw on failed", async () => {
|
||||
const driver = plunkEmail({ apiKey: "invalid" });
|
||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should send an email", async () => {
|
||||
const driver = plunkEmail({
|
||||
apiKey: process.env.PLUNK_API_KEY!,
|
||||
from: undefined, // Default to what Plunk sets
|
||||
});
|
||||
const response = await driver.send(
|
||||
"help@bknd.io",
|
||||
"Test Email from Plunk",
|
||||
"This is a test email",
|
||||
);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.emails).toBeDefined();
|
||||
expect(response.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("should send HTML email", async () => {
|
||||
const driver = plunkEmail({
|
||||
apiKey: process.env.PLUNK_API_KEY!,
|
||||
from: undefined,
|
||||
});
|
||||
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
||||
const response = await driver.send(
|
||||
"help@bknd.io",
|
||||
"HTML Test",
|
||||
htmlBody,
|
||||
);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should send with text and html", async () => {
|
||||
const driver = plunkEmail({
|
||||
apiKey: process.env.PLUNK_API_KEY!,
|
||||
from: undefined,
|
||||
});
|
||||
const response = await driver.send("test@example.com", "Test Email", {
|
||||
text: "help@bknd.io",
|
||||
html: "<p>This is HTML</p>",
|
||||
});
|
||||
expect(response).toBeDefined();
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
70
app/src/core/drivers/email/plunk.ts
Normal file
70
app/src/core/drivers/email/plunk.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { IEmailDriver } from "./index";
|
||||
|
||||
export type PlunkEmailOptions = {
|
||||
apiKey: string;
|
||||
host?: string;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
export type PlunkEmailSendOptions = {
|
||||
subscribed?: boolean;
|
||||
name?: string;
|
||||
from?: string;
|
||||
reply?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type PlunkEmailResponse = {
|
||||
success: boolean;
|
||||
emails: Array<{
|
||||
contact: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
email: string;
|
||||
}>;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export const plunkEmail = (
|
||||
config: PlunkEmailOptions,
|
||||
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
||||
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
||||
const from = config.from;
|
||||
|
||||
return {
|
||||
send: async (
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string | { text: string; html: string },
|
||||
options?: PlunkEmailSendOptions,
|
||||
) => {
|
||||
const payload: any = {
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
};
|
||||
|
||||
if (typeof body === "string") {
|
||||
payload.body = body;
|
||||
} else {
|
||||
payload.body = body.html;
|
||||
}
|
||||
|
||||
const res = await fetch(host, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ ...payload, ...options }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Plunk API error: ${await res.text()}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as PlunkEmailResponse;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export type { IEmailDriver } from "./email";
|
||||
export { resendEmail } from "./email/resend";
|
||||
export { sesEmail } from "./email/ses";
|
||||
export { mailchannelsEmail } from "./email/mailchannels";
|
||||
export { plunkEmail } from "./email/plunk";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
export function omitKeys<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys_: readonly K[],
|
||||
keys_: readonly K[] | K[] | string[],
|
||||
): Omit<T, Extract<K, keyof T>> {
|
||||
const keys = new Set(keys_);
|
||||
const keys = new Set(keys_ as readonly K[]);
|
||||
const result = {} as Omit<T, Extract<K, keyof T>>;
|
||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||
if (!keys.has(key as K)) {
|
||||
|
||||
@@ -79,6 +79,22 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function $waitUntil(
|
||||
message: string,
|
||||
condition: () => MaybePromise<boolean>,
|
||||
|
||||
@@ -120,17 +120,14 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
|
||||
}
|
||||
|
||||
export function slugify(str: string): string {
|
||||
return (
|
||||
String(str)
|
||||
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
||||
// biome-ignore lint/suspicious/noMisleadingCharacterClass: <explanation>
|
||||
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
||||
.trim() // trim leading or trailing whitespace
|
||||
.toLowerCase() // convert to lowercase
|
||||
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
||||
.replace(/\s+/g, "-") // replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // remove consecutive hyphens
|
||||
);
|
||||
return String(str)
|
||||
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
||||
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
||||
.trim() // trim leading or trailing whitespace
|
||||
.toLowerCase() // convert to lowercase
|
||||
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
||||
.replace(/\s+/g, "-") // replace spaces with hyphens
|
||||
.replace(/-+/g, "-"); // remove consecutive hyphens
|
||||
}
|
||||
|
||||
export function truncate(str: string, length = 50, end = "..."): string {
|
||||
|
||||
@@ -96,6 +96,9 @@ export class DataController extends Controller {
|
||||
// read entity schema
|
||||
hono.get(
|
||||
"/schema.json",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
@@ -124,6 +127,9 @@ export class DataController extends Controller {
|
||||
// read schema
|
||||
hono.get(
|
||||
"/schemas/:entity/:context?",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
@@ -161,7 +167,7 @@ export class DataController extends Controller {
|
||||
hono.get(
|
||||
"/types",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (c) => ({ module: "data" }),
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Retrieve data typescript definitions",
|
||||
@@ -182,6 +188,9 @@ export class DataController extends Controller {
|
||||
*/
|
||||
hono.get(
|
||||
"/info/:entity",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
|
||||
@@ -103,6 +103,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
validated.with = options.with;
|
||||
}
|
||||
|
||||
// add explicit joins. Implicit joins are added in `where` builder
|
||||
if (options.join && options.join.length > 0) {
|
||||
for (const entry of options.join) {
|
||||
const related = this.em.relationOf(entity.name, entry);
|
||||
@@ -127,12 +128,28 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||
if (field.includes(".")) {
|
||||
const [alias, prop] = field.split(".") as [string, string];
|
||||
if (!aliases.includes(alias)) {
|
||||
// check aliases first (added joins)
|
||||
if (aliases.includes(alias)) {
|
||||
this.checkIndex(alias, prop, "where");
|
||||
return !this.em.entity(alias).getField(prop);
|
||||
}
|
||||
// check if alias (entity) exists
|
||||
if (!this.em.hasEntity(alias)) {
|
||||
return true;
|
||||
}
|
||||
// check related fields for auto join
|
||||
const related = this.em.relationOf(entity.name, alias);
|
||||
if (related) {
|
||||
const other = related.other(entity);
|
||||
if (other.entity.getField(prop)) {
|
||||
// if related field is found, add join to validated options
|
||||
validated.join?.push(alias);
|
||||
this.checkIndex(alias, prop, "where");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.checkIndex(alias, prop, "where");
|
||||
return !this.em.entity(alias).getField(prop);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.checkIndex(entity.name, field, "where");
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
import { InvalidSearchParamsException } from "data/errors";
|
||||
import type { Entity, EntityManager, RepositoryQB } from "data/entities";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export class WithBuilder {
|
||||
static addClause(
|
||||
@@ -13,7 +14,7 @@ export class WithBuilder {
|
||||
withs: RepoQuery["with"],
|
||||
) {
|
||||
if (!withs || !isObject(withs)) {
|
||||
console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
||||
$console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -37,9 +38,7 @@ export class WithBuilder {
|
||||
let subQuery = relation.buildWith(entity, ref)(eb);
|
||||
if (query) {
|
||||
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
||||
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter(
|
||||
Boolean,
|
||||
) as any,
|
||||
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,7 +56,7 @@ export class WithBuilder {
|
||||
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
|
||||
let depth = 0;
|
||||
if (!withs || !isObject(withs)) {
|
||||
withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
|
||||
withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
|
||||
return depth;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@ export class JsonSchemaField<
|
||||
|
||||
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
|
||||
super(name, config);
|
||||
this.validator = new Validator({ ...this.getJsonSchema() });
|
||||
|
||||
// make sure to hand over clean json
|
||||
const schema = this.getJsonSchema();
|
||||
this.validator = new Validator(
|
||||
typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {},
|
||||
);
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
|
||||
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
||||
}
|
||||
|
||||
withConnection(connection: Connection): EntityManager<Schema<Entities>> {
|
||||
withConnection(connection: Connection): EntityManager<Schemas<Entities>> {
|
||||
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,19 @@ export type CodeMode<AdapterConfig extends BkndConfig> = AdapterConfig extends B
|
||||
? BkndModeConfig<Args, AdapterConfig>
|
||||
: never;
|
||||
|
||||
export function code<Args>(config: BkndCodeModeConfig<Args>): BkndConfig<Args> {
|
||||
export function code<
|
||||
Config extends BkndConfig,
|
||||
Args = Config extends BkndConfig<infer A> ? A : unknown,
|
||||
>(codeConfig: CodeMode<Config>): BkndConfig<Args> {
|
||||
return {
|
||||
...config,
|
||||
...codeConfig,
|
||||
app: async (args) => {
|
||||
const {
|
||||
config: appConfig,
|
||||
plugins,
|
||||
isProd,
|
||||
syncSchemaOptions,
|
||||
} = await makeModeConfig(config, args);
|
||||
} = await makeModeConfig(codeConfig, args);
|
||||
|
||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
|
||||
$console.warn("You should not set a different mode than `db` when using code mode");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
import { makeModeConfig, type BkndModeConfig } from "./shared";
|
||||
import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd";
|
||||
import { getDefaultConfig, type MaybePromise, type Merge } from "bknd";
|
||||
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { invariant, $console } from "bknd/utils";
|
||||
|
||||
@@ -9,7 +9,7 @@ export type BkndHybridModeOptions = {
|
||||
* Reader function to read the configuration from the file system.
|
||||
* This is required for hybrid mode to work.
|
||||
*/
|
||||
reader?: (path: string) => MaybePromise<string>;
|
||||
reader?: (path: string) => MaybePromise<string | object>;
|
||||
/**
|
||||
* Provided secrets to be merged into the configuration
|
||||
*/
|
||||
@@ -23,42 +23,36 @@ export type HybridMode<AdapterConfig extends BkndConfig> = AdapterConfig extends
|
||||
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
|
||||
: never;
|
||||
|
||||
export function hybrid<Args>({
|
||||
configFilePath = "bknd-config.json",
|
||||
...rest
|
||||
}: HybridBkndConfig<Args>): BkndConfig<Args> {
|
||||
export function hybrid<
|
||||
Config extends BkndConfig,
|
||||
Args = Config extends BkndConfig<infer A> ? A : unknown,
|
||||
>(hybridConfig: HybridMode<Config>): BkndConfig<Args> {
|
||||
return {
|
||||
...rest,
|
||||
config: undefined,
|
||||
...hybridConfig,
|
||||
app: async (args) => {
|
||||
const {
|
||||
config: appConfig,
|
||||
isProd,
|
||||
plugins,
|
||||
syncSchemaOptions,
|
||||
} = await makeModeConfig(
|
||||
{
|
||||
...rest,
|
||||
configFilePath,
|
||||
},
|
||||
args,
|
||||
);
|
||||
} = await makeModeConfig(hybridConfig, args);
|
||||
|
||||
const configFilePath = appConfig.configFilePath ?? "bknd-config.json";
|
||||
|
||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") {
|
||||
$console.warn("You should not set a different mode than `db` when using hybrid mode");
|
||||
}
|
||||
invariant(
|
||||
typeof appConfig.reader === "function",
|
||||
"You must set the `reader` option when using hybrid mode",
|
||||
"You must set a `reader` option when using hybrid mode",
|
||||
);
|
||||
|
||||
let fileConfig: ModuleConfigs;
|
||||
try {
|
||||
fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs;
|
||||
} catch (e) {
|
||||
const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs;
|
||||
await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2));
|
||||
fileConfig = defaultConfig;
|
||||
const fileContent = await appConfig.reader?.(configFilePath);
|
||||
let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent;
|
||||
if (!fileConfig) {
|
||||
$console.warn("No config found, using default config");
|
||||
fileConfig = getDefaultConfig();
|
||||
await appConfig.writer?.(configFilePath, JSON.stringify(fileConfig, null, 2));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -80,6 +74,13 @@ export function hybrid<Args>({
|
||||
skipValidation: isProd,
|
||||
// secrets are required for hybrid mode
|
||||
secrets: appConfig.secrets,
|
||||
onModulesBuilt: async (ctx) => {
|
||||
if (ctx.flags.sync_required && !isProd && syncSchemaOptions.force) {
|
||||
$console.log("[hybrid] syncing schema");
|
||||
await ctx.em.schema().sync(syncSchemaOptions);
|
||||
}
|
||||
await appConfig?.options?.manager?.onModulesBuilt?.(ctx);
|
||||
},
|
||||
...appConfig?.options?.manager,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
|
||||
import { syncTypes, syncConfig } from "bknd/plugins";
|
||||
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
|
||||
import { invariant, $console } from "bknd/utils";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export type BkndModeOptions = {
|
||||
/**
|
||||
@@ -56,6 +56,14 @@ export type BkndModeConfig<Args = any, Additional = {}> = BkndConfig<
|
||||
Merge<BkndModeOptions & Additional>
|
||||
>;
|
||||
|
||||
function _isProd() {
|
||||
try {
|
||||
return process.env.NODE_ENV === "production";
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeModeConfig<
|
||||
Args = any,
|
||||
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
|
||||
@@ -69,25 +77,24 @@ export async function makeModeConfig<
|
||||
|
||||
if (typeof config.isProduction !== "boolean") {
|
||||
$console.warn(
|
||||
"You should set `isProduction` option when using managed modes to prevent accidental issues",
|
||||
"You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to",
|
||||
_isProd(),
|
||||
);
|
||||
}
|
||||
|
||||
invariant(
|
||||
typeof config.writer === "function",
|
||||
"You must set the `writer` option when using managed modes",
|
||||
);
|
||||
let needsWriter = false;
|
||||
|
||||
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
|
||||
|
||||
const isProd = config.isProduction;
|
||||
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const isProd = config.isProduction ?? _isProd();
|
||||
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
|
||||
const syncSchemaOptions =
|
||||
typeof config.syncSchema === "object"
|
||||
? config.syncSchema
|
||||
: {
|
||||
force: config.syncSchema !== false,
|
||||
drop: true,
|
||||
force: syncFallback,
|
||||
drop: syncFallback,
|
||||
};
|
||||
|
||||
if (!isProd) {
|
||||
@@ -95,6 +102,7 @@ export async function makeModeConfig<
|
||||
if (plugins.some((p) => p.name === "bknd-sync-types")) {
|
||||
throw new Error("You have to unregister the `syncTypes` plugin");
|
||||
}
|
||||
needsWriter = true;
|
||||
plugins.push(
|
||||
syncTypes({
|
||||
enabled: true,
|
||||
@@ -114,6 +122,7 @@ export async function makeModeConfig<
|
||||
if (plugins.some((p) => p.name === "bknd-sync-config")) {
|
||||
throw new Error("You have to unregister the `syncConfig` plugin");
|
||||
}
|
||||
needsWriter = true;
|
||||
plugins.push(
|
||||
syncConfig({
|
||||
enabled: true,
|
||||
@@ -142,6 +151,7 @@ export async function makeModeConfig<
|
||||
.join(".");
|
||||
}
|
||||
|
||||
needsWriter = true;
|
||||
plugins.push(
|
||||
syncSecrets({
|
||||
enabled: true,
|
||||
@@ -174,6 +184,10 @@ export async function makeModeConfig<
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWriter && typeof config.writer !== "function") {
|
||||
$console.warn("You must set a `writer` function, attempts to write will fail");
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
isProd,
|
||||
|
||||
@@ -223,7 +223,7 @@ export class ModuleManager {
|
||||
}
|
||||
|
||||
extractSecrets() {
|
||||
const moduleConfigs = structuredClone(this.configs());
|
||||
const moduleConfigs = JSON.parse(JSON.stringify(this.configs()));
|
||||
const secrets = { ...this.options?.secrets };
|
||||
const extractedKeys: string[] = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils";
|
||||
import { mark, stripMark, $console, s, setPath } from "bknd/utils";
|
||||
import { BkndError } from "core/errors";
|
||||
import * as $diff from "core/object/diff";
|
||||
import type { Connection } from "data/connection";
|
||||
@@ -290,13 +290,12 @@ export class DbModuleManager extends ModuleManager {
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
} else if (e instanceof TransformPersistFailedException) {
|
||||
$console.error("ModuleManager: Cannot save invalid config");
|
||||
this.revertModules();
|
||||
throw e;
|
||||
} else {
|
||||
if (e instanceof TransformPersistFailedException) {
|
||||
$console.error("ModuleManager: Cannot save invalid config");
|
||||
}
|
||||
$console.error("ModuleManager: Aborting");
|
||||
this.revertModules();
|
||||
await this.revertModules();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,5 @@ export const schemaRead = new Permission(
|
||||
);
|
||||
export const build = new Permission("system.build");
|
||||
export const mcp = new Permission("system.mcp");
|
||||
export const info = new Permission("system.info");
|
||||
export const openapi = new Permission("system.openapi");
|
||||
|
||||
@@ -105,7 +105,10 @@ export class AppServer extends Module<AppServerConfig> {
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (isDebug()) {
|
||||
return c.json({ error: err.message, stack: err.stack }, 500);
|
||||
return c.json(
|
||||
{ error: err.message, stack: err.stack?.split("\n").map((line) => line.trim()) },
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import type { App } from "App";
|
||||
import {
|
||||
datetimeStringLocal,
|
||||
@@ -125,7 +123,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 +315,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,
|
||||
@@ -354,7 +357,7 @@ export class SystemController extends Controller {
|
||||
|
||||
override getController() {
|
||||
const { permission, auth } = this.middlewares;
|
||||
const hono = this.create().use(auth());
|
||||
const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {}));
|
||||
|
||||
this.registerConfigController(hono);
|
||||
|
||||
@@ -429,6 +432,9 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/permissions",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "auth" }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Get the permissions",
|
||||
tags: ["system"],
|
||||
@@ -441,6 +447,7 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.post(
|
||||
"/build",
|
||||
permission(SystemPermissions.build, {}),
|
||||
describeRoute({
|
||||
summary: "Build the app",
|
||||
tags: ["system"],
|
||||
@@ -471,6 +478,7 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/info",
|
||||
permission(SystemPermissions.info, {}),
|
||||
mcpTool("system_info"),
|
||||
describeRoute({
|
||||
summary: "Get the server info",
|
||||
@@ -504,6 +512,7 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/openapi.json",
|
||||
permission(SystemPermissions.openapi, {}),
|
||||
openAPISpecs(this.ctx.server, {
|
||||
info: {
|
||||
title: "bknd API",
|
||||
@@ -511,7 +520,11 @@ export class SystemController extends Controller {
|
||||
},
|
||||
}),
|
||||
);
|
||||
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||
hono.get(
|
||||
"/swagger",
|
||||
permission(SystemPermissions.openapi, {}),
|
||||
swaggerUI({ url: "/api/system/openapi.json" }),
|
||||
);
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
683
app/src/plugins/auth/email-otp.plugin.spec.ts
Normal file
683
app/src/plugins/auth/email-otp.plugin.spec.ts
Normal file
@@ -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
app/src/plugins/auth/email-otp.plugin.ts
Normal file
387
app/src/plugins/auth/email-otp.plugin.ts
Normal 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",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -8,3 +8,4 @@ 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";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { useApi } from "bknd/client";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
|
||||
@@ -14,18 +14,20 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
|
||||
export type ClientProviderProps = {
|
||||
children?: ReactNode;
|
||||
baseUrl?: string;
|
||||
api?: Api;
|
||||
} & ApiOptions;
|
||||
|
||||
export const ClientProvider = ({
|
||||
children,
|
||||
host,
|
||||
baseUrl: _baseUrl = host,
|
||||
api: _api,
|
||||
...props
|
||||
}: ClientProviderProps) => {
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx = useClientContext();
|
||||
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
|
||||
let user: any = undefined;
|
||||
let user: any;
|
||||
|
||||
if (winCtx) {
|
||||
user = winCtx.user;
|
||||
@@ -40,6 +42,7 @@ export const ClientProvider = ({
|
||||
const apiProps = { user, ...props, host: actualBaseUrl };
|
||||
const api = useMemo(
|
||||
() =>
|
||||
_api ??
|
||||
new Api({
|
||||
...apiProps,
|
||||
verbose: isDebug(),
|
||||
@@ -50,7 +53,7 @@ export const ClientProvider = ({
|
||||
}
|
||||
},
|
||||
}),
|
||||
[JSON.stringify(apiProps)],
|
||||
[_api, JSON.stringify(apiProps)],
|
||||
);
|
||||
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||
@@ -64,6 +67,10 @@ export const ClientProvider = ({
|
||||
|
||||
export const useApi = (host?: ApiOptions["host"]): Api => {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
throw new Error("useApi must be used within a ClientProvider");
|
||||
}
|
||||
|
||||
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
|
||||
return new Api({ host: host ?? "" });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Api } from "Api";
|
||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useApi } from "ui/client";
|
||||
import { useApi } from "../ClientProvider";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useApiQuery = <
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
ModuleApi,
|
||||
} from "bknd";
|
||||
import { objectTransform, encodeSearch } from "bknd/utils";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
|
||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
import { type Api, useApi } from "bknd/client";
|
||||
|
||||
export class UseEntityApiError<Payload = any> extends Error {
|
||||
constructor(
|
||||
@@ -33,6 +33,7 @@ interface UseEntityReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
ActualId = Data extends { id: infer I } ? (I extends Generated<infer T> ? T : I) : never,
|
||||
Response = ResponseObject<RepositoryResult<Selectable<Data>>>,
|
||||
> {
|
||||
create: (input: Insertable<Data>) => Promise<Response>;
|
||||
@@ -42,9 +43,11 @@ interface UseEntityReturn<
|
||||
ResponseObject<RepositoryResult<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>>
|
||||
>;
|
||||
update: Id extends undefined
|
||||
? (input: Updateable<Data>, id: Id) => Promise<Response>
|
||||
? (input: Updateable<Data>, id: ActualId) => Promise<Response>
|
||||
: (input: Updateable<Data>) => Promise<Response>;
|
||||
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
|
||||
_delete: Id extends undefined
|
||||
? (id: PrimaryFieldType) => Promise<Response>
|
||||
: () => Promise<Response>;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type ClientProviderProps,
|
||||
useApi,
|
||||
useBaseUrl,
|
||||
useClientContext
|
||||
} from "./ClientProvider";
|
||||
|
||||
export * from "./api/use-api";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AuthState } from "Api";
|
||||
import type { AuthResponse } from "bknd";
|
||||
import { useApi, useInvalidate } from "ui/client";
|
||||
import { useClientContext } from "ui/client/ClientProvider";
|
||||
import { useApi, useInvalidate, useClientContext } from "bknd/client";
|
||||
|
||||
type LoginData = {
|
||||
email: string;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { useApi } from "bknd/client";
|
||||
|
||||
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||
export const useAuthStrategies = (options?: {
|
||||
baseUrl?: string;
|
||||
}): Partial<AuthStrategyData> & {
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [data, setData] = useState<AuthStrategyData>();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { checkMaxReached } from "./helper";
|
||||
import { DropzoneInner } from "./DropzoneInner";
|
||||
import { createDropzoneStore } from "ui/elements/media/dropzone-state";
|
||||
import { createDropzoneStore } from "./dropzone-state";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
export type FileState = {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Api } from "bknd/client";
|
||||
import type { PrimaryFieldType, RepoQueryIn } from "bknd";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId, useEffect, useRef, useState } from "react";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
||||
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
@@ -132,26 +131,24 @@ export function DropzoneContainer({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropzone
|
||||
key={key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
infinite &&
|
||||
"setSize" in $q && (
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={placeholderLength}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
<Dropzone
|
||||
key={key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
infinite &&
|
||||
"setSize" in $q && (
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={placeholderLength}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} from "react-icons/tb";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { formatNumber } from "core/utils";
|
||||
import type { DropzoneRenderProps, FileState } from "ui/elements";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
import type { DropzoneRenderProps, FileState } from "./Dropzone";
|
||||
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
||||
|
||||
function handleUploadError(e: unknown) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TbUser,
|
||||
TbX,
|
||||
} from "react-icons/tb";
|
||||
import { useAuth, useBkndWindowContext } from "ui/client";
|
||||
import { useAuth, useBkndWindowContext } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useApi, useInvalidate } from "ui/client";
|
||||
import { useApi, useInvalidate } from "bknd/client";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { EntityData } from "bknd";
|
||||
import type { RelationField } from "data/relations";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TbEye } from "react-icons/tb";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
import { useApiQuery } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
|
||||
|
||||
@@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { cn } from "ui/lib/utils";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { mountOnce, useApiQuery } from "ui/client";
|
||||
import { mountOnce, useApiQuery } from "bknd/client";
|
||||
import { CodePreview } from "ui/components/code/CodePreview";
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils";
|
||||
import type { Entity, EntityData, EntityRelation } from "bknd";
|
||||
import { Fragment, useState } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery, useEntityQuery } from "ui/client";
|
||||
import { useApiQuery, useEntityQuery } from "bknd/client";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EntityData } from "bknd";
|
||||
import { useState } from "react";
|
||||
import { useEntityMutate } from "ui/client";
|
||||
import { useEntityMutate } from "bknd/client";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Entity } from "bknd";
|
||||
import { repoQuery } from "data/server/query";
|
||||
import { Fragment } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
import { useApiQuery } from "bknd/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
ucFirstAllSnakeToPascalWithSpaces,
|
||||
s,
|
||||
stringIdentifier,
|
||||
pickKeys,
|
||||
} from "bknd/utils";
|
||||
import {
|
||||
type TAppDataEntityFields,
|
||||
fieldsSchemaObject as originalFieldsSchemaObject,
|
||||
} from "data/data-schema";
|
||||
import { omit } from "lodash-es";
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
|
||||
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
|
||||
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
|
||||
@@ -317,7 +317,6 @@ function EntityField({
|
||||
const name = watch(`fields.${index}.name`);
|
||||
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
|
||||
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
|
||||
const specificData = omit(field.field.config, commonProps);
|
||||
const disabled = fieldSpec.disabled || [];
|
||||
const hidden = fieldSpec.hidden || [];
|
||||
const dragDisabled = index === 0;
|
||||
@@ -476,7 +475,7 @@ function EntityField({
|
||||
field={field}
|
||||
onChange={(value) => {
|
||||
setValue(`${prefix}.config`, {
|
||||
...getValues([`fields.${index}.config`])[0],
|
||||
...pickKeys(getValues([`${prefix}.config`])[0], commonProps),
|
||||
...value,
|
||||
});
|
||||
}}
|
||||
@@ -520,7 +519,7 @@ const SpecificForm = ({
|
||||
readonly?: boolean;
|
||||
}) => {
|
||||
const type = field.field.type;
|
||||
const specificData = omit(field.field.config, commonProps);
|
||||
const specificData = omitKeys(field.field.config ?? {}, commonProps);
|
||||
|
||||
return (
|
||||
<JsonSchemaForm
|
||||
|
||||
@@ -11,7 +11,7 @@ import SettingsRoutes from "./settings";
|
||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
import { AuthRegister } from "ui/routes/auth/auth.register";
|
||||
import { BkndModalsProvider } from "ui/modals";
|
||||
import { useBkndWindowContext } from "ui/client";
|
||||
import { useBkndWindowContext } from "bknd/client";
|
||||
import ToolsRoutes from "./tools";
|
||||
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useLocation } from "wouter";
|
||||
import { bkndModals } from "ui/modals";
|
||||
import { DropzoneContainer } from "ui/elements/media/DropzoneContainer";
|
||||
import type { FileState } from "ui/elements/media/Dropzone";
|
||||
|
||||
export function MediaIndex() {
|
||||
const { config } = useBknd();
|
||||
@@ -35,7 +36,7 @@ export function MediaIndex() {
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
|
||||
<DropzoneContainer onClick={onClick} infinite query={{ sort: "-id" }} />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconHome } from "@tabler/icons-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "ui/client";
|
||||
import { useAuth } from "bknd/client";
|
||||
import { useEffectOnce } from "ui/hooks/use-effect";
|
||||
import { Empty } from "../components/display/Empty";
|
||||
import { useBrowserTitle } from "../hooks/use-browser-title";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi, useApiQuery } from "ui/client";
|
||||
import { useApi, useApiQuery } from "bknd/client";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
function Bla() {
|
||||
|
||||
Reference in New Issue
Block a user