From 0629e1bc5083bcfa2f10776065cb7c763c2a6e0f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:24:37 +0200 Subject: [PATCH 1/5] feat: add role selection and auth checks in user create command integrated role selection prompt during user creation and added an auth-enabled check to ensure correct configuration before executing commands. adjusted CLI commands to include role assignment for newly created users. --- app/src/cli/commands/user.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 3721a2b..726748b 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -3,6 +3,7 @@ import { log as $log, password as $password, text as $text, + select as $select, } from "@clack/prompts"; import type { App } from "App"; import type { PasswordStrategy } from "auth/authenticate/strategies"; @@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig server: "node", }); + if (!app.module.auth.enabled) { + $log.error("Auth is not enabled"); + process.exit(1); + } + switch (action) { case "create": await create(app, options); @@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig } async function create(app: App, options: any) { - const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; + const auth = app.module.auth; + let role: string | null = null; + const roles = Object.keys(auth.config.roles ?? {}); + + const strategy = auth.authenticator.strategy("password") as PasswordStrategy; + if (roles.length > 0) { + role = (await $select({ + message: "Select role", + options: [ + { + value: null, + label: "", + hint: "No role will be assigned to the user", + }, + ...roles.map((role) => ({ + value: role, + label: role, + })), + ], + })) as any; + if ($isCancel(role)) process.exit(1); + } if (!strategy) { $log.error("Password strategy not configured"); @@ -76,6 +103,7 @@ async function create(app: App, options: any) { const created = await app.createUser({ email, password: await strategy.hash(password as string), + role, }); $log.success(`Created user: ${c.cyan(created.email)}`); process.exit(0); From ace9c1b2b958c07ab1b84ff7b7f076e612659072 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:26:07 +0200 Subject: [PATCH 2/5] feat: add helper methods for auth cookie headers introduced `getAuthCookieHeader` and `removeAuthCookieHeader` methods to simplify header management for authentication cookies. added tests to validate the new methods. --- app/__test__/auth/Authenticator.spec.ts | 40 +++++++++++++++++++++- app/src/auth/authenticate/Authenticator.ts | 25 ++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/__test__/auth/Authenticator.spec.ts b/app/__test__/auth/Authenticator.spec.ts index 0794528..fcf8e5c 100644 --- a/app/__test__/auth/Authenticator.spec.ts +++ b/app/__test__/auth/Authenticator.spec.ts @@ -1,3 +1,41 @@ +import { Authenticator } from "auth/authenticate/Authenticator"; import { describe, expect, test } from "bun:test"; -describe("Authenticator", async () => {}); +describe("Authenticator", async () => { + test("should return auth cookie headers", async () => { + const auth = new Authenticator({}, null as any, { + jwt: { + secret: "secret", + fields: [], + }, + cookie: { + sameSite: "strict", + }, + }); + const headers = await auth.getAuthCookieHeader("token"); + const cookie = headers.get("Set-Cookie"); + expect(cookie).toStartWith("auth="); + expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict"); + + // now expect it to be removed + const headers2 = await auth.removeAuthCookieHeader(headers); + const cookie2 = headers2.get("Set-Cookie"); + expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires="); + expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict"); + }); + + test("should return auth cookie string", async () => { + const auth = new Authenticator({}, null as any, { + jwt: { + secret: "secret", + fields: [], + }, + cookie: { + sameSite: "strict", + }, + }); + const cookie = await auth.unsafeGetAuthCookie("token"); + expect(cookie).toStartWith("auth="); + expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict"); + }); +}); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 52a2e42..711099b 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -327,6 +327,31 @@ export class Authenticator< await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } + async getAuthCookieHeader(token: string, headers = new Headers()) { + const c = { + header: (key: string, value: string) => { + headers.set(key, value); + }, + }; + await this.setAuthCookie(c as any, token); + return headers; + } + + async removeAuthCookieHeader(headers = new Headers()) { + const c = { + header: (key: string, value: string) => { + headers.set(key, value); + }, + req: { + raw: { + headers, + }, + }, + }; + this.deleteAuthCookie(c as any); + return headers; + } + async unsafeGetAuthCookie(token: string): Promise { // this works for as long as cookieOptions.prefix is not set return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions); From 7344b1cf3db52a2f821b903ca25d5d88e722879c Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:29:03 +0200 Subject: [PATCH 3/5] feat: add media option to module to restrict body max size updated media schema to enforce strict validation, introduced `options` for AppMedia, and added a key prefix feature for StorageR2Adapter to enhance flexibility and control. --- app/src/adapter/cloudflare/storage/StorageR2Adapter.ts | 4 +++- app/src/media/AppMedia.ts | 3 +++ app/src/media/api/MediaController.ts | 5 ++++- app/src/media/media-schema.ts | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index e257b7c..7f1250a 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -49,6 +49,8 @@ export function registerMedia( * @todo: add tests (bun tests won't work, need node native tests) */ export class StorageR2Adapter extends StorageAdapter { + public keyPrefix: string = ""; + constructor(private readonly bucket: R2Bucket) { super(); } @@ -175,7 +177,7 @@ export class StorageR2Adapter extends StorageAdapter { } protected getKey(key: string) { - return key; + return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/"); } toJSON(secrets?: boolean) { diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 0971187..eb92d48 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -21,6 +21,9 @@ declare module "bknd" { // @todo: current workaround to make it all required export class AppMedia extends Module> { private _storage?: Storage; + options = { + body_max_size: null as number | null, + }; override async build() { if (!this.config.enabled) { diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 6a72048..81017aa 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -93,7 +93,10 @@ export class MediaController extends Controller { }, ); - const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; + const maxSize = + this.media.options.body_max_size ?? + this.getStorage().getConfig().body_max_size ?? + Number.POSITIVE_INFINITY; if (isDebug()) { hono.post( diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 4e71d83..eaa2b8d 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -48,7 +48,7 @@ export function buildMediaSchema() { { default: {}, }, - ); + ).strict(); } export const mediaConfigSchema = buildMediaSchema(); From 06d7558c3c451a99c6166dc0d07e67bc7c761207 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 14:48:45 +0200 Subject: [PATCH 4/5] feat: batch schema manager statements run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan. --- app/__test__/data/specs/SchemaManager.spec.ts | 37 ++++++++++++++- .../cloudflare-workers.adapter.spec.ts | 4 +- app/src/data/entities/EntityManager.ts | 7 +-- app/src/data/schema/SchemaManager.ts | 46 +++++++------------ 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/app/__test__/data/specs/SchemaManager.spec.ts b/app/__test__/data/specs/SchemaManager.spec.ts index 679010b..e35df15 100644 --- a/app/__test__/data/specs/SchemaManager.spec.ts +++ b/app/__test__/data/specs/SchemaManager.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line import/no-unresolved -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { randomString } from "core/utils"; import { Entity, EntityManager } from "data/entities"; import { TextField, EntityIndex } from "data/fields"; @@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => { const diffAfter = await em.schema().getDiff(); expect(diffAfter.length).toBe(0); }); + + test("returns statements", async () => { + const amount = 5; + const entities = new Array(amount) + .fill(0) + .map(() => new Entity(randomString(16), [new TextField("text")])); + const em = new EntityManager(entities, dummyConnection); + const statements = await em.schema().sync({ force: true }); + expect(statements.length).toBe(amount); + expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe( + true, + ); + }); + + test("batches statements", async () => { + const { dummyConnection } = getDummyConnection(); + const entities = new Array(20) + .fill(0) + .map(() => new Entity(randomString(16), [new TextField("text")])); + const em = new EntityManager(entities, dummyConnection); + const spy = spyOn(em.connection, "executeQueries"); + const statements = await em.schema().sync(); + expect(statements.length).toBe(entities.length); + expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe( + true, + ); + await em.schema().sync({ force: true }); + expect(spy).toHaveBeenCalledTimes(1); + const tables = await em.connection.kysely + .selectFrom("sqlite_master") + .where("type", "=", "table") + .selectAll() + .execute(); + expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */ + }); }); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 65477b6..6cb0f90 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite"; import { bunTestRunner } from "adapter/bun/test"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; -/* beforeAll(disableConsoleLog); -afterAll(enableConsoleLog); */ +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("cf adapter", () => { const DB_URL = ":memory:"; diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 36168f8..033d51a 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -34,7 +34,6 @@ export class EntityManager { private _entities: Entity[] = []; private _relations: EntityRelation[] = []; private _indices: EntityIndex[] = []; - private _schema?: SchemaManager; readonly emgr: EventManager; static readonly Events = { ...MutatorEvents, ...RepositoryEvents }; @@ -249,11 +248,7 @@ export class EntityManager { } schema() { - if (!this._schema) { - this._schema = new SchemaManager(this); - } - - return this._schema; + return new SchemaManager(this); } // @todo: centralize and add tests diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index ab2d15f..a0618f4 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -247,20 +247,16 @@ export class SchemaManager { async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) { const diff = await this.getDiff(); - let updates: number = 0; const statements: { sql: string; parameters: readonly unknown[] }[] = []; const schema = this.em.connection.kysely.schema; + const qbs: { compile(): CompiledQuery; execute(): Promise }[] = []; for (const table of diff) { - const qbs: { compile(): CompiledQuery; execute(): Promise }[] = []; - let local_updates: number = 0; const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add); const dropFields = table.columns.drop; const dropIndices = table.indices.drop; if (table.isDrop) { - updates++; - local_updates++; if (config.drop) { qbs.push(schema.dropTable(table.name)); } @@ -268,8 +264,6 @@ export class SchemaManager { let createQb = schema.createTable(table.name); // add fields for (const fieldSchema of addFieldSchemas) { - updates++; - local_updates++; // @ts-ignore createQb = createQb.addColumn(...fieldSchema); } @@ -280,8 +274,6 @@ export class SchemaManager { if (addFieldSchemas.length > 0) { // add fields for (const fieldSchema of addFieldSchemas) { - updates++; - local_updates++; // @ts-ignore qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema)); } @@ -291,8 +283,6 @@ export class SchemaManager { if (config.drop && dropFields.length > 0) { // drop fields for (const column of dropFields) { - updates++; - local_updates++; qbs.push(schema.alterTable(table.name).dropColumn(column)); } } @@ -310,35 +300,33 @@ export class SchemaManager { qb = qb.unique(); } qbs.push(qb); - local_updates++; - updates++; } // drop indices if (config.drop) { for (const index of dropIndices) { qbs.push(schema.dropIndex(index)); - local_updates++; - updates++; } } + } - if (local_updates === 0) continue; + if (qbs.length > 0) { + statements.push( + ...qbs.map((qb) => { + const { sql, parameters } = qb.compile(); + return { sql, parameters }; + }), + ); - // iterate through built qbs - // @todo: run in batches - for (const qb of qbs) { - const { sql, parameters } = qb.compile(); - statements.push({ sql, parameters }); + $console.debug( + "[SchemaManager]", + `${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`, + ); - if (config.force) { - try { - $console.debug("[SchemaManager]", sql); - await qb.execute(); - } catch (e) { - throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`); - } - } + try { + await this.em.connection.executeQueries(...qbs); + } catch (e) { + throw new Error(`Failed to execute batch: ${String(e)}`); } } From 1fdee8435d2dfc043c9415ea8aa3cbbcf6e7e4eb Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 29 Sep 2025 22:12:23 +0200 Subject: [PATCH 5/5] feat: add timestamps plugin to manage created_at and updated_at fields Introduced a new timestamps plugin that allows the addition of `created_at` and `updated_at` fields to specified entities. Included tests to verify functionality, ensuring timestamps are correctly set on entity creation and updates. Updated the plugin index to export the new timestamps functionality. --- app/src/plugins/data/timestamp.plugin.spec.ts | 74 ++++++++++++++++ app/src/plugins/data/timestamps.plugin.ts | 86 +++++++++++++++++++ app/src/plugins/index.ts | 1 + 3 files changed, 161 insertions(+) create mode 100644 app/src/plugins/data/timestamp.plugin.spec.ts create mode 100644 app/src/plugins/data/timestamps.plugin.ts diff --git a/app/src/plugins/data/timestamp.plugin.spec.ts b/app/src/plugins/data/timestamp.plugin.spec.ts new file mode 100644 index 0000000..bdf8811 --- /dev/null +++ b/app/src/plugins/data/timestamp.plugin.spec.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { timestamps } from "./timestamps.plugin"; +import { em, entity, text } from "bknd"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +describe("timestamps plugin", () => { + test("should ignore if no or invalid entities are provided", async () => { + const app = createApp({ + options: { + plugins: [timestamps({ entities: [] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual([]); + + { + const app = createApp({ + options: { + plugins: [timestamps({ entities: ["posts"] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual([]); + } + }); + + test("should add timestamps to the specified entities", async () => { + const app = createApp({ + config: { + data: em({ + posts: entity("posts", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [timestamps({ entities: ["posts", "invalid"] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]); + expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([ + "id", + "title", + "created_at", + "updated_at", + ]); + + // insert + const mutator = app.em.mutator(app.em.entity("posts")); + const { data } = await mutator.insertOne({ title: "Hello" }); + expect(data.created_at).toBeDefined(); + expect(data.updated_at).toBeDefined(); + expect(data.created_at).toBeInstanceOf(Date); + expect(data.updated_at).toBeInstanceOf(Date); + const diff = data.created_at.getTime() - data.updated_at.getTime(); + expect(diff).toBeLessThan(10); + expect(diff).toBeGreaterThan(-1); + + // update (set updated_at to null, otherwise it's too fast to test) + await app.em.connection.kysely + .updateTable("posts") + .set({ updated_at: null }) + .where("id", "=", data.id) + .execute(); + const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" }); + expect(updatedData.updated_at).toBeDefined(); + expect(updatedData.updated_at).toBeInstanceOf(Date); + }); +}); diff --git a/app/src/plugins/data/timestamps.plugin.ts b/app/src/plugins/data/timestamps.plugin.ts new file mode 100644 index 0000000..0de5a94 --- /dev/null +++ b/app/src/plugins/data/timestamps.plugin.ts @@ -0,0 +1,86 @@ +import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd"; +import { $console } from "bknd/utils"; + +export type TimestampsPluginOptions = { + entities: string[]; + setUpdatedOnCreate?: boolean; +}; + +/** + * This plugin adds `created_at` and `updated_at` fields to the specified entities. + * Add it to your plugins in `bknd.config.ts` like this: + * + * ```ts + * export default { + * plugins: [timestamps({ entities: ["posts"] })], + * } + * ``` + */ +export function timestamps({ + entities = [], + setUpdatedOnCreate = true, +}: TimestampsPluginOptions): AppPlugin { + return (app: App) => ({ + name: "timestamps", + schema: () => { + if (entities.length === 0) { + $console.warn("No entities specified for timestamps plugin"); + return; + } + + const appEntities = app.em.entities.map((e) => e.name); + + return em( + Object.fromEntries( + entities + .filter((e) => appEntities.includes(e)) + .map((e) => [ + e, + entity(e, { + created_at: datetime(), + updated_at: datetime(), + }), + ]), + ), + ); + }, + onBuilt: async () => { + app.emgr.onEvent( + DatabaseEvents.MutatorInsertBefore, + (event) => { + const { entity, data } = event.params; + if (entities.includes(entity.name)) { + return { + ...data, + created_at: new Date(), + updated_at: setUpdatedOnCreate ? new Date() : null, + }; + } + return data; + }, + { + mode: "sync", + id: "bknd-timestamps", + }, + ); + + app.emgr.onEvent( + DatabaseEvents.MutatorUpdateBefore, + async (event) => { + const { entity, data } = event.params; + if (entities.includes(entity.name)) { + return { + ...data, + updated_at: new Date(), + }; + } + return data; + }, + { + mode: "sync", + id: "bknd-timestamps", + }, + ); + }, + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 45db2d5..b0090ff 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin"; 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";