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/opfs-and-sqlocal
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MaybePromise } from "core/types";
|
||||
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
||||
|
||||
/**
|
||||
@@ -77,3 +78,37 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function $waitUntil(
|
||||
message: string,
|
||||
condition: () => MaybePromise<boolean>,
|
||||
delay = 100,
|
||||
maxAttempts = 10,
|
||||
) {
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
if (await condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
|
||||
}
|
||||
|
||||
@@ -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") }),
|
||||
}),
|
||||
|
||||
@@ -14,27 +14,31 @@ export function connectionTestSuite(
|
||||
{
|
||||
makeConnection,
|
||||
rawDialectDetails,
|
||||
disableConsoleLog: _disableConsoleLog = true,
|
||||
}: {
|
||||
makeConnection: () => MaybePromise<{
|
||||
connection: Connection;
|
||||
dispose: () => MaybePromise<void>;
|
||||
}>;
|
||||
rawDialectDetails: string[];
|
||||
disableConsoleLog?: boolean;
|
||||
},
|
||||
) {
|
||||
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
if (_disableConsoleLog) {
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
}
|
||||
|
||||
describe("base", () => {
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
beforeEach(async () => {
|
||||
ctx = await makeConnection();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
beforeEach(async () => {
|
||||
ctx = await makeConnection();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
describe("base", async () => {
|
||||
test("pings", async () => {
|
||||
const res = await ctx.connection.ping();
|
||||
expect(res).toBe(true);
|
||||
@@ -98,52 +102,54 @@ export function connectionTestSuite(
|
||||
});
|
||||
|
||||
describe("schema", async () => {
|
||||
const { connection, dispose } = await makeConnection();
|
||||
afterAll(async () => {
|
||||
await dispose();
|
||||
});
|
||||
const makeSchema = async () => {
|
||||
const fields = [
|
||||
{
|
||||
type: "integer",
|
||||
name: "id",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "text",
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
name: "json",
|
||||
},
|
||||
] as const satisfies FieldSpec[];
|
||||
|
||||
const fields = [
|
||||
{
|
||||
type: "integer",
|
||||
name: "id",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "text",
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
name: "json",
|
||||
},
|
||||
] as const satisfies FieldSpec[];
|
||||
let b = ctx.connection.kysely.schema.createTable("test");
|
||||
for (const field of fields) {
|
||||
// @ts-expect-error
|
||||
b = b.addColumn(...ctx.connection.getFieldSchema(field));
|
||||
}
|
||||
await b.execute();
|
||||
|
||||
let b = connection.kysely.schema.createTable("test");
|
||||
for (const field of fields) {
|
||||
// @ts-expect-error
|
||||
b = b.addColumn(...connection.getFieldSchema(field));
|
||||
}
|
||||
await b.execute();
|
||||
|
||||
// add index
|
||||
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
|
||||
// add index
|
||||
await ctx.connection.kysely.schema
|
||||
.createIndex("test_index")
|
||||
.on("test")
|
||||
.columns(["id"])
|
||||
.execute();
|
||||
};
|
||||
|
||||
test("executes query", async () => {
|
||||
await connection.kysely
|
||||
await makeSchema();
|
||||
await ctx.connection.kysely
|
||||
.insertInto("test")
|
||||
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
|
||||
.execute();
|
||||
|
||||
const expected = { id: 1, text: "test", json: { a: 1 } };
|
||||
|
||||
const qb = connection.kysely.selectFrom("test").selectAll();
|
||||
const res = await connection.executeQuery(qb);
|
||||
const qb = ctx.connection.kysely.selectFrom("test").selectAll();
|
||||
const res = await ctx.connection.executeQuery(qb);
|
||||
expect(res.rows).toEqual([expected]);
|
||||
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
|
||||
|
||||
{
|
||||
const res = await connection.executeQueries(qb, qb);
|
||||
const res = await ctx.connection.executeQueries(qb, qb);
|
||||
expect(res.length).toBe(2);
|
||||
res.map((r) => {
|
||||
expect(r.rows).toEqual([expected]);
|
||||
@@ -155,15 +161,21 @@ export function connectionTestSuite(
|
||||
});
|
||||
|
||||
test("introspects", async () => {
|
||||
const tables = await connection.getIntrospector().getTables({
|
||||
await makeSchema();
|
||||
const tables = await ctx.connection.getIntrospector().getTables({
|
||||
withInternalKyselyTables: false,
|
||||
});
|
||||
const clean = tables.map((t) => ({
|
||||
...t,
|
||||
columns: t.columns.map((c) => ({
|
||||
...c,
|
||||
dataType: undefined,
|
||||
})),
|
||||
columns: t.columns
|
||||
.map((c) => ({
|
||||
...c,
|
||||
// ignore data type
|
||||
dataType: undefined,
|
||||
// ignore default value if "id"
|
||||
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}));
|
||||
|
||||
expect(clean).toEqual([
|
||||
@@ -176,14 +188,8 @@ export function connectionTestSuite(
|
||||
dataType: undefined,
|
||||
isNullable: false,
|
||||
isAutoIncrementing: true,
|
||||
hasDefaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "text",
|
||||
dataType: undefined,
|
||||
isNullable: true,
|
||||
isAutoIncrementing: false,
|
||||
hasDefaultValue: false,
|
||||
hasDefaultValue: undefined,
|
||||
comment: undefined,
|
||||
},
|
||||
{
|
||||
name: "json",
|
||||
@@ -191,25 +197,34 @@ export function connectionTestSuite(
|
||||
isNullable: true,
|
||||
isAutoIncrementing: false,
|
||||
hasDefaultValue: false,
|
||||
comment: undefined,
|
||||
},
|
||||
{
|
||||
name: "text",
|
||||
dataType: undefined,
|
||||
isNullable: true,
|
||||
isAutoIncrementing: false,
|
||||
hasDefaultValue: false,
|
||||
comment: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
|
||||
{
|
||||
name: "test_index",
|
||||
table: "test",
|
||||
isUnique: false,
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(await connection.getIntrospector().getIndices()).toEqual([
|
||||
{
|
||||
name: "test_index",
|
||||
table: "test",
|
||||
isUnique: false,
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("integration", async () => {
|
||||
|
||||
33
app/src/data/connection/postgres/PgPostgresConnection.ts
Normal file
33
app/src/data/connection/postgres/PgPostgresConnection.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
|
||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
||||
import { customIntrospector } from "../Connection";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
export type PostgresDialectConfig = Omit<KyselyPostgresDialectConfig, "pool"> & {
|
||||
pool: Pool;
|
||||
};
|
||||
|
||||
export class PgPostgresConnection extends PostgresConnection<Pool> {
|
||||
override name = "pg";
|
||||
|
||||
constructor(config: PostgresDialectConfig) {
|
||||
const kysely = new Kysely({
|
||||
dialect: customIntrospector(PostgresDialect, PostgresIntrospector, {
|
||||
excludeTables: [],
|
||||
}).create(config),
|
||||
plugins,
|
||||
});
|
||||
|
||||
super(kysely);
|
||||
this.client = config.pool;
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
await this.client.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function pg(config: PostgresDialectConfig): PgPostgresConnection {
|
||||
return new PgPostgresConnection(config);
|
||||
}
|
||||
89
app/src/data/connection/postgres/PostgresConnection.ts
Normal file
89
app/src/data/connection/postgres/PostgresConnection.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Connection,
|
||||
type DbFunctions,
|
||||
type FieldSpec,
|
||||
type SchemaResponse,
|
||||
type ConnQuery,
|
||||
type ConnQueryResults,
|
||||
} from "../Connection";
|
||||
import {
|
||||
ParseJSONResultsPlugin,
|
||||
type ColumnDataType,
|
||||
type ColumnDefinitionBuilder,
|
||||
type Kysely,
|
||||
type KyselyPlugin,
|
||||
type SelectQueryBuilder,
|
||||
} from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
|
||||
export type QB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
export const plugins = [new ParseJSONResultsPlugin()];
|
||||
|
||||
export abstract class PostgresConnection<Client = unknown> extends Connection<Client> {
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
softscans: true,
|
||||
};
|
||||
|
||||
constructor(kysely: Kysely<any>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
|
||||
super(
|
||||
kysely,
|
||||
fn ?? {
|
||||
jsonArrayFrom,
|
||||
jsonBuildObject,
|
||||
jsonObjectFrom,
|
||||
},
|
||||
_plugins ?? plugins,
|
||||
);
|
||||
}
|
||||
|
||||
override getFieldSchema(spec: FieldSpec): SchemaResponse {
|
||||
this.validateFieldSpecType(spec.type);
|
||||
let type: ColumnDataType = spec.type;
|
||||
|
||||
if (spec.primary) {
|
||||
if (spec.type === "integer") {
|
||||
type = "serial";
|
||||
}
|
||||
}
|
||||
|
||||
switch (spec.type) {
|
||||
case "blob":
|
||||
type = "bytea";
|
||||
break;
|
||||
case "date":
|
||||
case "datetime":
|
||||
// https://www.postgresql.org/docs/17/datatype-datetime.html
|
||||
type = "timestamp";
|
||||
break;
|
||||
case "text":
|
||||
// https://www.postgresql.org/docs/17/datatype-character.html
|
||||
type = "varchar";
|
||||
break;
|
||||
}
|
||||
|
||||
return [
|
||||
spec.name,
|
||||
type,
|
||||
(col: ColumnDefinitionBuilder) => {
|
||||
if (spec.primary) {
|
||||
return col.primaryKey().notNull();
|
||||
}
|
||||
if (spec.references) {
|
||||
return col
|
||||
.references(spec.references)
|
||||
.onDelete(spec.onDelete ?? "set null")
|
||||
.onUpdate(spec.onUpdate ?? "no action");
|
||||
}
|
||||
return col;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
|
||||
return this.kysely.transaction().execute(async (trx) => {
|
||||
return Promise.all(qbs.map((q) => trx.executeQuery(q)));
|
||||
}) as any;
|
||||
}
|
||||
}
|
||||
128
app/src/data/connection/postgres/PostgresIntrospector.ts
Normal file
128
app/src/data/connection/postgres/PostgresIntrospector.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { type SchemaMetadata, sql } from "kysely";
|
||||
import { BaseIntrospector } from "../BaseIntrospector";
|
||||
|
||||
type PostgresSchemaSpec = {
|
||||
name: string;
|
||||
type: "VIEW" | "BASE TABLE";
|
||||
columns: {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt: string;
|
||||
pk: boolean;
|
||||
}[];
|
||||
indices: {
|
||||
name: string;
|
||||
origin: string;
|
||||
partial: number;
|
||||
sql: string;
|
||||
columns: { name: string; seqno: number }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export class PostgresIntrospector extends BaseIntrospector {
|
||||
async getSchemas(): Promise<SchemaMetadata[]> {
|
||||
const rawSchemas = await this.db
|
||||
.selectFrom("pg_catalog.pg_namespace")
|
||||
.select("nspname")
|
||||
.$castTo<{ nspname: string }>()
|
||||
.execute();
|
||||
|
||||
return rawSchemas.map((it) => ({ name: it.nspname }));
|
||||
}
|
||||
|
||||
async getSchemaSpec() {
|
||||
const query = sql`
|
||||
WITH tables_and_views AS (
|
||||
SELECT table_name AS name,
|
||||
table_type AS type
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type IN ('BASE TABLE', 'VIEW')
|
||||
AND table_name NOT LIKE 'pg_%'
|
||||
AND table_name NOT IN (${this.getExcludedTableNames().join(", ")})
|
||||
),
|
||||
|
||||
columns_info AS (
|
||||
SELECT table_name AS name,
|
||||
json_agg(json_build_object(
|
||||
'name', column_name,
|
||||
'type', data_type,
|
||||
'notnull', (CASE WHEN is_nullable = 'NO' THEN true ELSE false END),
|
||||
'dflt', column_default,
|
||||
'pk', (SELECT COUNT(*) > 0
|
||||
FROM information_schema.table_constraints tc
|
||||
INNER JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.table_name = c.table_name
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
AND kcu.column_name = c.column_name)
|
||||
)) AS columns
|
||||
FROM information_schema.columns c
|
||||
WHERE table_schema = 'public'
|
||||
GROUP BY table_name
|
||||
),
|
||||
|
||||
indices_info AS (
|
||||
SELECT
|
||||
t.relname AS table_name,
|
||||
json_agg(json_build_object(
|
||||
'name', i.relname,
|
||||
'origin', pg_get_indexdef(i.oid),
|
||||
'partial', (CASE WHEN ix.indisvalid THEN false ELSE true END),
|
||||
'sql', pg_get_indexdef(i.oid),
|
||||
'columns', (
|
||||
SELECT json_agg(json_build_object(
|
||||
'name', a.attname,
|
||||
'seqno', x.ordinal_position
|
||||
))
|
||||
FROM unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinal_position)
|
||||
JOIN pg_attribute a ON a.attnum = x.attnum AND a.attrelid = t.oid
|
||||
))) AS indices
|
||||
FROM pg_class t
|
||||
LEFT JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
LEFT JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
WHERE t.relkind IN ('r', 'v') -- r = table, v = view
|
||||
AND t.relname NOT LIKE 'pg_%'
|
||||
GROUP BY t.relname
|
||||
)
|
||||
|
||||
SELECT
|
||||
tv.name,
|
||||
tv.type,
|
||||
ci.columns,
|
||||
ii.indices
|
||||
FROM tables_and_views tv
|
||||
LEFT JOIN columns_info ci ON tv.name = ci.name
|
||||
LEFT JOIN indices_info ii ON tv.name = ii.table_name;
|
||||
`;
|
||||
|
||||
const tables = await this.executeWithPlugins<PostgresSchemaSpec[]>(query);
|
||||
|
||||
return tables.map((table) => ({
|
||||
name: table.name,
|
||||
isView: table.type === "VIEW",
|
||||
columns: table.columns.map((col) => ({
|
||||
name: col.name,
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.dflt?.toLowerCase().includes("nextval") ?? false,
|
||||
hasDefaultValue: col.dflt != null,
|
||||
comment: undefined,
|
||||
})),
|
||||
indices: table.indices
|
||||
// filter out db-managed primary key index
|
||||
.filter((index) => index.name !== `${table.name}_pkey`)
|
||||
.map((index) => ({
|
||||
name: index.name,
|
||||
table: table.name,
|
||||
isUnique: index.sql?.match(/unique/i) != null,
|
||||
columns: index.columns.map((col) => ({
|
||||
name: col.name,
|
||||
// seqno starts at 1
|
||||
order: col.seqno - 1,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
}
|
||||
31
app/src/data/connection/postgres/PostgresJsConnection.ts
Normal file
31
app/src/data/connection/postgres/PostgresJsConnection.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Kysely } from "kysely";
|
||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
||||
import { customIntrospector } from "../Connection";
|
||||
import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js";
|
||||
|
||||
export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectConfig["postgres"]> {
|
||||
override name = "postgres-js";
|
||||
|
||||
constructor(config: PostgresJSDialectConfig) {
|
||||
const kysely = new Kysely({
|
||||
dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, {
|
||||
excludeTables: [],
|
||||
}).create(config),
|
||||
plugins,
|
||||
});
|
||||
|
||||
super(kysely);
|
||||
this.client = config.postgres;
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
await this.client.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function postgresJs(
|
||||
config: PostgresJSDialectConfig,
|
||||
): PostgresJsConnection {
|
||||
return new PostgresJsConnection(config);
|
||||
}
|
||||
46
app/src/data/connection/postgres/custom.ts
Normal file
46
app/src/data/connection/postgres/custom.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { customIntrospector, type DbFunctions } from "../Connection";
|
||||
import { Kysely, type Dialect, type KyselyPlugin } from "kysely";
|
||||
import { plugins, PostgresConnection } from "./PostgresConnection";
|
||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||
|
||||
export type Constructor<T> = new (...args: any[]) => T;
|
||||
|
||||
export type CustomPostgresConnection = {
|
||||
supports?: Partial<PostgresConnection["supported"]>;
|
||||
fn?: Partial<DbFunctions>;
|
||||
plugins?: KyselyPlugin[];
|
||||
excludeTables?: string[];
|
||||
};
|
||||
|
||||
export function createCustomPostgresConnection<
|
||||
T extends Constructor<Dialect>,
|
||||
C extends ConstructorParameters<T>[0],
|
||||
>(
|
||||
name: string,
|
||||
dialect: Constructor<Dialect>,
|
||||
options?: CustomPostgresConnection,
|
||||
): (config: C) => PostgresConnection {
|
||||
const supported = {
|
||||
batching: true,
|
||||
...((options?.supports ?? {}) as any),
|
||||
};
|
||||
|
||||
return (config: C) =>
|
||||
new (class extends PostgresConnection {
|
||||
override name = name;
|
||||
override readonly supported = supported;
|
||||
|
||||
constructor(config: C) {
|
||||
super(
|
||||
new Kysely({
|
||||
dialect: customIntrospector(dialect, PostgresIntrospector, {
|
||||
excludeTables: options?.excludeTables ?? [],
|
||||
}).create(config),
|
||||
plugins: options?.plugins ?? plugins,
|
||||
}),
|
||||
options?.fn,
|
||||
options?.plugins,
|
||||
);
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
5
app/src/data/connection/postgres/index.ts
Normal file
5
app/src/data/connection/postgres/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { pg, PgPostgresConnection, type PgPostgresConnectionConfig } from "./PgPostgresConnection";
|
||||
export { PostgresIntrospector } from "./PostgresIntrospector";
|
||||
export { PostgresConnection, type QB, plugins } from "./PostgresConnection";
|
||||
export { postgresJs, PostgresJsConnection, type PostgresJsConfig } from "./PostgresJsConnection";
|
||||
export { createCustomPostgresConnection } from "./custom";
|
||||
@@ -13,7 +13,6 @@ import { customIntrospector } from "../Connection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
import type { Field } from "data/fields/Field";
|
||||
|
||||
// @todo: add pragmas
|
||||
export type SqliteConnectionConfig<
|
||||
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
|
||||
> = {
|
||||
|
||||
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null,
|
||||
comment: undefined,
|
||||
};
|
||||
}) ?? [],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import { test, describe, expect, beforeAll, afterAll } from "bun:test";
|
||||
import * as q from "./query";
|
||||
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
||||
import type { PrimaryFieldType } from "modules";
|
||||
import type { Generated } from "kysely";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
const parse = (v: unknown, o: ParseOptions = {}) =>
|
||||
$parse(q.repoQuery, v, {
|
||||
@@ -15,6 +16,9 @@ const decode = (input: any, output: any) => {
|
||||
expect(parse(input)).toEqual(output);
|
||||
};
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("server/query", () => {
|
||||
test("limit & offset", () => {
|
||||
//expect(() => parse({ limit: false })).toThrow();
|
||||
|
||||
@@ -132,6 +132,8 @@ export type * from "data/entities/Entity";
|
||||
export type { EntityManager } from "data/entities/EntityManager";
|
||||
export type { SchemaManager } from "data/schema/SchemaManager";
|
||||
export type * from "data/entities";
|
||||
|
||||
// data connection
|
||||
export {
|
||||
BaseIntrospector,
|
||||
Connection,
|
||||
@@ -144,9 +146,29 @@ export {
|
||||
type ConnQuery,
|
||||
type ConnQueryResults,
|
||||
} from "data/connection";
|
||||
|
||||
// data sqlite
|
||||
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
|
||||
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
|
||||
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
|
||||
|
||||
// data postgres
|
||||
export {
|
||||
pg,
|
||||
PgPostgresConnection,
|
||||
} from "data/connection/postgres/PgPostgresConnection";
|
||||
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
|
||||
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
|
||||
export {
|
||||
postgresJs,
|
||||
PostgresJsConnection,
|
||||
} from "data/connection/postgres/PostgresJsConnection";
|
||||
export {
|
||||
createCustomPostgresConnection,
|
||||
type CustomPostgresConnection,
|
||||
} from "data/connection/postgres/custom";
|
||||
|
||||
// data prototype
|
||||
export {
|
||||
text,
|
||||
number,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = <
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
import { objectTransform, encodeSearch } from "bknd/utils";
|
||||
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(
|
||||
|
||||
@@ -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