From 0629e1bc5083bcfa2f10776065cb7c763c2a6e0f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:24:37 +0200 Subject: [PATCH 01/48] 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 3721a2bc..726748ba 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 02/48] 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 0794528e..fcf8e5cb 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 52a2e42e..711099ba 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 03/48] 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 e257b7c0..7f1250a6 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 0971187a..eb92d484 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 6a720485..81017aaf 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 4e71d83a..eaa2b8d5 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 04/48] 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 679010ba..e35df157 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 65477b6f..6cb0f901 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 36168f8b..033d51a5 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 ab2d15fa..a0618f4a 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 05/48] 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 00000000..bdf8811d --- /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 00000000..0de5a94e --- /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 45db2d59..b0090ffc 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"; From d6dcfe3acc61ee368b78326e4e6bd6f92befaf9f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 1 Oct 2025 09:46:16 +0200 Subject: [PATCH 06/48] feat: implement file acceptance validation in utils and integrate with Dropzone component --- app/__test__/core/utils.spec.ts | 29 +++++++++++++++++ app/src/core/utils/file.ts | 43 ++++++++++++++++++++++++++ app/src/ui/elements/media/Dropzone.tsx | 10 +++--- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index 36b4969f..66d289e2 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -264,6 +264,35 @@ describe("Core Utils", async () => { height: 512, }); }); + + test("isFileAccepted", () => { + const file = new File([""], "file.txt", { + type: "text/plain", + }); + expect(utils.isFileAccepted(file, "text/plain")).toBe(true); + expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true); + expect(utils.isFileAccepted(file, "text/html")).toBe(false); + + { + const file = new File([""], "file.jpg", { + type: "image/jpeg", + }); + expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true); + expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true); + expect(utils.isFileAccepted(file, "image/png")).toBe(false); + expect(utils.isFileAccepted(file, "image/*")).toBe(true); + expect(utils.isFileAccepted(file, ".jpg")).toBe(true); + expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true); + expect(utils.isFileAccepted(file, ".png")).toBe(false); + } + + { + const file = new File([""], "file.png"); + expect(utils.isFileAccepted(file, undefined as any)).toBe(true); + } + + expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow(); + }); }); describe("dates", () => { diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index 8e812cf9..a2093c01 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -240,3 +240,46 @@ export async function blobToFile( lastModified: Date.now(), }); } + +export function isFileAccepted(file: File | unknown, _accept: string | string[]): boolean { + const accept = Array.isArray(_accept) ? _accept.join(",") : _accept; + if (!accept || !accept.trim()) return true; // no restrictions + if (!isFile(file)) { + throw new Error("Given file is not a File instance"); + } + + const name = file.name.toLowerCase(); + const type = (file.type || "").trim().toLowerCase(); + + // split on commas, trim whitespace + const tokens = accept + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + + // try each token until one matches + return tokens.some((token) => { + if (token.startsWith(".")) { + // extension match, e.g. ".png" or ".tar.gz" + return name.endsWith(token); + } + + const slashIdx = token.indexOf("/"); + if (slashIdx !== -1) { + const [major, minor] = token.split("/"); + if (minor === "*") { + // wildcard like "image/*" + if (!type) return false; + const [fMajor] = type.split("/"); + return fMajor === major; + } else { + // exact MIME like "image/svg+xml" or "application/pdf" + // because of "text/plain;charset=utf-8" + return type.startsWith(token); + } + } + + // unknown token shape, ignore + return false; + }); +} diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index ffaa5df9..b7cb384c 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -9,8 +9,8 @@ import { useEffect, useMemo, useRef, - useState, } from "react"; +import { isFileAccepted } from "bknd/utils"; import { type FileWithPath, useDropzone } from "./use-dropzone"; import { checkMaxReached } from "./helper"; import { DropzoneInner } from "./DropzoneInner"; @@ -173,12 +173,14 @@ export function Dropzone({ return specs.every((spec) => { if (spec.kind !== "file") { - console.log("not a file", spec.kind); + console.warn("file not accepted: not a file", spec.kind); return false; } if (allowedMimeTypes && allowedMimeTypes.length > 0) { - console.log("not allowed mimetype", spec.type); - return allowedMimeTypes.includes(spec.type); + if (!isFileAccepted(i, allowedMimeTypes)) { + console.warn("file not accepted: not allowed mimetype", spec.type); + return false; + } } return true; }); From 90f93caff4ca7919e11f1bf07c20fda1ad06cd17 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 3 Oct 2025 20:22:42 +0200 Subject: [PATCH 07/48] refactor: enhance permission handling and introduce new Permission and Policy classes - Updated the `Guard` class to improve permission checking by utilizing the new `Permission` class. - Refactored tests in `authorize.spec.ts` to use `Permission` instances instead of strings for better type safety. - Introduced a new `permissions.spec.ts` file to test the functionality of the `Permission` and `Policy` classes. - Enhanced the `recursivelyReplacePlaceholders` utility function to support various object structures and types. - Updated middleware and controller files to align with the new permission handling structure. --- app/__test__/auth/authorize/authorize.spec.ts | 32 ++--- .../auth/authorize/permissions.spec.ts | 93 +++++++++++++++ app/__test__/core/utils.spec.ts | 110 ++++++++++++++++++ app/src/auth/api/AuthController.ts | 3 +- app/src/auth/authorize/Guard.ts | 25 +++- app/src/auth/middlewares.ts | 20 ++-- app/src/core/security/Permission.ts | 90 +++++++++++++- app/src/core/utils/objects.ts | 35 ++++++ app/src/core/utils/schema/index.ts | 2 + app/src/media/api/MediaController.ts | 3 +- app/src/modules/ModuleHelper.ts | 2 +- app/src/modules/permissions/index.ts | 25 +++- app/src/modules/server/AdminController.tsx | 21 ++-- app/src/modules/server/SystemController.ts | 22 +++- 14 files changed, 432 insertions(+), 51 deletions(-) create mode 100644 app/__test__/auth/authorize/permissions.spec.ts diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index c0e04ff9..5510e730 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { Guard } from "../../../src/auth/authorize/Guard"; +import { Guard } from "auth/authorize/Guard"; +import { Permission } from "core/security/Permission"; describe("authorize", () => { + const read = new Permission("read"); + const write = new Permission("write"); + test("basic", async () => { const guard = Guard.create( ["read", "write"], @@ -16,10 +20,10 @@ describe("authorize", () => { role: "admin", }; - expect(guard.granted("read", user)).toBe(true); - expect(guard.granted("write", user)).toBe(true); + expect(guard.granted(read, user)).toBe(true); + expect(guard.granted(write, user)).toBe(true); - expect(() => guard.granted("something")).toThrow(); + expect(() => guard.granted(new Permission("something"))).toThrow(); }); test("with default", async () => { @@ -37,22 +41,22 @@ describe("authorize", () => { { enabled: true }, ); - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(false); + expect(guard.granted(read)).toBe(true); + expect(guard.granted(write)).toBe(false); const user = { role: "admin", }; - expect(guard.granted("read", user)).toBe(true); - expect(guard.granted("write", user)).toBe(true); + expect(guard.granted(read, user)).toBe(true); + expect(guard.granted(write, user)).toBe(true); }); test("guard implicit allow", async () => { const guard = Guard.create([], {}, { enabled: false }); - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted(read)).toBe(true); + expect(guard.granted(write)).toBe(true); }); test("role implicit allow", async () => { @@ -66,8 +70,8 @@ describe("authorize", () => { role: "admin", }; - expect(guard.granted("read", user)).toBe(true); - expect(guard.granted("write", user)).toBe(true); + expect(guard.granted(read, user)).toBe(true); + expect(guard.granted(write, user)).toBe(true); }); test("guard with guest role implicit allow", async () => { @@ -79,7 +83,7 @@ describe("authorize", () => { }); expect(guard.getUserRole()?.name).toBe("guest"); - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted(read)).toBe(true); + expect(guard.granted(write)).toBe(true); }); }); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts new file mode 100644 index 00000000..c6e58fb2 --- /dev/null +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "bun:test"; +import { s } from "bknd/utils"; +import { Permission, Policy } from "core/security/Permission"; + +describe("Permission", () => { + it("works with minimal schema", () => { + expect(() => new Permission("test")).not.toThrow(); + }); + + it("parses context", () => { + const p = new Permission( + "test3", + { + filterable: true, + }, + s.object({ + a: s.string(), + }), + ); + + // @ts-expect-error + expect(() => p.parseContext({ a: [] })).toThrow(); + expect(p.parseContext({ a: "test" })).toEqual({ a: "test" }); + // @ts-expect-error + expect(p.parseContext({ a: 1 })).toEqual({ a: "1" }); + }); +}); + +describe("Policy", () => { + it("works with minimal schema", () => { + expect(() => new Policy().toJSON()).not.toThrow(); + }); + + it("checks condition", () => { + const p = new Policy({ + condition: { + a: 1, + }, + }); + + expect(p.meetsCondition({ a: 1 })).toBe(true); + expect(p.meetsCondition({ a: 2 })).toBe(false); + expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true); + expect(p.meetsCondition({})).toBe(false); + + const p2 = new Policy({ + condition: { + a: { $gt: 1 }, + $or: { + b: { $lt: 2 }, + }, + }, + }); + + expect(p2.meetsCondition({ a: 2 })).toBe(true); + expect(p2.meetsCondition({ a: 1 })).toBe(false); + expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true); + }); + + it("filters", () => { + const p = new Policy({ + filter: { + age: { $gt: 18 }, + }, + }); + const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }]; + + expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]); + + expect(p.meetsFilter({ age: 19 })).toBe(true); + expect(p.meetsFilter({ age: 17 })).toBe(false); + expect(p.meetsFilter({ age: 12 })).toBe(false); + }); + + it("replaces placeholders", () => { + const p = new Policy({ + condition: { + a: "@auth.username", + }, + filter: { + a: "@auth.username", + }, + }); + const vars = { auth: { username: "test" } }; + + expect(p.meetsCondition({ a: "test" }, vars)).toBe(true); + expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false); + expect(p.meetsCondition({ a: "test2" })).toBe(false); + expect(p.meetsFilter({ a: "test" }, vars)).toBe(true); + expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false); + expect(p.meetsFilter({ a: "test2" })).toBe(false); + }); +}); diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index 36b4969f..b7d4c96f 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -194,6 +194,116 @@ describe("Core Utils", async () => { expect(result).toEqual(expected); } }); + + test("recursivelyReplacePlaceholders", () => { + // test basic replacement with simple pattern + const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } }; + const variables1 = { name: "John" }; + const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1); + expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } }); + + // test the specific example from the user request + const obj2 = { some: "value", here: "@auth.user" }; + const variables2 = { auth: { user: "what" } }; + const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2); + expect(result2).toEqual({ some: "value", here: "what" }); + + // test with arrays + const obj3 = { items: ["@config.name", "static", "@config.version"] }; + const variables3 = { config: { name: "MyApp", version: "1.0.0" } }; + const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3); + expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] }); + + // test with nested objects and deep paths + const obj4 = { + user: "@auth.user.name", + settings: { + theme: "@ui.theme", + nested: { + value: "@deep.nested.value", + }, + }, + }; + const variables4 = { + auth: { user: { name: "Alice" } }, + ui: { theme: "dark" }, + deep: { nested: { value: "found" } }, + }; + const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4); + expect(result4).toEqual({ + user: "Alice", + settings: { + theme: "dark", + nested: { + value: "found", + }, + }, + }); + + // test with missing paths (should return original match) + const obj5 = { value: "@missing.path" }; + const variables5 = { existing: "value" }; + const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5); + expect(result5).toEqual({ value: "@missing.path" }); + + // test with non-matching strings (should remain unchanged) + const obj6 = { value: "normal string", other: "not@matching" }; + const variables6 = { some: "value" }; + const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6); + expect(result6).toEqual({ value: "normal string", other: "not@matching" }); + + // test with primitive values (should handle gracefully) + expect( + utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, { + test: { value: "replaced" }, + }), + ).toBe("replaced"); + expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123); + expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null); + + // test type preservation for full string matches + const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } }; + const result7 = utils.recursivelyReplacePlaceholders( + { + number: "@test.value", + boolean: "@test.flag", + nullValue: "@test.data", + array: "@test.arr", + }, + /^@([a-z\.]+)$/, + variables7, + ); + expect(result7).toEqual({ + number: 123, + boolean: true, + nullValue: null, + array: [1, 2, 3], + }); + + // test partial string replacement (should convert to string) + const result8 = utils.recursivelyReplacePlaceholders( + { message: "The value is @test.value!" }, + /@([a-z\.]+)/g, + variables7, + ); + expect(result8).toEqual({ message: "The value is 123!" }); + + // test mixed scenarios + const result9 = utils.recursivelyReplacePlaceholders( + { + fullMatch: "@test.value", // should preserve number type + partialMatch: "Value: @test.value", // should convert to string + noMatch: "static text", + }, + /^@([a-z\.]+)$/, + variables7, + ); + expect(result9).toEqual({ + fullMatch: 123, // number preserved + partialMatch: "Value: @test.value", // no replacement (pattern requires full match) + noMatch: "static text", + }); + }); }); describe("file", async () => { diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index ba12d4a2..15448beb 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -60,7 +60,8 @@ export class AuthController extends Controller { if (create) { hono.post( "/create", - permission([AuthPermissions.createUser, DataPermissions.entityCreate]), + permission(AuthPermissions.createUser), + permission(DataPermissions.entityCreate), describeRoute({ summary: "Create a new user", tags: ["auth"], diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index a89b98dc..5576d4bd 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,5 +1,5 @@ import { Exception } from "core/errors"; -import { $console, objectTransform } from "bknd/utils"; +import { $console, objectTransform, type s } from "bknd/utils"; import { Permission } from "core/security/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; @@ -12,6 +12,7 @@ export type GuardUserContext = { export type GuardConfig = { enabled?: boolean; + context?: string; }; export type GuardContext = Context | GuardUserContext; @@ -26,6 +27,9 @@ export class Guard { this.config = config; } + /** + * @deprecated + */ static create( permissionNames: string[], roles?: Record< @@ -156,12 +160,25 @@ export class Guard { return !!rolePermission; } - granted(permission: Permission | string, c?: GuardContext): boolean { + granted

( + permission: P, + c?: GuardContext, + context: s.Static = {} as s.Static, + ): boolean { const user = c && "get" in c ? c.get("auth")?.user : c; - return this.hasPermission(permission as any, user); + const ctx = { + ...context, + user, + context: this.config?.context, + }; + return this.hasPermission(permission, user); } - throwUnlessGranted(permission: Permission | string, c: GuardContext) { + throwUnlessGranted

( + permission: P, + c: GuardContext, + context: s.Static, + ) { if (!this.granted(permission, c)) { throw new Exception( `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index 702023b9..685a9bd5 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -1,8 +1,9 @@ import type { Permission } from "core/security/Permission"; -import { $console, patternMatch } from "bknd/utils"; +import { $console, patternMatch, type s } from "bknd/utils"; import type { Context } from "hono"; import { createMiddleware } from "hono/factory"; import type { ServerEnv } from "modules/Controller"; +import type { MaybePromise } from "core/types"; function getPath(reqOrCtx: Request | Context) { const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; @@ -49,7 +50,7 @@ export const auth = (options?: { // make sure to only register once if (authCtx.registered) { skipped = true; - $console.warn(`auth middleware already registered for ${getPath(c)}`); + $console.debug(`auth middleware already registered for ${getPath(c)}`); } else { authCtx.registered = true; @@ -68,11 +69,12 @@ export const auth = (options?: { authCtx.user = undefined; }); -export const permission = ( - permission: Permission | Permission[], +export const permission =

( + permission: P, options?: { - onGranted?: (c: Context) => Promise; - onDenied?: (c: Context) => Promise; + onGranted?: (c: Context) => MaybePromise; + onDenied?: (c: Context) => MaybePromise; + context?: (c: Context) => MaybePromise>; }, ) => // @ts-ignore @@ -93,11 +95,11 @@ export const permission = ( } } else if (!authCtx.skip) { const guard = app.modules.ctx().guard; - const permissions = Array.isArray(permission) ? permission : [permission]; + const context = (await options?.context?.(c)) ?? ({} as any); if (options?.onGranted || options?.onDenied) { let returned: undefined | void | Response; - if (permissions.every((p) => guard.granted(p, c))) { + if (guard.granted(permission, c, context)) { returned = await options?.onGranted?.(c); } else { returned = await options?.onDenied?.(c); @@ -106,7 +108,7 @@ export const permission = ( return returned; } } else { - permissions.some((p) => guard.throwUnlessGranted(p, c)); + guard.throwUnlessGranted(permission, c, context); } } diff --git a/app/src/core/security/Permission.ts b/app/src/core/security/Permission.ts index 86cf46b1..5ca4d849 100644 --- a/app/src/core/security/Permission.ts +++ b/app/src/core/security/Permission.ts @@ -1,11 +1,95 @@ -export class Permission { - constructor(public name: Name) { - this.name = name; +import { + s, + type ParseOptions, + parse, + InvalidSchemaError, + recursivelyReplacePlaceholders, +} from "bknd/utils"; +import * as query from "core/object/query/object-query"; + +export const permissionOptionsSchema = s + .strictObject({ + description: s.string(), + filterable: s.boolean(), + }) + .partial(); + +export type PermissionOptions = s.Static; + +export class InvalidPermissionContextError extends InvalidSchemaError { + override name = "InvalidPermissionContextError"; + + static from(e: InvalidSchemaError) { + return new InvalidPermissionContextError(e.schema, e.value, e.errors); + } +} + +export class Permission< + Name extends string = string, + Options extends PermissionOptions = {}, + Context extends s.ObjectSchema = s.ObjectSchema, +> { + constructor( + public name: Name, + public options: Options = {} as Options, + public context: Context = s.object({}) as Context, + ) {} + + parseContext(ctx: s.Static, opts?: ParseOptions) { + try { + return parse(this.context, ctx, opts); + } catch (e) { + if (e instanceof InvalidSchemaError) { + throw InvalidPermissionContextError.from(e); + } + + throw e; + } } toJSON() { return { name: this.name, + ...this.options, + context: this.context, }; } } + +export const policySchema = s + .strictObject({ + description: s.string(), + condition: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>, + effect: s.string({ enum: ["allow", "deny", "filter"], default: "deny" }), + filter: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>, + }) + .partial(); +export type PolicySchema = s.Static; + +export class Policy { + public content: Schema; + + constructor(content?: Schema) { + this.content = parse(policySchema, content ?? {}) as Schema; + } + + replace(context: object, vars?: Record) { + return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; + } + + meetsCondition(context: object, vars?: Record) { + return query.validate(this.replace(this.content.condition!, vars), context); + } + + meetsFilter(subject: object, vars?: Record) { + return query.validate(this.replace(this.content.filter!, vars), subject); + } + + getFiltered(given: Given): Given { + return given.filter((item) => this.meetsFilter(item)) as Given; + } + + toJSON() { + return this.content; + } +} diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 41902a92..65f18f91 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -512,3 +512,38 @@ export function convertNumberedObjectToArray(obj: object): any[] | object { } return obj; } + +export function recursivelyReplacePlaceholders( + obj: any, + pattern: RegExp, + variables: Record, +) { + if (typeof obj === "string") { + // check if the entire string matches the pattern + const match = obj.match(pattern); + if (match && match[0] === obj && match[1]) { + // full string match - replace with the actual value (preserving type) + const key = match[1]; + const value = getPath(variables, key); + return value !== undefined ? value : obj; + } + // partial match - use string replacement + if (pattern.test(obj)) { + return obj.replace(pattern, (match, key) => { + const value = getPath(variables, key); + // convert to string for partial replacements + return value !== undefined ? String(value) : match; + }); + } + } + if (Array.isArray(obj)) { + return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables)); + } + if (obj && typeof obj === "object") { + return Object.entries(obj).reduce((acc, [key, value]) => { + acc[key] = recursivelyReplacePlaceholders(value, pattern, variables); + return acc; + }, {} as object); + } + return obj; +} diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 3d3692c7..d30aae1e 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -59,6 +59,8 @@ export const stringIdentifier = s.string({ }); export class InvalidSchemaError extends Error { + override name = "InvalidSchemaError"; + constructor( public schema: s.Schema, public value: unknown, diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 6a720485..e20fa2e2 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -186,7 +186,8 @@ export class MediaController extends Controller { }), ), jsc("query", s.object({ overwrite: s.boolean().optional() })), - permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), + permission(DataPermissions.entityCreate), + permission(MediaPermissions.uploadFile), async (c) => { const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param"); diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 60a6dfce..d6710656 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -115,7 +115,7 @@ export class ModuleHelper { } async throwUnlessGranted( - permission: Permission | string, + permission: Permission, c: { context: ModuleBuildContextMcpContext; raw?: unknown }, ) { invariant(c.context.app, "app is not available in mcp context"); diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index b6fbead8..5f5ccd1e 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,10 +1,29 @@ import { Permission } from "core/security/Permission"; +import { s } from "bknd/utils"; export const accessAdmin = new Permission("system.access.admin"); export const accessApi = new Permission("system.access.api"); -export const configRead = new Permission("system.config.read"); -export const configReadSecrets = new Permission("system.config.read.secrets"); -export const configWrite = new Permission("system.config.write"); +export const configRead = new Permission( + "system.config.read", + {}, + s.object({ + module: s.string().optional(), + }), +); +export const configReadSecrets = new Permission( + "system.config.read.secrets", + {}, + s.object({ + module: s.string().optional(), + }), +); +export const configWrite = new Permission( + "system.config.write", + {}, + s.object({ + module: s.string().optional(), + }), +); export const schemaRead = new Permission("system.schema.read"); export const build = new Permission("system.build"); export const mcp = new Permission("system.mcp"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 28007813..2cdb2a7a 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -139,17 +139,18 @@ export class AdminController extends Controller { } if (auth_enabled) { + const options = { + onGranted: async (c) => { + // @todo: add strict test to permissions middleware? + if (c.get("auth")?.user) { + $console.log("redirecting to success"); + return c.redirect(authRoutes.success); + } + }, + }; const redirectRouteParams = [ - permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { - // @ts-ignore - onGranted: async (c) => { - // @todo: add strict test to permissions middleware? - if (c.get("auth")?.user) { - $console.log("redirecting to success"); - return c.redirect(authRoutes.success); - } - }, - }), + permission(SystemPermissions.accessAdmin, options), + permission(SystemPermissions.schemaRead, options), async (c) => { return c.html(c.get("html")!); }, diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 93533a29..43d688e5 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -130,7 +130,7 @@ export class SystemController extends Controller { summary: "Get the raw config", tags: ["system"], }), - permission([SystemPermissions.configReadSecrets]), + permission(SystemPermissions.configReadSecrets), async (c) => { // @ts-expect-error "fetch" is private return c.json(await this.app.modules.fetch().then((r) => r?.configs)); @@ -295,7 +295,11 @@ export class SystemController extends Controller { const { secrets } = c.req.valid("query"); const { module } = c.req.valid("param"); - secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); + if (secrets) { + this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { + module, + }); + } const config = this.app.toJSON(secrets); @@ -342,8 +346,16 @@ export class SystemController extends Controller { const { config, secrets, fresh } = c.req.valid("query"); const readonly = this.app.isReadOnly(); - config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c); - secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); + if (config) { + this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, { + module, + }); + } + if (secrets) { + this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { + module, + }); + } const { version, ...schema } = this.app.getSchema(); @@ -383,7 +395,7 @@ export class SystemController extends Controller { jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })), async (c) => { const options = c.req.valid("query") as Record; - this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); + this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c, {}); await this.app.build(options); return c.json({ From 0e870cda81856776110241dc724d6e06ced3302c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Oct 2025 22:09:03 +0200 Subject: [PATCH 08/48] Bump version --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c946b6a9..5159e77c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app # define bknd version to be used as: # `docker build --build-arg VERSION= -t bknd .` -ARG VERSION=0.17.1 +ARG VERSION=0.18.0 # Install & copy required cli RUN npm install --omit=dev bknd@${VERSION} From 5377ac1a4181699bbdf6a6e2d80fa70ebe80b90c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Oct 2025 22:41:43 +0200 Subject: [PATCH 09/48] Update docker builder --- docker/Dockerfile | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5159e77c..71c27161 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,5 @@ # Stage 1: Build stage FROM node:24 as builder - WORKDIR /app # define bknd version to be used as: @@ -9,7 +8,6 @@ ARG VERSION=0.18.0 # Install & copy required cli RUN npm install --omit=dev bknd@${VERSION} -RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist # Stage 2: Final minimal image FROM node:24-alpine @@ -19,14 +17,14 @@ WORKDIR /app # Install required dependencies RUN npm install -g pm2 RUN echo '{"type":"module"}' > package.json -RUN npm install jsonv-ts @libsql/client + +# Copy dist and node_modules from builder +COPY --from=builder /app/node_modules/bknd/dist ./dist +COPY --from=builder /app/node_modules ./node_modules # Create volume and init args VOLUME /data ENV DEFAULT_ARGS="--db-url file:/data/data.db" -# Copy output from builder -COPY --from=builder /output/dist ./dist - EXPOSE 1337 CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"] From 2f88c2216cd39350d48ef9f992be578553f4a645 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 13 Oct 2025 18:20:46 +0200 Subject: [PATCH 10/48] refactor: restructure permission handling and enhance Guard functionality - Introduced a new `createGuard` function to streamline the creation of Guard instances with permissions and roles. - Updated tests in `authorize.spec.ts` to reflect changes in permission checks, ensuring they now return undefined for denied permissions. - Added new `Permission` and `Policy` classes to improve type safety and flexibility in permission management. - Refactored middleware and controller files to utilize the updated permission structure, including context handling for permissions. - Created a new `SystemController.spec.ts` file to test the integration of the new permission system within the SystemController. - Removed legacy permission handling from core security files, consolidating permission logic within the new structure. --- .../auth/authorize/SystemController.spec.ts | 20 ++ app/__test__/auth/authorize/authorize.spec.ts | 63 ++-- .../auth/authorize/permissions.spec.ts | 337 +++++++++++++++++- .../integration/auth.integration.test.ts | 2 +- app/src/auth/api/AuthController.ts | 12 +- app/src/auth/auth-permissions.ts | 2 +- app/src/auth/authorize/Guard.ts | 250 ++++++++----- app/src/auth/authorize/Permission.ts | 68 ++++ app/src/auth/authorize/Policy.ts | 42 +++ app/src/auth/authorize/Role.ts | 70 ++-- .../auth.middleware.ts} | 50 +-- .../auth/middlewares/permission.middleware.ts | 93 +++++ app/src/core/security/Permission.ts | 95 ----- app/src/core/utils/runtime.ts | 9 + app/src/core/utils/schema/index.ts | 5 +- app/src/data/api/DataController.ts | 32 +- app/src/data/permissions/index.ts | 2 +- app/src/index.ts | 2 +- app/src/media/api/MediaController.ts | 12 +- app/src/media/media-permissions.ts | 2 +- app/src/modules/ModuleHelper.ts | 26 +- app/src/modules/middlewares/index.ts | 3 +- app/src/modules/permissions/index.ts | 10 +- app/src/modules/server/AdminController.tsx | 4 +- app/src/modules/server/AppServer.ts | 4 + app/src/modules/server/SystemController.ts | 106 ++++-- 26 files changed, 954 insertions(+), 367 deletions(-) create mode 100644 app/__test__/auth/authorize/SystemController.spec.ts create mode 100644 app/src/auth/authorize/Permission.ts create mode 100644 app/src/auth/authorize/Policy.ts rename app/src/auth/{middlewares.ts => middlewares/auth.middleware.ts} (52%) create mode 100644 app/src/auth/middlewares/permission.middleware.ts delete mode 100644 app/src/core/security/Permission.ts diff --git a/app/__test__/auth/authorize/SystemController.spec.ts b/app/__test__/auth/authorize/SystemController.spec.ts new file mode 100644 index 00000000..8400f461 --- /dev/null +++ b/app/__test__/auth/authorize/SystemController.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "bun:test"; +import { SystemController } from "modules/server/SystemController"; +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import { getPermissionRoutes } from "auth/middlewares/permission.middleware"; + +async function makeApp(config: Partial = {}) { + const app = createApp(config); + await app.build(); + return app; +} + +describe("SystemController", () => { + it("...", async () => { + const app = await makeApp(); + const controller = new SystemController(app); + const hono = controller.getController(); + console.log(getPermissionRoutes(hono)); + }); +}); diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index 5510e730..fbf787f5 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,13 +1,36 @@ import { describe, expect, test } from "bun:test"; -import { Guard } from "auth/authorize/Guard"; -import { Permission } from "core/security/Permission"; +import { Guard, type GuardConfig } from "auth/authorize/Guard"; +import { Permission } from "auth/authorize/Permission"; +import { Role } from "auth/authorize/Role"; +import { objectTransform } from "bknd/utils"; + +function createGuard( + permissionNames: string[], + roles?: Record< + string, + { + permissions?: string[]; + is_default?: boolean; + implicit_allow?: boolean; + } + >, + config?: GuardConfig, +) { + const _roles = roles + ? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => { + return Role.create({ name, permissions, is_default, implicit_allow }); + }) + : {}; + const _permissions = permissionNames.map((name) => new Permission(name)); + return new Guard(_permissions, Object.values(_roles), config); +} describe("authorize", () => { const read = new Permission("read"); const write = new Permission("write"); test("basic", async () => { - const guard = Guard.create( + const guard = createGuard( ["read", "write"], { admin: { @@ -20,14 +43,14 @@ describe("authorize", () => { role: "admin", }; - expect(guard.granted(read, user)).toBe(true); - expect(guard.granted(write, user)).toBe(true); + expect(guard.granted(read, user)).toBeUndefined(); + expect(guard.granted(write, user)).toBeUndefined(); - expect(() => guard.granted(new Permission("something"))).toThrow(); + expect(() => guard.granted(new Permission("something"), {})).toThrow(); }); test("with default", async () => { - const guard = Guard.create( + const guard = createGuard( ["read", "write"], { admin: { @@ -41,26 +64,26 @@ describe("authorize", () => { { enabled: true }, ); - expect(guard.granted(read)).toBe(true); - expect(guard.granted(write)).toBe(false); + expect(guard.granted(read, {})).toBeUndefined(); + expect(() => guard.granted(write, {})).toThrow(); const user = { role: "admin", }; - expect(guard.granted(read, user)).toBe(true); - expect(guard.granted(write, user)).toBe(true); + expect(guard.granted(read, user)).toBeUndefined(); + expect(guard.granted(write, user)).toBeUndefined(); }); test("guard implicit allow", async () => { - const guard = Guard.create([], {}, { enabled: false }); + const guard = createGuard([], {}, { enabled: false }); - expect(guard.granted(read)).toBe(true); - expect(guard.granted(write)).toBe(true); + expect(guard.granted(read, {})).toBeUndefined(); + expect(guard.granted(write, {})).toBeUndefined(); }); test("role implicit allow", async () => { - const guard = Guard.create(["read", "write"], { + const guard = createGuard(["read", "write"], { admin: { implicit_allow: true, }, @@ -70,12 +93,12 @@ describe("authorize", () => { role: "admin", }; - expect(guard.granted(read, user)).toBe(true); - expect(guard.granted(write, user)).toBe(true); + expect(guard.granted(read, user)).toBeUndefined(); + expect(guard.granted(write, user)).toBeUndefined(); }); test("guard with guest role implicit allow", async () => { - const guard = Guard.create(["read", "write"], { + const guard = createGuard(["read", "write"], { guest: { implicit_allow: true, is_default: true, @@ -83,7 +106,7 @@ describe("authorize", () => { }); expect(guard.getUserRole()?.name).toBe("guest"); - expect(guard.granted(read)).toBe(true); - expect(guard.granted(write)).toBe(true); + expect(guard.granted(read, {})).toBeUndefined(); + expect(guard.granted(write, {})).toBeUndefined(); }); }); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index c6e58fb2..2b3a5ced 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "bun:test"; import { s } from "bknd/utils"; -import { Permission, Policy } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; +import { Policy } from "auth/authorize/Policy"; +import { Hono } from "hono"; +import { permission } from "auth/middlewares/permission.middleware"; +import { auth } from "auth/middlewares/auth.middleware"; +import { Guard, type GuardConfig } from "auth/authorize/Guard"; +import { Role, RolePermission } from "auth/authorize/Role"; +import { Exception } from "bknd"; describe("Permission", () => { it("works with minimal schema", () => { @@ -91,3 +98,331 @@ describe("Policy", () => { expect(p.meetsFilter({ a: "test2" })).toBe(false); }); }); + +describe("Guard", () => { + it("collects filters", () => { + const p = new Permission( + "test", + { + filterable: true, + }, + s.object({ + a: s.number(), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + filter: { a: { $eq: 1 } }, + effect: "filter", + }), + ]), + ]); + const guard = new Guard([p], [r], { + enabled: true, + }); + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 1 }, + ), + ).toEqual({ a: { $eq: 1 } }); + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 2 }, + ), + ).toBeUndefined(); + // if no user context given, filter cannot be applied + expect(guard.getPolicyFilter(p, {}, { a: 1 })).toBeUndefined(); + }); + + it("collects filters for default role", () => { + const p = new Permission( + "test", + { + filterable: true, + }, + s.object({ + a: s.number(), + }), + ); + const r = new Role( + "test", + [ + new RolePermission(p, [ + new Policy({ + filter: { a: { $eq: 1 } }, + effect: "filter", + }), + ]), + ], + true, + ); + const guard = new Guard([p], [r], { + enabled: true, + }); + + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 1 }, + ), + ).toEqual({ a: { $eq: 1 } }); + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 2 }, + ), + ).toBeUndefined(); + // if no user context given, the default role is applied + // hence it can be found + expect(guard.getPolicyFilter(p, {}, { a: 1 })).toEqual({ a: { $eq: 1 } }); + }); +}); + +describe("permission middleware", () => { + const makeApp = ( + permissions: Permission[], + roles: Role[] = [], + config: Partial = {}, + ) => { + const app = { + module: { + auth: { + enabled: true, + }, + }, + modules: { + ctx: () => ({ + guard: new Guard(permissions, roles, { + enabled: true, + ...config, + }), + }), + }, + }; + return new Hono() + .use(async (c, next) => { + // @ts-expect-error + c.set("app", app); + await next(); + }) + .use(auth()) + .onError((err, c) => { + if (err instanceof Exception) { + return c.json(err.toJSON(), err.code as any); + } + return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500); + }); + }; + + it("allows if guard is disabled", async () => { + const p = new Permission("test"); + const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) => + c.text("test"), + ); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("test"); + }); + + it("denies if guard is enabled", async () => { + const p = new Permission("test"); + const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(403); + }); + + it("allows if user has (plain) role", async () => { + const p = new Permission("test"); + const r = Role.create({ name: "test", permissions: [p.name] }); + const hono = makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows if user has role with policy", async () => { + const p = new Permission("test"); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $gte: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r], { + context: { + a: 1, + }, + }) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + }); + + it("denies if user with role doesn't meet condition", async () => { + const p = new Permission("test"); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $lt: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r], { + context: { + a: 1, + }, + }) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(403); + }); + + it("allows if user with role doesn't meet condition (from middleware)", async () => { + const p = new Permission( + "test", + {}, + s.object({ + a: s.number(), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get( + "/test", + permission(p, { + context: (c) => ({ + a: 1, + }), + }), + async (c) => c.text("test"), + ); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + }); + + it("throws if permission context is invalid", async () => { + const p = new Permission( + "test", + {}, + s.object({ + a: s.number({ minimum: 2 }), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get( + "/test", + permission(p, { + context: (c) => ({ + a: 1, + }), + }), + async (c) => c.text("test"), + ); + + const res = await hono.request("/test"); + // expecting 500 because bknd should have handled it correctly + expect(res.status).toBe(500); + }); +}); + +describe("Role", () => { + it("serializes and deserializes", () => { + const p = new Permission( + "test", + { + filterable: true, + }, + s.object({ + a: s.number({ minimum: 2 }), + }), + ); + const r = new Role( + "test", + [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + effect: "deny", + filter: { + b: { $lt: 1 }, + }, + }), + ]), + ], + true, + ); + const json = JSON.parse(JSON.stringify(r.toJSON())); + const r2 = Role.create(json); + expect(r2.toJSON()).toEqual(r.toJSON()); + }); +}); diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index 340ccaff..477951f2 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { App, createApp, type AuthResponse } from "../../src"; -import { auth } from "../../src/auth/middlewares"; +import { auth } from "../../src/modules/middlewares"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { getDummyConnection } from "../helper"; diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 15448beb..b94aa4b6 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -60,8 +60,8 @@ export class AuthController extends Controller { if (create) { hono.post( "/create", - permission(AuthPermissions.createUser), - permission(DataPermissions.entityCreate), + permission(AuthPermissions.createUser, {}), + permission(DataPermissions.entityCreate, {}), describeRoute({ summary: "Create a new user", tags: ["auth"], @@ -239,7 +239,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c); + await c.context.ctx().helper.granted(c, AuthPermissions.createUser); return c.json(await this.auth.createUser(params)); }, @@ -256,7 +256,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c); + await c.context.ctx().helper.granted(c, AuthPermissions.createToken); const user = await getUser(params); return c.json({ user, token: await this.auth.authenticator.jwt(user) }); @@ -275,7 +275,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c); + await c.context.ctx().helper.granted(c, AuthPermissions.changePassword); const user = await getUser(params); if (!(await this.auth.changePassword(user.id, params.password))) { @@ -296,7 +296,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c); + await c.context.ctx().helper.granted(c, AuthPermissions.testPassword); const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; const controller = pw.getController(this.auth.authenticator); diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts index 8b097e75..dce59f5c 100644 --- a/app/src/auth/auth-permissions.ts +++ b/app/src/auth/auth-permissions.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; export const createUser = new Permission("auth.user.create"); //export const updateUser = new Permission("auth.user.update"); diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 5576d4bd..ac97c636 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,9 +1,11 @@ import { Exception } from "core/errors"; -import { $console, objectTransform, type s } from "bknd/utils"; -import { Permission } from "core/security/Permission"; +import { $console, type s } from "bknd/utils"; +import type { Permission, PermissionContext } from "auth/authorize/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; -import { Role } from "./Role"; +import type { Role } from "./Role"; +import { HttpStatus } from "bknd/utils"; +import type { Policy, PolicySchema } from "./Policy"; export type GuardUserContext = { role?: string | null; @@ -12,45 +14,43 @@ export type GuardUserContext = { export type GuardConfig = { enabled?: boolean; - context?: string; + context?: object; }; export type GuardContext = Context | GuardUserContext; -export class Guard { - permissions: Permission[]; - roles?: Role[]; - config?: GuardConfig; +export class GuardPermissionsException extends Exception { + override name = "PermissionsException"; + override code = HttpStatus.FORBIDDEN; - constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) { + constructor( + public permission: Permission, + public policy?: Policy, + public description?: string, + ) { + super(`Permission "${permission.name}" not granted`); + } + + override toJSON(): any { + return { + ...super.toJSON(), + description: this.description, + permission: this.permission.name, + policy: this.policy?.toJSON(), + }; + } +} + +export class Guard { + constructor( + public permissions: Permission[] = [], + public roles: Role[] = [], + public config?: GuardConfig, + ) { this.permissions = permissions; this.roles = roles; this.config = config; } - /** - * @deprecated - */ - static create( - permissionNames: string[], - roles?: Record< - string, - { - permissions?: string[]; - is_default?: boolean; - implicit_allow?: boolean; - } - >, - config?: GuardConfig, - ) { - const _roles = roles - ? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => { - return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow); - }) - : {}; - const _permissions = permissionNames.map((name) => new Permission(name)); - return new Guard(_permissions, Object.values(_roles), config); - } - getPermissionNames(): string[] { return this.permissions.map((permission) => permission.name); } @@ -77,7 +77,7 @@ export class Guard { return this; } - registerPermission(permission: Permission) { + registerPermission(permission: Permission) { if (this.permissions.find((p) => p.name === permission.name)) { throw new Error(`Permission ${permission.name} already exists`); } @@ -86,9 +86,13 @@ export class Guard { return this; } - registerPermissions(permissions: Record); - registerPermissions(permissions: Permission[]); - registerPermissions(permissions: Permission[] | Record) { + registerPermissions(permissions: Record>); + registerPermissions(permissions: Permission[]); + registerPermissions( + permissions: + | Permission[] + | Record>, + ) { const p = Array.isArray(permissions) ? permissions : Object.values(permissions); for (const permission of p) { @@ -121,69 +125,133 @@ export class Guard { return this.config?.enabled === true; } - hasPermission(permission: Permission, user?: GuardUserContext): boolean; - hasPermission(name: string, user?: GuardUserContext): boolean; - hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { - if (!this.isEnabled()) { - return true; - } - - const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; - $console.debug("guard: checking permission", { - name, - user: { id: user?.id, role: user?.role }, - }); - const exists = this.permissionExists(name); - if (!exists) { - throw new Error(`Permission ${name} does not exist`); - } - + private collect(permission: Permission, c: GuardContext, context: any) { + const user = c && "get" in c ? c.get("auth")?.user : c; + const ctx = { + ...((context ?? {}) as any), + ...this.config?.context, + user, + }; + const exists = this.permissionExists(permission.name); const role = this.getUserRole(user); + const rolePermission = role?.permissions.find( + (rolePermission) => rolePermission.permission.name === permission.name, + ); + return { + ctx, + user, + exists, + role, + rolePermission, + }; + } + + granted

>( + permission: P, + c: GuardContext, + context: PermissionContext

, + ): void; + granted

>(permission: P, c: GuardContext): void; + granted

>( + permission: P, + c: GuardContext, + context?: PermissionContext

, + ): void { + if (!this.isEnabled()) { + return; + } + const { ctx, user, exists, role, rolePermission } = this.collect(permission, c, context); + + $console.debug("guard: checking permission", { + name: permission.name, + context: ctx, + }); + if (!exists) { + throw new GuardPermissionsException( + permission, + undefined, + `Permission ${permission.name} does not exist`, + ); + } if (!role) { $console.debug("guard: user has no role, denying"); - return false; + throw new GuardPermissionsException(permission, undefined, "User has no role"); } else if (role.implicit_allow === true) { $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); - return true; + return; } - const rolePermission = role.permissions.find( - (rolePermission) => rolePermission.permission.name === name, - ); - - $console.debug("guard: rolePermission, allowing?", { - permission: name, - role: role.name, - allowing: !!rolePermission, - }); - return !!rolePermission; - } - - granted

( - permission: P, - c?: GuardContext, - context: s.Static = {} as s.Static, - ): boolean { - const user = c && "get" in c ? c.get("auth")?.user : c; - const ctx = { - ...context, - user, - context: this.config?.context, - }; - return this.hasPermission(permission, user); - } - - throwUnlessGranted

( - permission: P, - c: GuardContext, - context: s.Static, - ) { - if (!this.granted(permission, c)) { - throw new Exception( - `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, - 403, + if (!rolePermission) { + $console.debug("guard: rolePermission not found, denying"); + throw new GuardPermissionsException( + permission, + undefined, + "Role does not have required permission", ); } + + // validate context + let ctx2 = Object.assign({}, ctx); + if (permission.context) { + ctx2 = permission.parseContext(ctx2); + } + + if (rolePermission?.policies.length > 0) { + $console.debug("guard: rolePermission has policies, checking"); + for (const policy of rolePermission.policies) { + // skip filter policies + if (policy.content.effect === "filter") continue; + + // if condition unmet or effect is deny, throw + const meets = policy.meetsCondition(ctx2); + if (!meets || (meets && policy.content.effect === "deny")) { + throw new GuardPermissionsException( + permission, + policy, + "Policy does not meet condition", + ); + } + } + } + + $console.debug("guard allowing", { + permission: permission.name, + role: role.name, + }); + } + + getPolicyFilter

>( + permission: P, + c: GuardContext, + context: PermissionContext

, + ): PolicySchema["filter"] | undefined; + getPolicyFilter

>( + permission: P, + c: GuardContext, + ): PolicySchema["filter"] | undefined; + getPolicyFilter

>( + permission: P, + c: GuardContext, + context?: PermissionContext

, + ): PolicySchema["filter"] | undefined { + if (!permission.isFilterable()) return; + + const { ctx, exists, role, rolePermission } = this.collect(permission, c, context); + + // validate context + let ctx2 = Object.assign({}, ctx); + if (permission.context) { + ctx2 = permission.parseContext(ctx2); + } + + if (exists && role && rolePermission && rolePermission.policies.length > 0) { + for (const policy of rolePermission.policies) { + if (policy.content.effect === "filter") { + return policy.meetsFilter(ctx2) ? policy.content.filter : undefined; + } + } + } + return; } } diff --git a/app/src/auth/authorize/Permission.ts b/app/src/auth/authorize/Permission.ts new file mode 100644 index 00000000..cd7b51bf --- /dev/null +++ b/app/src/auth/authorize/Permission.ts @@ -0,0 +1,68 @@ +import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils"; + +export const permissionOptionsSchema = s + .strictObject({ + description: s.string(), + filterable: s.boolean(), + }) + .partial(); + +export type PermissionOptions = s.Static; +export type PermissionContext

> = P extends Permission< + any, + any, + infer Context, + any +> + ? Context extends s.ObjectSchema + ? s.Static + : never + : never; + +export class InvalidPermissionContextError extends InvalidSchemaError { + override name = "InvalidPermissionContextError"; + + // changing to internal server error because it's an unexpected behavior + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + static from(e: InvalidSchemaError) { + return new InvalidPermissionContextError(e.schema, e.value, e.errors); + } +} + +export class Permission< + Name extends string = string, + Options extends PermissionOptions = {}, + Context extends s.ObjectSchema | undefined = undefined, + ContextValue = Context extends s.ObjectSchema ? s.Static : undefined, +> { + constructor( + public name: Name, + public options: Options = {} as Options, + public context: Context = undefined as Context, + ) {} + + isFilterable() { + return this.options.filterable === true; + } + + parseContext(ctx: ContextValue, opts?: ParseOptions) { + try { + return this.context ? parse(this.context!, ctx, opts) : undefined; + } catch (e) { + if (e instanceof InvalidSchemaError) { + throw InvalidPermissionContextError.from(e); + } + + throw e; + } + } + + toJSON() { + return { + name: this.name, + ...this.options, + context: this.context, + }; + } +} diff --git a/app/src/auth/authorize/Policy.ts b/app/src/auth/authorize/Policy.ts new file mode 100644 index 00000000..fd873afc --- /dev/null +++ b/app/src/auth/authorize/Policy.ts @@ -0,0 +1,42 @@ +import { s, parse, recursivelyReplacePlaceholders } from "bknd/utils"; +import * as query from "core/object/query/object-query"; + +export const policySchema = s + .strictObject({ + description: s.string(), + condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>, + effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }), + filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>, + }) + .partial(); +export type PolicySchema = s.Static; + +export class Policy { + public content: Schema; + + constructor(content?: Schema) { + this.content = parse(policySchema, content ?? {}, { + withDefaults: true, + }) as Schema; + } + + replace(context: object, vars?: Record) { + return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; + } + + meetsCondition(context: object, vars?: Record) { + return query.validate(this.replace(this.content.condition!, vars), context); + } + + meetsFilter(subject: object, vars?: Record) { + return query.validate(this.replace(this.content.filter!, vars), subject); + } + + getFiltered(given: Given): Given { + return given.filter((item) => this.meetsFilter(item)) as Given; + } + + toJSON() { + return this.content; + } +} diff --git a/app/src/auth/authorize/Role.ts b/app/src/auth/authorize/Role.ts index 54efaf12..0cf038b5 100644 --- a/app/src/auth/authorize/Role.ts +++ b/app/src/auth/authorize/Role.ts @@ -1,10 +1,33 @@ -import { Permission } from "core/security/Permission"; +import { parse, s } from "bknd/utils"; +import { Permission } from "./Permission"; +import { Policy, policySchema } from "./Policy"; + +export const rolePermissionSchema = s.strictObject({ + permission: s.string(), + policies: s.array(policySchema).optional(), +}); +export type RolePermissionSchema = s.Static; + +export const roleSchema = s.strictObject({ + name: s.string(), + permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(), + is_default: s.boolean().optional(), + implicit_allow: s.boolean().optional(), +}); +export type RoleSchema = s.Static; export class RolePermission { constructor( - public permission: Permission, - public config?: any, + public permission: Permission, + public policies: Policy[] = [], ) {} + + toJSON() { + return { + permission: this.permission.name, + policies: this.policies.map((p) => p.toJSON()), + }; + } } export class Role { @@ -15,31 +38,24 @@ export class Role { public implicit_allow: boolean = false, ) {} - static createWithPermissionNames( - name: string, - permissionNames: string[], - is_default: boolean = false, - implicit_allow: boolean = false, - ) { - return new Role( - name, - permissionNames.map((name) => new RolePermission(new Permission(name))), - is_default, - implicit_allow, - ); + static create(config: RoleSchema) { + const permissions = + config.permissions?.map((p: string | RolePermissionSchema) => { + if (typeof p === "string") { + return new RolePermission(new Permission(p), []); + } + const policies = p.policies?.map((policy) => new Policy(policy)); + return new RolePermission(new Permission(p.permission), policies); + }) ?? []; + return new Role(config.name, permissions, config.is_default, config.implicit_allow); } - static create(config: { - name: string; - permissions?: string[]; - is_default?: boolean; - implicit_allow?: boolean; - }) { - return new Role( - config.name, - config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [], - config.is_default, - config.implicit_allow, - ); + toJSON() { + return { + name: this.name, + permissions: this.permissions.map((p) => p.toJSON()), + is_default: this.is_default, + implicit_allow: this.implicit_allow, + }; } } diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares/auth.middleware.ts similarity index 52% rename from app/src/auth/middlewares.ts rename to app/src/auth/middlewares/auth.middleware.ts index 685a9bd5..eeebe454 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares/auth.middleware.ts @@ -1,9 +1,7 @@ -import type { Permission } from "core/security/Permission"; -import { $console, patternMatch, type s } from "bknd/utils"; +import { $console, patternMatch } from "bknd/utils"; import type { Context } from "hono"; import { createMiddleware } from "hono/factory"; import type { ServerEnv } from "modules/Controller"; -import type { MaybePromise } from "core/types"; function getPath(reqOrCtx: Request | Context) { const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; @@ -68,49 +66,3 @@ export const auth = (options?: { authCtx.resolved = false; authCtx.user = undefined; }); - -export const permission =

( - permission: P, - options?: { - onGranted?: (c: Context) => MaybePromise; - onDenied?: (c: Context) => MaybePromise; - context?: (c: Context) => MaybePromise>; - }, -) => - // @ts-ignore - createMiddleware(async (c, next) => { - const app = c.get("app"); - const authCtx = c.get("auth"); - if (!authCtx) { - throw new Error("auth ctx not found"); - } - - // in tests, app is not defined - if (!authCtx.registered || !app) { - const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; - if (app?.module.auth.enabled) { - throw new Error(msg); - } else { - $console.warn(msg); - } - } else if (!authCtx.skip) { - const guard = app.modules.ctx().guard; - const context = (await options?.context?.(c)) ?? ({} as any); - - if (options?.onGranted || options?.onDenied) { - let returned: undefined | void | Response; - if (guard.granted(permission, c, context)) { - returned = await options?.onGranted?.(c); - } else { - returned = await options?.onDenied?.(c); - } - if (returned instanceof Response) { - return returned; - } - } else { - guard.throwUnlessGranted(permission, c, context); - } - } - - await next(); - }); diff --git a/app/src/auth/middlewares/permission.middleware.ts b/app/src/auth/middlewares/permission.middleware.ts new file mode 100644 index 00000000..ac38a08b --- /dev/null +++ b/app/src/auth/middlewares/permission.middleware.ts @@ -0,0 +1,93 @@ +import type { Permission, PermissionContext } from "auth/authorize/Permission"; +import { $console, threw } from "bknd/utils"; +import type { Context, Hono } from "hono"; +import type { RouterRoute } from "hono/types"; +import { createMiddleware } from "hono/factory"; +import type { ServerEnv } from "modules/Controller"; +import type { MaybePromise } from "core/types"; + +function getPath(reqOrCtx: Request | Context) { + const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; + return new URL(req.url).pathname; +} + +const permissionSymbol = Symbol.for("permission"); + +type PermissionMiddlewareOptions

> = { + onGranted?: (c: Context) => MaybePromise; + onDenied?: (c: Context) => MaybePromise; +} & (P extends Permission + ? PC extends undefined + ? { + context?: never; + } + : { + context: (c: Context) => MaybePromise>; + } + : { + context?: never; + }); + +export function permission

>( + permission: P, + options: PermissionMiddlewareOptions

, +) { + // @ts-ignore (middlewares do not always return) + const handler = createMiddleware(async (c, next) => { + const app = c.get("app"); + const authCtx = c.get("auth"); + if (!authCtx) { + throw new Error("auth ctx not found"); + } + + // in tests, app is not defined + if (!authCtx.registered || !app) { + const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; + if (app?.module.auth.enabled) { + throw new Error(msg); + } else { + $console.warn(msg); + } + } else if (!authCtx.skip) { + const guard = app.modules.ctx().guard; + const context = (await options?.context?.(c)) ?? ({} as any); + + if (options?.onGranted || options?.onDenied) { + let returned: undefined | void | Response; + if (threw(() => guard.granted(permission, c, context))) { + returned = await options?.onDenied?.(c); + } else { + returned = await options?.onGranted?.(c); + } + if (returned instanceof Response) { + return returned; + } + } else { + guard.granted(permission, c, context); + } + } + + await next(); + }); + + return Object.assign(handler, { + [permissionSymbol]: { permission, options }, + }); +} + +export function getPermissionRoutes(hono: Hono) { + const routes: { + route: RouterRoute; + permission: Permission; + options: PermissionMiddlewareOptions; + }[] = []; + for (const route of hono.routes) { + if (permissionSymbol in route.handler) { + routes.push({ + route, + ...(route.handler[permissionSymbol] as any), + }); + } + } + return routes; +} diff --git a/app/src/core/security/Permission.ts b/app/src/core/security/Permission.ts deleted file mode 100644 index 5ca4d849..00000000 --- a/app/src/core/security/Permission.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - s, - type ParseOptions, - parse, - InvalidSchemaError, - recursivelyReplacePlaceholders, -} from "bknd/utils"; -import * as query from "core/object/query/object-query"; - -export const permissionOptionsSchema = s - .strictObject({ - description: s.string(), - filterable: s.boolean(), - }) - .partial(); - -export type PermissionOptions = s.Static; - -export class InvalidPermissionContextError extends InvalidSchemaError { - override name = "InvalidPermissionContextError"; - - static from(e: InvalidSchemaError) { - return new InvalidPermissionContextError(e.schema, e.value, e.errors); - } -} - -export class Permission< - Name extends string = string, - Options extends PermissionOptions = {}, - Context extends s.ObjectSchema = s.ObjectSchema, -> { - constructor( - public name: Name, - public options: Options = {} as Options, - public context: Context = s.object({}) as Context, - ) {} - - parseContext(ctx: s.Static, opts?: ParseOptions) { - try { - return parse(this.context, ctx, opts); - } catch (e) { - if (e instanceof InvalidSchemaError) { - throw InvalidPermissionContextError.from(e); - } - - throw e; - } - } - - toJSON() { - return { - name: this.name, - ...this.options, - context: this.context, - }; - } -} - -export const policySchema = s - .strictObject({ - description: s.string(), - condition: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>, - effect: s.string({ enum: ["allow", "deny", "filter"], default: "deny" }), - filter: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>, - }) - .partial(); -export type PolicySchema = s.Static; - -export class Policy { - public content: Schema; - - constructor(content?: Schema) { - this.content = parse(policySchema, content ?? {}) as Schema; - } - - replace(context: object, vars?: Record) { - return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; - } - - meetsCondition(context: object, vars?: Record) { - return query.validate(this.replace(this.content.condition!, vars), context); - } - - meetsFilter(subject: object, vars?: Record) { - return query.validate(this.replace(this.content.filter!, vars), subject); - } - - getFiltered(given: Given): Given { - return given.filter((item) => this.meetsFilter(item)) as Given; - } - - toJSON() { - return this.content; - } -} diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 0772abd2..9b8e3850 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -61,3 +61,12 @@ export function invariant(condition: boolean | any, message: string) { throw new Error(message); } } + +export function threw(fn: () => any) { + try { + fn(); + return false; + } catch (e) { + return true; + } +} diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index d30aae1e..ff8190c0 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -1,3 +1,5 @@ +import { Exception } from "core/errors"; +import { HttpStatus } from "bknd/utils"; import * as s from "jsonv-ts"; export { validator as jsc, type Options } from "jsonv-ts/hono"; @@ -58,8 +60,9 @@ export const stringIdentifier = s.string({ maxLength: 150, }); -export class InvalidSchemaError extends Error { +export class InvalidSchemaError extends Exception { override name = "InvalidSchemaError"; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor( public schema: s.Schema, diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 163f0aff..d4f9cdfe 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -42,7 +42,7 @@ export class DataController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); + const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {})); const entitiesEnum = this.getEntitiesEnum(this.em); // info @@ -58,7 +58,7 @@ export class DataController extends Controller { // sync endpoint hono.get( "/sync", - permission(DataPermissions.databaseSync), + permission(DataPermissions.databaseSync, {}), mcpTool("data_sync", { // @todo: should be removed if readonly annotations: { @@ -95,7 +95,7 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Retrieve data schema", tags: ["data"], @@ -121,7 +121,7 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Retrieve entity schema", tags: ["data"], @@ -161,7 +161,7 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Retrieve entity info", tags: ["data"], @@ -213,7 +213,7 @@ export class DataController extends Controller { // fn: count hono.post( "/:entity/fn/count", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Count entities", tags: ["data"], @@ -236,7 +236,7 @@ export class DataController extends Controller { // fn: exists hono.post( "/:entity/fn/exists", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Check if entity exists", tags: ["data"], @@ -285,7 +285,7 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), jsc("param", s.object({ entity: entitiesEnum })), jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { @@ -308,7 +308,7 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["offset", "sort", "select"]), tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), mcpTool("data_entity_read_one", { inputSchema: { param: s.object({ entity: entitiesEnum, id: idType }), @@ -344,7 +344,7 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(), tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), jsc( "param", s.object({ @@ -390,7 +390,7 @@ export class DataController extends Controller { }, tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), mcpTool("data_entity_read_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -421,7 +421,7 @@ export class DataController extends Controller { summary: "Insert one or many", tags: ["data"], }), - permission(DataPermissions.entityCreate), + permission(DataPermissions.entityCreate, {}), mcpTool("data_entity_insert"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), @@ -455,7 +455,7 @@ export class DataController extends Controller { summary: "Update many", tags: ["data"], }), - permission(DataPermissions.entityUpdate), + permission(DataPermissions.entityUpdate, {}), mcpTool("data_entity_update_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -495,7 +495,7 @@ export class DataController extends Controller { summary: "Update one", tags: ["data"], }), - permission(DataPermissions.entityUpdate), + permission(DataPermissions.entityUpdate, {}), mcpTool("data_entity_update_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("json", s.object({})), @@ -518,7 +518,7 @@ export class DataController extends Controller { summary: "Delete one", tags: ["data"], }), - permission(DataPermissions.entityDelete), + permission(DataPermissions.entityDelete, {}), mcpTool("data_entity_delete_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), async (c) => { @@ -539,7 +539,7 @@ export class DataController extends Controller { summary: "Delete many", tags: ["data"], }), - permission(DataPermissions.entityDelete), + permission(DataPermissions.entityDelete, {}), mcpTool("data_entity_delete_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), diff --git a/app/src/data/permissions/index.ts b/app/src/data/permissions/index.ts index 3db75edc..124980e8 100644 --- a/app/src/data/permissions/index.ts +++ b/app/src/data/permissions/index.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; export const entityRead = new Permission("data.entity.read"); export const entityCreate = new Permission("data.entity.create"); diff --git a/app/src/index.ts b/app/src/index.ts index ae011517..0f3d9808 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -45,7 +45,7 @@ export type { MaybePromise } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; -export { Permission } from "core/security/Permission"; +export { Permission } from "auth/authorize/Permission"; export { getFlashMessage } from "core/server/flash"; export * from "core/drivers"; export { Event, InvalidEventReturn } from "core/events/Event"; diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 3c67bc76..e1f795b5 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -36,7 +36,7 @@ export class MediaController extends Controller { summary: "Get the list of files", tags: ["media"], }), - permission(MediaPermissions.listFiles), + permission(MediaPermissions.listFiles, {}), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); @@ -51,7 +51,7 @@ export class MediaController extends Controller { summary: "Get a file by name", tags: ["media"], }), - permission(MediaPermissions.readFile), + permission(MediaPermissions.readFile, {}), async (c) => { const { filename } = c.req.param(); if (!filename) { @@ -81,7 +81,7 @@ export class MediaController extends Controller { summary: "Delete a file by name", tags: ["media"], }), - permission(MediaPermissions.deleteFile), + permission(MediaPermissions.deleteFile, {}), async (c) => { const { filename } = c.req.param(); if (!filename) { @@ -149,7 +149,7 @@ export class MediaController extends Controller { requestBody, }), jsc("param", s.object({ filename: s.string().optional() })), - permission(MediaPermissions.uploadFile), + permission(MediaPermissions.uploadFile, {}), async (c) => { const reqname = c.req.param("filename"); @@ -189,8 +189,8 @@ export class MediaController extends Controller { }), ), jsc("query", s.object({ overwrite: s.boolean().optional() })), - permission(DataPermissions.entityCreate), - permission(MediaPermissions.uploadFile), + permission(DataPermissions.entityCreate, {}), + permission(MediaPermissions.uploadFile, {}), async (c) => { const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param"); diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts index 527ce28c..0ae00178 100644 --- a/app/src/media/media-permissions.ts +++ b/app/src/media/media-permissions.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; export const readFile = new Permission("media.file.read"); export const listFiles = new Permission("media.file.list"); diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index d6710656..29c4172e 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -5,7 +5,7 @@ import { entityTypes } from "data/entities/Entity"; import { isEqual } from "lodash-es"; import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { EntityRelation } from "data/relations"; -import type { Permission } from "core/security/Permission"; +import type { Permission, PermissionContext } from "auth/authorize/Permission"; import { Exception } from "core/errors"; import { invariant, isPlainObject } from "bknd/utils"; @@ -114,10 +114,20 @@ export class ModuleHelper { entity.__replaceField(name, newField); } - async throwUnlessGranted( - permission: Permission, + async granted

>( c: { context: ModuleBuildContextMcpContext; raw?: unknown }, - ) { + permission: P, + context: PermissionContext

, + ): Promise; + async granted

>( + c: { context: ModuleBuildContextMcpContext; raw?: unknown }, + permission: P, + ): Promise; + async granted

>( + c: { context: ModuleBuildContextMcpContext; raw?: unknown }, + permission: P, + context?: PermissionContext

, + ): Promise { invariant(c.context.app, "app is not available in mcp context"); const auth = c.context.app.module.auth; if (!auth.enabled) return; @@ -127,12 +137,6 @@ export class ModuleHelper { } const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); - - if (!this.ctx.guard.granted(permission, user)) { - throw new Exception( - `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, - 403, - ); - } + this.ctx.guard.granted(permission, { user }, context as any); } } diff --git a/app/src/modules/middlewares/index.ts b/app/src/modules/middlewares/index.ts index be1ad591..213eb7e9 100644 --- a/app/src/modules/middlewares/index.ts +++ b/app/src/modules/middlewares/index.ts @@ -1 +1,2 @@ -export { auth, permission } from "auth/middlewares"; +export { auth } from "auth/middlewares/auth.middleware"; +export { permission } from "auth/middlewares/permission.middleware"; diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 5f5ccd1e..152072d4 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; import { s } from "bknd/utils"; export const accessAdmin = new Permission("system.access.admin"); @@ -24,6 +24,12 @@ export const configWrite = new Permission( module: s.string().optional(), }), ); -export const schemaRead = new Permission("system.schema.read"); +export const schemaRead = new Permission( + "system.schema.read", + {}, + s.object({ + module: s.string().optional(), + }), +); export const build = new Permission("system.build"); export const mcp = new Permission("system.mcp"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 2cdb2a7a..454ad404 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -116,6 +116,7 @@ export class AdminController extends Controller { onDenied: async (c) => { addFlashMessage(c, "You not allowed to read the schema", "warning"); }, + context: (c) => ({}), }), async (c) => { const obj: AdminBkndWindowContext = { @@ -147,9 +148,10 @@ export class AdminController extends Controller { return c.redirect(authRoutes.success); } }, + context: (c) => ({}), }; const redirectRouteParams = [ - permission(SystemPermissions.accessAdmin, options), + permission(SystemPermissions.accessAdmin, options as any), permission(SystemPermissions.schemaRead, options), async (c) => { return c.html(c.get("html")!); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index b9fb531c..e774d1eb 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -87,6 +87,10 @@ export class AppServer extends Module { } if (err instanceof AuthException) { + if (isDebug()) { + return c.json(err.toJSON(), err.code); + } + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 43d688e5..4469c7ef 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -119,7 +119,7 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): 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(); /* .use(permission(SystemPermissions.configRead)); */ if (!this.app.isReadOnly()) { const manager = this.app.modules as DbModuleManager; @@ -130,7 +130,11 @@ export class SystemController extends Controller { summary: "Get the raw config", tags: ["system"], }), - permission(SystemPermissions.configReadSecrets), + permission(SystemPermissions.configReadSecrets, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @ts-expect-error "fetch" is private return c.json(await this.app.modules.fetch().then((r) => r?.configs)); @@ -165,7 +169,11 @@ export class SystemController extends Controller { hono.post( "/set/:module", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }), async (c) => { const module = c.req.param("module") as any; @@ -194,32 +202,44 @@ export class SystemController extends Controller { }, ); - hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => { - // @todo: require auth (admin) - const module = c.req.param("module") as any; - const value = await c.req.json(); - const path = c.req.param("path") as string; + hono.post( + "/add/:module/:path", + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), + async (c) => { + // @todo: require auth (admin) + const module = c.req.param("module") as any; + const value = await c.req.json(); + const path = c.req.param("path") as string; - if (this.app.modules.get(module).schema().has(path)) { - return c.json( - { success: false, path, error: "Path already exists" }, - { status: 400 }, - ); - } + if (this.app.modules.get(module).schema().has(path)) { + return c.json( + { success: false, path, error: "Path already exists" }, + { status: 400 }, + ); + } - return await handleConfigUpdateResponse(c, async () => { - await manager.mutateConfigSafe(module).patch(path, value); - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }); + return await handleConfigUpdateResponse(c, async () => { + await manager.mutateConfigSafe(module).patch(path, value); + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }, + ); hono.patch( "/patch/:module/:path", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @todo: require auth (admin) const module = c.req.param("module") as any; @@ -239,7 +259,11 @@ export class SystemController extends Controller { hono.put( "/overwrite/:module/:path", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @todo: require auth (admin) const module = c.req.param("module") as any; @@ -259,7 +283,11 @@ export class SystemController extends Controller { hono.delete( "/remove/:module/:path", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @todo: require auth (admin) const module = c.req.param("module") as any; @@ -296,7 +324,7 @@ export class SystemController extends Controller { const { module } = c.req.valid("param"); if (secrets) { - this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { + this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, { module, }); } @@ -330,7 +358,11 @@ export class SystemController extends Controller { summary: "Get the schema for a module", tags: ["system"], }), - permission(SystemPermissions.schemaRead), + permission(SystemPermissions.schemaRead, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), jsc( "query", s @@ -347,12 +379,12 @@ export class SystemController extends Controller { const readonly = this.app.isReadOnly(); if (config) { - this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, { + this.ctx.guard.granted(SystemPermissions.configRead, c, { module, }); } if (secrets) { - this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { + this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, { module, }); } @@ -395,7 +427,7 @@ export class SystemController extends Controller { jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })), async (c) => { const options = c.req.valid("query") as Record; - this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c, {}); + this.ctx.guard.granted(SystemPermissions.build, c); await this.app.build(options); return c.json({ @@ -467,7 +499,7 @@ export class SystemController extends Controller { const { version, ...appConfig } = this.app.toJSON(); mcp.resource("system_config", "bknd://system/config", async (c) => { - await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c); + await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {}); return c.json(this.app.toJSON(), { title: "System Config", @@ -477,7 +509,9 @@ export class SystemController extends Controller { "system_config_module", "bknd://system/config/{module}", async (c, { module }) => { - await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c); + await this.ctx.helper.granted(c, SystemPermissions.configRead, { + module, + }); const m = this.app.modules.get(module as any) as Module; return c.json(m.toJSON(), { @@ -489,7 +523,7 @@ export class SystemController extends Controller { }, ) .resource("system_schema", "bknd://system/schema", async (c) => { - await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {}); return c.json(this.app.getSchema(), { title: "System Schema", @@ -499,7 +533,9 @@ export class SystemController extends Controller { "system_schema_module", "bknd://system/schema/{module}", async (c, { module }) => { - await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + await this.ctx.helper.granted(c, SystemPermissions.schemaRead, { + module, + }); const m = this.app.modules.get(module as any); return c.json(m.getSchema().toJSON(), { From 7e5c28d62196f67b4ba1f00baac2a8ee8e22aa24 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 13 Oct 2025 21:03:49 +0200 Subject: [PATCH 11/48] enhance Guard and permission handling with new test cases - Updated the `Guard` class to improve context validation and permission checks, ensuring clearer error messages for unmet conditions. - Refactored the `Policy` and `RolePermission` classes to support default effects and better handle conditions and filters. - Enhanced tests in `authorize.spec.ts` and `permissions.spec.ts` to cover new permission scenarios, including guest and member role behaviors. - Added new tests for context validation in permission middleware, ensuring robust error handling for invalid contexts. - Improved utility functions for better integration with the updated permission structure. --- app/__test__/auth/authorize/authorize.spec.ts | 153 ++++++++++++++++-- .../auth/authorize/permissions.spec.ts | 116 +++++++++++-- app/__test__/core/object/object-query.spec.ts | 10 ++ app/src/auth/authorize/Guard.ts | 57 ++++--- app/src/auth/authorize/Policy.ts | 3 + app/src/auth/authorize/Role.ts | 10 +- .../auth/middlewares/permission.middleware.ts | 3 +- app/src/core/object/query/query.ts | 8 +- app/src/core/utils/runtime.ts | 9 +- 9 files changed, 317 insertions(+), 52 deletions(-) diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index fbf787f5..5e39fb8c 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,19 +1,12 @@ import { describe, expect, test } from "bun:test"; import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Permission } from "auth/authorize/Permission"; -import { Role } from "auth/authorize/Role"; -import { objectTransform } from "bknd/utils"; +import { Role, type RoleSchema } from "auth/authorize/Role"; +import { objectTransform, s } from "bknd/utils"; function createGuard( permissionNames: string[], - roles?: Record< - string, - { - permissions?: string[]; - is_default?: boolean; - implicit_allow?: boolean; - } - >, + roles?: Record>, config?: GuardConfig, ) { const _roles = roles @@ -26,7 +19,9 @@ function createGuard( } describe("authorize", () => { - const read = new Permission("read"); + const read = new Permission("read", { + filterable: true, + }); const write = new Permission("write"); test("basic", async () => { @@ -109,4 +104,140 @@ describe("authorize", () => { expect(guard.granted(read, {})).toBeUndefined(); expect(guard.granted(write, {})).toBeUndefined(); }); + + describe("cases", () => { + test("guest none, member deny if user.enabled is false", () => { + const guard = createGuard( + ["read"], + { + guest: { + is_default: true, + }, + member: { + permissions: [ + { + permission: "read", + policies: [ + { + condition: {}, + effect: "filter", + filter: { + type: "member", + }, + }, + { + condition: { + "user.enabled": false, + }, + effect: "deny", + }, + ], + }, + ], + }, + }, + { enabled: true }, + ); + + expect(() => guard.granted(read, { role: "guest" })).toThrow(); + + // member is allowed, because default role permission effect is allow + // and no deny policy is met + expect(guard.granted(read, { role: "member" })).toBeUndefined(); + + // member is allowed, because deny policy is not met + expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined(); + + // member is denied, because deny policy is met + expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow(); + + // get the filter for member role + expect(guard.getPolicyFilter(read, { role: "member" })).toEqual({ + type: "member", + }); + + // get filter for guest + expect(guard.getPolicyFilter(read, {})).toBeUndefined(); + }); + + test("guest should only read posts that are public", () => { + const read = new Permission( + "read", + { + // make this permission filterable + // without this, `filter` policies have no effect + filterable: true, + }, + // expect the context to match this schema + // otherwise exit with 500 to ensure proper policy checking + s.object({ + entity: s.string(), + }), + ); + const guard = createGuard( + ["read"], + { + guest: { + // this permission is applied if no (or invalid) role is provided + is_default: true, + permissions: [ + { + permission: "read", + // effect deny means only having this permission, doesn't guarantee access + effect: "deny", + policies: [ + { + // only if this condition is met + condition: { + entity: { + $in: ["posts"], + }, + }, + // the effect is allow + effect: "allow", + }, + { + condition: { + entity: "posts", + }, + effect: "filter", + filter: { + public: true, + }, + }, + ], + }, + ], + }, + // members should be allowed to read all + member: { + permissions: [ + { + permission: "read", + }, + ], + }, + }, + { enabled: true }, + ); + + // guest can only read posts + expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined(); + expect(() => guard.granted(read, {}, { entity: "users" })).toThrow(); + + // and guests can only read public posts + expect(guard.getPolicyFilter(read, {}, { entity: "posts" })).toEqual({ + public: true, + }); + + // member can read posts and users + expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined(); + expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined(); + + // member should not have a filter + expect( + guard.getPolicyFilter(read, { role: "member" }, { entity: "posts" }), + ).toBeUndefined(); + }); + }); }); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index 2b3a5ced..f885d6e1 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -3,7 +3,7 @@ import { s } from "bknd/utils"; import { Permission } from "auth/authorize/Permission"; import { Policy } from "auth/authorize/Policy"; import { Hono } from "hono"; -import { permission } from "auth/middlewares/permission.middleware"; +import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware"; import { auth } from "auth/middlewares/auth.middleware"; import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Role, RolePermission } from "auth/authorize/Role"; @@ -113,7 +113,8 @@ describe("Guard", () => { const r = new Role("test", [ new RolePermission(p, [ new Policy({ - filter: { a: { $eq: 1 } }, + condition: { a: { $eq: 1 } }, + filter: { foo: "bar" }, effect: "filter", }), ]), @@ -129,7 +130,7 @@ describe("Guard", () => { }, { a: 1 }, ), - ).toEqual({ a: { $eq: 1 } }); + ).toEqual({ foo: "bar" }); expect( guard.getPolicyFilter( p, @@ -158,7 +159,8 @@ describe("Guard", () => { [ new RolePermission(p, [ new Policy({ - filter: { a: { $eq: 1 } }, + condition: { a: { $eq: 1 } }, + filter: { foo: "bar" }, effect: "filter", }), ]), @@ -177,7 +179,7 @@ describe("Guard", () => { }, { a: 1 }, ), - ).toEqual({ a: { $eq: 1 } }); + ).toEqual({ foo: "bar" }); expect( guard.getPolicyFilter( p, @@ -189,7 +191,7 @@ describe("Guard", () => { ).toBeUndefined(); // if no user context given, the default role is applied // hence it can be found - expect(guard.getPolicyFilter(p, {}, { a: 1 })).toEqual({ a: { $eq: 1 } }); + expect(guard.getPolicyFilter(p, {}, { a: 1 })).toEqual({ foo: "bar" }); }); }); @@ -293,13 +295,19 @@ describe("permission middleware", () => { it("denies if user with role doesn't meet condition", async () => { const p = new Permission("test"); const r = new Role("test", [ - new RolePermission(p, [ - new Policy({ - condition: { - a: { $lt: 1 }, - }, - }), - ]), + new RolePermission( + p, + [ + new Policy({ + condition: { + a: { $lt: 1 }, + }, + // default effect is allow + }), + ], + // change default effect to deny if no condition is met + "deny", + ), ]); const hono = makeApp([p], [r], { context: { @@ -391,6 +399,88 @@ describe("permission middleware", () => { // expecting 500 because bknd should have handled it correctly expect(res.status).toBe(500); }); + + it("checks context on routes with permissions", async () => { + const make = (user: any) => { + const p = new Permission( + "test", + {}, + s.object({ + a: s.number(), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + }), + ]), + ]); + return makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user }); + await next(); + }) + .get( + "/valid", + permission(p, { + context: (c) => ({ + a: 1, + }), + }), + async (c) => c.text("test"), + ) + .get( + "/invalid", + permission(p, { + // @ts-expect-error + context: (c) => ({ + b: "1", + }), + }), + async (c) => c.text("test"), + ) + .get( + "/invalid2", + permission(p, { + // @ts-expect-error + context: (c) => ({}), + }), + async (c) => c.text("test"), + ) + .get( + "/invalid3", + // @ts-expect-error + permission(p), + async (c) => c.text("test"), + ); + }; + + const hono = make({ id: 0, role: "test" }); + const valid = await hono.request("/valid"); + expect(valid.status).toBe(200); + const invalid = await hono.request("/invalid"); + expect(invalid.status).toBe(500); + const invalid2 = await hono.request("/invalid2"); + expect(invalid2.status).toBe(500); + const invalid3 = await hono.request("/invalid3"); + expect(invalid3.status).toBe(500); + + { + const hono = make(null); + const valid = await hono.request("/valid"); + expect(valid.status).toBe(403); + const invalid = await hono.request("/invalid"); + expect(invalid.status).toBe(500); + const invalid2 = await hono.request("/invalid2"); + expect(invalid2.status).toBe(500); + const invalid3 = await hono.request("/invalid3"); + expect(invalid3.status).toBe(500); + } + }); }); describe("Role", () => { diff --git a/app/__test__/core/object/object-query.spec.ts b/app/__test__/core/object/object-query.spec.ts index dc03fb6d..215adf8c 100644 --- a/app/__test__/core/object/object-query.spec.ts +++ b/app/__test__/core/object/object-query.spec.ts @@ -66,4 +66,14 @@ describe("object-query", () => { expect(result).toBe(expected); } }); + + test("paths", () => { + const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } }); + expect(result).toBe(true); + }); + + test("empty filters", () => { + const result = validate({}, { user: { age: 17 } }); + expect(result).toBe(true); + }); }); diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index ac97c636..37ee8421 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -160,7 +160,13 @@ export class Guard { if (!this.isEnabled()) { return; } - const { ctx, user, exists, role, rolePermission } = this.collect(permission, c, context); + const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context); + + // validate context + let ctx = Object.assign({}, _ctx); + if (permission.context) { + ctx = permission.parseContext(ctx); + } $console.debug("guard: checking permission", { name: permission.name, @@ -187,32 +193,37 @@ export class Guard { throw new GuardPermissionsException( permission, undefined, - "Role does not have required permission", + `Role "${role.name}" does not have required permission`, ); } - // validate context - let ctx2 = Object.assign({}, ctx); - if (permission.context) { - ctx2 = permission.parseContext(ctx2); - } - if (rolePermission?.policies.length > 0) { $console.debug("guard: rolePermission has policies, checking"); + + // set the default effect of the role permission + let allowed = rolePermission.effect === "allow"; for (const policy of rolePermission.policies) { // skip filter policies if (policy.content.effect === "filter") continue; - // if condition unmet or effect is deny, throw - const meets = policy.meetsCondition(ctx2); - if (!meets || (meets && policy.content.effect === "deny")) { - throw new GuardPermissionsException( - permission, - policy, - "Policy does not meet condition", - ); + // if condition is met, check the effect + const meets = policy.meetsCondition(ctx); + if (meets) { + // if deny, then break early + if (policy.content.effect === "deny") { + allowed = false; + break; + + // if allow, set allow but continue checking + } else if (policy.content.effect === "allow") { + allowed = true; + } } } + + if (!allowed) { + throw new GuardPermissionsException(permission, undefined, "Policy condition unmet"); + } } $console.debug("guard allowing", { @@ -235,20 +246,24 @@ export class Guard { c: GuardContext, context?: PermissionContext

, ): PolicySchema["filter"] | undefined { - if (!permission.isFilterable()) return; + if (!permission.isFilterable()) { + $console.debug("getPolicyFilter: permission is not filterable, returning undefined"); + return; + } - const { ctx, exists, role, rolePermission } = this.collect(permission, c, context); + const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context); // validate context - let ctx2 = Object.assign({}, ctx); + let ctx = Object.assign({}, _ctx); if (permission.context) { - ctx2 = permission.parseContext(ctx2); + ctx = permission.parseContext(ctx); } if (exists && role && rolePermission && rolePermission.policies.length > 0) { for (const policy of rolePermission.policies) { if (policy.content.effect === "filter") { - return policy.meetsFilter(ctx2) ? policy.content.filter : undefined; + const meets = policy.meetsCondition(ctx); + return meets ? policy.content.filter : undefined; } } } diff --git a/app/src/auth/authorize/Policy.ts b/app/src/auth/authorize/Policy.ts index fd873afc..9995e202 100644 --- a/app/src/auth/authorize/Policy.ts +++ b/app/src/auth/authorize/Policy.ts @@ -5,6 +5,7 @@ export const policySchema = s .strictObject({ description: s.string(), condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>, + // @todo: potentially remove this, and invert from rolePermission.effect effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }), filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>, }) @@ -25,10 +26,12 @@ export class Policy { } meetsCondition(context: object, vars?: Record) { + if (!this.content.condition) return true; return query.validate(this.replace(this.content.condition!, vars), context); } meetsFilter(subject: object, vars?: Record) { + if (!this.content.filter) return true; return query.validate(this.replace(this.content.filter!, vars), subject); } diff --git a/app/src/auth/authorize/Role.ts b/app/src/auth/authorize/Role.ts index 0cf038b5..3f072a1f 100644 --- a/app/src/auth/authorize/Role.ts +++ b/app/src/auth/authorize/Role.ts @@ -1,9 +1,13 @@ -import { parse, s } from "bknd/utils"; +import { s } from "bknd/utils"; import { Permission } from "./Permission"; import { Policy, policySchema } from "./Policy"; +// default effect is allow for backward compatibility +const defaultEffect = "allow"; + export const rolePermissionSchema = s.strictObject({ permission: s.string(), + effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(), policies: s.array(policySchema).optional(), }); export type RolePermissionSchema = s.Static; @@ -20,12 +24,14 @@ export class RolePermission { constructor( public permission: Permission, public policies: Policy[] = [], + public effect: "allow" | "deny" = defaultEffect, ) {} toJSON() { return { permission: this.permission.name, policies: this.policies.map((p) => p.toJSON()), + effect: this.effect, }; } } @@ -45,7 +51,7 @@ export class Role { return new RolePermission(new Permission(p), []); } const policies = p.policies?.map((policy) => new Policy(policy)); - return new RolePermission(new Permission(p.permission), policies); + return new RolePermission(new Permission(p.permission), policies, p.effect); }) ?? []; return new Role(config.name, permissions, config.is_default, config.implicit_allow); } diff --git a/app/src/auth/middlewares/permission.middleware.ts b/app/src/auth/middlewares/permission.middleware.ts index ac38a08b..c6e53f4e 100644 --- a/app/src/auth/middlewares/permission.middleware.ts +++ b/app/src/auth/middlewares/permission.middleware.ts @@ -5,6 +5,7 @@ import type { RouterRoute } from "hono/types"; import { createMiddleware } from "hono/factory"; import type { ServerEnv } from "modules/Controller"; import type { MaybePromise } from "core/types"; +import { GuardPermissionsException } from "auth/authorize/Guard"; function getPath(reqOrCtx: Request | Context) { const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; @@ -54,7 +55,7 @@ export function permission

>( if (options?.onGranted || options?.onDenied) { let returned: undefined | void | Response; - if (threw(() => guard.granted(permission, c, context))) { + if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) { returned = await options?.onDenied?.(c); } else { returned = await options?.onGranted?.(c); diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index e90921d2..24352c40 100644 --- a/app/src/core/object/query/query.ts +++ b/app/src/core/object/query/query.ts @@ -1,4 +1,5 @@ import type { PrimaryFieldType } from "core/config"; +import { getPath, invariant } from "bknd/utils"; export type Primitive = PrimaryFieldType | string | number | boolean; export function isPrimitive(value: any): value is Primitive { @@ -67,8 +68,9 @@ function _convert( expressions: Exps, path: string[] = [], ): FilterQuery { + invariant(typeof $query === "object", "$query must be an object"); const ExpressionConditionKeys = expressions.map((e) => e.key); - const keys = Object.keys($query); + const keys = Object.keys($query ?? {}); const operands = [OperandOr] as const; const newQuery: FilterQuery = {}; @@ -157,7 +159,7 @@ function _build( // check $and for (const [key, value] of Object.entries($and)) { for (const [$op, $v] of Object.entries(value)) { - const objValue = options.value_is_kv ? key : options.object[key]; + const objValue = options.value_is_kv ? key : getPath(options.object, key); result.$and.push(__validate($op, $v, objValue, [key])); result.keys.add(key); } @@ -165,7 +167,7 @@ function _build( // check $or for (const [key, value] of Object.entries($or ?? {})) { - const objValue = options.value_is_kv ? key : options.object[key]; + const objValue = options.value_is_kv ? key : getPath(options.object, key); for (const [$op, $v] of Object.entries(value)) { result.$or.push(__validate($op, $v, objValue, [key])); diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 9b8e3850..5b943ff4 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -62,11 +62,18 @@ export function invariant(condition: boolean | any, message: string) { } } -export function threw(fn: () => any) { +export function threw(fn: () => any, instance?: new (...args: any[]) => Error) { try { 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; } } From 6624927286f8cfe5459878d6bfb6ab834ce8fea5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 14 Oct 2025 16:36:16 +0200 Subject: [PATCH 12/48] enhance form field components and add JsonEditor support - Updated `ObjectField`, `ArrayField`, and `FieldWrapper` components to improve flexibility and integration options by supporting additional props like `wrapperProps`. - Added `JsonEditor` for enhanced object editing capabilities with state management and safety checks. - Refactored utility functions and error handling for improved stability and developer experience. - Introduced new test cases to validate `JsonEditor` functionality and schema-based forms handling. --- app/src/ui/components/code/JsonEditor.tsx | 35 +++++++- .../ui/components/form/Formy/components.tsx | 3 +- .../form/json-schema-form/ArrayField.tsx | 83 ++++++++++++------- .../form/json-schema-form/Field.tsx | 2 +- .../form/json-schema-form/FieldWrapper.tsx | 7 +- .../form/json-schema-form/ObjectField.tsx | 10 ++- .../components/form/json-schema-form/utils.ts | 15 ++-- .../ui/routes/test/tests/code-editor-test.tsx | 13 +++ .../routes/test/tests/json-schema-form3.tsx | 49 ++++++++++- 9 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 app/src/ui/routes/test/tests/code-editor-test.tsx diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx index ec96811e..c65e59ac 100644 --- a/app/src/ui/components/code/JsonEditor.tsx +++ b/app/src/ui/components/code/JsonEditor.tsx @@ -1,9 +1,37 @@ -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useState } from "react"; import { twMerge } from "tailwind-merge"; import type { CodeEditorProps } from "./CodeEditor"; const CodeEditor = lazy(() => import("./CodeEditor")); -export function JsonEditor({ editable, className, ...props }: CodeEditorProps) { +export type JsonEditorProps = Omit & { + value?: object; + onChange?: (value: object) => void; + emptyAs?: "null" | "undefined"; +}; + +export function JsonEditor({ + editable, + className, + value, + onChange, + onBlur, + emptyAs = "undefined", + ...props +}: JsonEditorProps) { + const [editorValue, setEditorValue] = useState( + JSON.stringify(value, null, 2), + ); + const handleChange = (given: string) => { + const value = given === "" ? (emptyAs === "null" ? null : undefined) : given; + try { + setEditorValue(value); + onChange?.(value ? JSON.parse(value) : value); + } catch (e) {} + }; + const handleBlur = (e) => { + setEditorValue(JSON.stringify(value, null, 2)); + onBlur?.(e); + }; return ( diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index 502a8449..3ad9146a 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -28,8 +28,9 @@ export const Group = ({ return ( { +export type ArrayFieldProps = { + path?: string; + labelAdd?: string; + wrapperProps?: Omit; +}; + +export const ArrayField = ({ + path = "", + labelAdd = "Add", + wrapperProps = { wrapper: "fieldset" }, +}: ArrayFieldProps) => { const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path); if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`; // if unique items with enum if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) { return ( - + { } return ( - + {({ value }) => value?.map((v, index: number) => ( @@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => { }

- +
); }; const ArrayItem = memo(({ path, index, schema }: any) => { - const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => { + const { + value, + path: absolutePath, + ...ctx + } = useDerivedFieldContext(path, (ctx) => { return ctx.value?.[index]; }); - const itemPath = suffixPath(path, index); + const itemPath = suffixPath(absolutePath, index); let subschema = schema.items; const itemsMultiSchema = getMultiSchema(schema.items); if (itemsMultiSchema) { @@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => { subschema = _subschema; } - const handleUpdate = useEvent((pointer: string, value: any) => { - ctx.setValue(pointer, value); - }); - const handleDelete = useEvent((pointer: string) => { ctx.deleteValue(pointer); }); @@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => { ); return ( -
- { - handleUpdate(itemPath, coerce(e.target.value, subschema!)); - }} - className="w-full" - /> - {DeleteButton} -
+ +
+ {/* another wrap is required for primitive schemas */} + + {DeleteButton} +
+
); }, isEqual); +const AnotherField = (props: Partial) => { + const { value } = useFormValue(""); + + const inputProps = { + // @todo: check, potentially just provide value + value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined, + }; + return ; +}; + const ArrayIterator = memo( ({ name, children }: any) => { return children(useFormValue(name)); @@ -98,19 +113,25 @@ const ArrayIterator = memo( (prev, next) => prev.value?.length === next.value?.length, ); -const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => { +const ArrayAdd = ({ + schema, + path: _path, + label = "Add", +}: { schema: JsonSchema; path: string; label?: string }) => { const { setValue, value: { currentIndex }, + path, ...ctx - } = useDerivedFieldContext(path, (ctx) => { + } = useDerivedFieldContext(_path, (ctx) => { return { currentIndex: ctx.value?.length ?? 0 }; }); const itemsMultiSchema = getMultiSchema(schema.items); + const options = { addOptionalProps: true }; function handleAdd(template?: any) { const newPath = suffixPath(path, currentIndex); - setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items)); + setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options)); } if (itemsMultiSchema) { @@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => { }} items={itemsMultiSchema.map((s, i) => ({ label: s!.title ?? `Option ${i + 1}`, - onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)), + onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)), }))} onClickItem={console.log} > - + ); } - return ; + return ; }; diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index 60351ca8..bc81a83c 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -72,7 +72,7 @@ const FieldImpl = ({ ); if (isType(schema.type, "object")) { - return ; + return ; } if (isType(schema.type, "array")) { diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx index 784db35a..af4607ca 100644 --- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx +++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx @@ -11,6 +11,7 @@ import { } from "ui/components/form/json-schema-form/Form"; import { Popover } from "ui/components/overlay/Popover"; import { getLabel } from "./utils"; +import { twMerge } from "tailwind-merge"; export type FieldwrapperProps = { name: string; @@ -25,6 +26,7 @@ export type FieldwrapperProps = { description?: string; descriptionPlacement?: "top" | "bottom"; fieldId?: string; + className?: string; }; export function FieldWrapper({ @@ -38,6 +40,7 @@ export function FieldWrapper({ descriptionPlacement = "bottom", children, fieldId, + className, ...props }: FieldwrapperProps) { const errors = useFormError(name, { strict: true }); @@ -60,7 +63,7 @@ export function FieldWrapper({ 0} as={wrapper === "fieldset" ? "fieldset" : "div"} - className={hidden ? "hidden" : "relative"} + className={twMerge(hidden ? "hidden" : "relative", className)} > {errorPlacement === "top" && Errors} @@ -76,7 +79,7 @@ export function FieldWrapper({ )} {descriptionPlacement === "top" && Description} -
+
{Children.count(children) === 1 && isValidElement(children) ? cloneElement(children, { diff --git a/app/src/ui/components/form/json-schema-form/ObjectField.tsx b/app/src/ui/components/form/json-schema-form/ObjectField.tsx index 59decebf..3cf920be 100644 --- a/app/src/ui/components/form/json-schema-form/ObjectField.tsx +++ b/app/src/ui/components/form/json-schema-form/ObjectField.tsx @@ -11,7 +11,7 @@ export type ObjectFieldProps = { }; export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => { - const { schema, ...ctx } = useDerivedFieldContext(path); + const { schema } = useDerivedFieldContext(path); if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`; const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][]; @@ -24,7 +24,7 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj {...wrapperProps} > {properties.length === 0 ? ( - No properties + ) : ( properties.map(([prop, schema]) => { const name = [path, prop].filter(Boolean).join("."); @@ -40,3 +40,9 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj ); }; + +export const ObjectJsonField = ({ path }: { path: string }) => { + const { value } = useFormValue(path); + const { setValue, path: absolutePath } = useDerivedFieldContext(path); + return setValue(absolutePath, value)} />; +}; diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 333bba3c..7b755cfa 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -67,18 +67,23 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data return false; } - const childSchema = lib.getSchema({ pointer, data, schema }); - if (typeof childSchema === "object" && "const" in childSchema) { - return true; + try { + const childSchema = lib.getSchema({ pointer, data, schema }); + if (typeof childSchema === "object" && "const" in childSchema) { + return true; + } + } catch (e) { + return false; } const parentPointer = getParentPointer(pointer); const parentSchema = lib.getSchema({ pointer: parentPointer, data }); - const required = parentSchema?.required?.includes(pointer.split("/").pop()!); + const l = pointer.split("/").pop(); + const required = parentSchema?.required?.includes(l); return !!required; } catch (e) { - console.error("isRequired", { pointer, schema, data, e }); + console.warn("isRequired", { pointer, schema, data, e }); return false; } } diff --git a/app/src/ui/routes/test/tests/code-editor-test.tsx b/app/src/ui/routes/test/tests/code-editor-test.tsx new file mode 100644 index 00000000..99bcee18 --- /dev/null +++ b/app/src/ui/routes/test/tests/code-editor-test.tsx @@ -0,0 +1,13 @@ +import { useState } from "react"; +import { JsonEditor } from "ui/components/code/JsonEditor"; +import { JsonViewer } from "ui/components/code/JsonViewer"; + +export default function CodeEditorTest() { + const [value, setValue] = useState({}); + return ( +
+ + +
+ ); +} diff --git a/app/src/ui/routes/test/tests/json-schema-form3.tsx b/app/src/ui/routes/test/tests/json-schema-form3.tsx index 401ab1f6..f1d219cf 100644 --- a/app/src/ui/routes/test/tests/json-schema-form3.tsx +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -56,6 +56,14 @@ const authSchema = { }, } as const satisfies JSONSchema; +const objectCodeSchema = { + type: "object", + properties: { + name: { type: "string" }, + config: { type: "object", properties: {} }, + }, +}; + const formOptions = { debug: true, }; @@ -77,6 +85,45 @@ export default function JsonSchemaForm3() { {/*
*/} + + + + + {/* + /> */} {/* console.log("change", data)} From 1b8ce41837362d0fc48c4eb901d9c11d547ad7dd Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 14 Oct 2025 16:36:42 +0200 Subject: [PATCH 13/48] role and permission handling in auth module - Updated the `Role` class to change the `create` method signature for improved clarity and flexibility. - Refactored the `guardRoleSchema` to utilize the new `roleSchema` for better consistency. - Introduced a new `TPermission` type to enhance type safety in permission handling across the application. - Updated various components and forms to accommodate the new permission structure, ensuring backward compatibility. - Enhanced the `AuthRolesEdit` and `AuthRolesList` components to improve role management and permissions display. - Added new API endpoints for fetching permissions, improving the overall functionality of the auth module. --- app/src/auth/AppAuth.ts | 5 +- app/src/auth/auth-schema.ts | 10 +- app/src/auth/authorize/Permission.ts | 7 + app/src/auth/authorize/Role.ts | 7 +- app/src/modules/server/SystemController.ts | 19 +- app/src/ui/client/BkndProvider.tsx | 3 +- .../form/json-schema-form/ObjectField.tsx | 3 +- .../ui/routes/auth/auth.roles.edit.$role.tsx | 217 +++++++++++++++--- app/src/ui/routes/auth/auth.roles.tsx | 17 +- app/src/ui/routes/auth/forms/role.form.tsx | 12 +- .../routes/settings/routes/auth.settings.tsx | 4 +- app/src/ui/routes/test/index.tsx | 2 + 12 files changed, 254 insertions(+), 52 deletions(-) diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index a0c60727..cead597c 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -61,7 +61,7 @@ export class AppAuth extends Module { // register roles const roles = transformObject(this.config.roles ?? {}, (role, name) => { - return Role.create({ name, ...role }); + return Role.create(name, role); }); this.ctx.guard.setRoles(Object.values(roles)); this.ctx.guard.setConfig(this.config.guard ?? {}); @@ -210,10 +210,13 @@ export class AppAuth extends Module { } const strategies = this.authenticator.getStrategies(); + const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()])); + console.log("roles", roles); return { ...this.config, ...this.authenticator.toJSON(secrets), + roles: secrets ? roles : undefined, strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), ...strategy.toJSON(secrets), diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 4fd40a4c..e479ea1e 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,6 +1,7 @@ import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; -import { objectTransform, s } from "bknd/utils"; +import { roleSchema } from "auth/authorize/Role"; +import { objectTransform, omitKeys, pick, s } from "bknd/utils"; import { $object, $record } from "modules/mcp"; export const Strategies = { @@ -40,11 +41,8 @@ export type AppAuthCustomOAuthStrategy = s.Static; export type PermissionContext

> = P extends Permission< any, diff --git a/app/src/auth/authorize/Role.ts b/app/src/auth/authorize/Role.ts index 3f072a1f..7506fc78 100644 --- a/app/src/auth/authorize/Role.ts +++ b/app/src/auth/authorize/Role.ts @@ -13,7 +13,7 @@ export const rolePermissionSchema = s.strictObject({ export type RolePermissionSchema = s.Static; export const roleSchema = s.strictObject({ - name: s.string(), + // @todo: remove anyOf, add migration permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(), is_default: s.boolean().optional(), implicit_allow: s.boolean().optional(), @@ -44,7 +44,7 @@ export class Role { public implicit_allow: boolean = false, ) {} - static create(config: RoleSchema) { + static create(name: string, config: RoleSchema) { const permissions = config.permissions?.map((p: string | RolePermissionSchema) => { if (typeof p === "string") { @@ -53,12 +53,11 @@ export class Role { const policies = p.policies?.map((policy) => new Policy(policy)); return new RolePermission(new Permission(p.permission), policies, p.effect); }) ?? []; - return new Role(config.name, permissions, config.is_default, config.implicit_allow); + return new Role(name, permissions, config.is_default, config.implicit_allow); } toJSON() { return { - name: this.name, permissions: this.permissions.map((p) => p.toJSON()), is_default: this.is_default, implicit_allow: this.implicit_allow, diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 4469c7ef..45787bf9 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -32,6 +32,7 @@ import { getVersion } from "core/env"; import type { Module } from "modules/Module"; import { getSystemMcp } from "modules/mcp/system-mcp"; import type { DbModuleManager } from "modules/db/DbModuleManager"; +import type { TPermission } from "auth/authorize/Permission"; export type ConfigUpdate = { success: true; @@ -46,7 +47,8 @@ export type SchemaResponse = { schema: ModuleSchemas; readonly: boolean; config: ModuleConfigs; - permissions: string[]; + //permissions: string[]; + permissions: TPermission[]; }; export class SystemController extends Controller { @@ -412,11 +414,24 @@ export class SystemController extends Controller { readonly, schema, config: config ? this.app.toJSON(secrets) : undefined, - permissions: this.app.modules.ctx().guard.getPermissionNames(), + permissions: this.app.modules.ctx().guard.getPermissions(), + //permissions: this.app.modules.ctx().guard.getPermissionNames(), }); }, ); + hono.get( + "/permissions", + describeRoute({ + summary: "Get the permissions", + tags: ["system"], + }), + (c) => { + const permissions = this.app.modules.ctx().guard.getPermissions(); + return c.json({ permissions }); + }, + ); + hono.post( "/build", describeRoute({ diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index abb0020a..c37d1820 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced"; import { Message } from "ui/components/display/Message"; import { useNavigate } from "ui/lib/routes"; import type { BkndAdminProps } from "ui/Admin"; +import type { TPermission } from "auth/authorize/Permission"; export type BkndContext = { version: number; readonly: boolean; schema: ModuleSchemas; config: ModuleConfigs; - permissions: string[]; + permissions: TPermission[]; hasSecrets: boolean; requireSecrets: () => Promise; actions: ReturnType; diff --git a/app/src/ui/components/form/json-schema-form/ObjectField.tsx b/app/src/ui/components/form/json-schema-form/ObjectField.tsx index 3cf920be..748bf23d 100644 --- a/app/src/ui/components/form/json-schema-form/ObjectField.tsx +++ b/app/src/ui/components/form/json-schema-form/ObjectField.tsx @@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils"; import { AnyOfField } from "./AnyOfField"; import { Field } from "./Field"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; -import { type JSONSchema, useDerivedFieldContext } from "./Form"; +import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form"; +import { JsonEditor } from "ui/components/code/JsonEditor"; export type ObjectFieldProps = { path?: string; diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index 7ee4ea63..ff7c39d3 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -1,17 +1,38 @@ -import { useRef } from "react"; -import { TbDots } from "react-icons/tb"; import { useBknd } from "ui/client/bknd"; -import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; -import { Button } from "ui/components/buttons/Button"; -import { IconButton } from "ui/components/buttons/IconButton"; import { Message } from "ui/components/display/Message"; +import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; +import { useRef, useState } from "react"; +import { useNavigate } from "ui/lib/routes"; +import { isDebug } from "core/env"; import { Dropdown } from "ui/components/overlay/Dropdown"; -import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { IconButton } from "ui/components/buttons/IconButton"; +import { TbAdjustments, TbDots, TbLock, TbLockOpen, TbLockOpen2 } from "react-icons/tb"; +import { Button } from "ui/components/buttons/Button"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; -import { routes, useNavigate } from "ui/lib/routes"; -import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form"; +import { routes } from "ui/lib/routes"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; +import * as Formy from "ui/components/form/Formy"; + +import { ucFirst, type s } from "bknd/utils"; +import type { ModuleSchemas } from "bknd"; +import { + ArrayField, + Field, + Form, + FormDebug, + Subscribe, + useFormContext, + useFormValue, +} from "ui/components/form/json-schema-form"; +import type { TPermission } from "auth/authorize/Permission"; +import type { RoleSchema } from "auth/authorize/Role"; +import { SegmentedControl, Tooltip } from "@mantine/core"; +import { cn } from "ui/lib/utils"; export function AuthRolesEdit(props) { + useBrowserTitle(["Auth", "Roles", props.params.role]); + const { hasSecrets } = useBknd({ withSecrets: true }); if (!hasSecrets) { return ; @@ -20,32 +41,46 @@ export function AuthRolesEdit(props) { return ; } +// currently for backward compatibility +function getSchema(authSchema: ModuleSchemas["auth"]) { + const roles = authSchema.properties.roles.additionalProperties; + return { + ...roles, + properties: { + ...roles.properties, + permissions: { + ...roles.properties.permissions.anyOf[1], + }, + }, + }; +} + +const formConfig = { + options: { + debug: isDebug(), + }, +}; + function AuthRolesEditInternal({ params }) { const [navigate] = useNavigate(); - const { config, actions } = useBkndAuth(); + const { config, schema: authSchema, actions } = useBkndAuth(); const roleName = params.role; const role = config.roles?.[roleName]; - const formRef = useRef(null); const { readonly } = useBknd(); + const schema = getSchema(authSchema); - async function handleUpdate() { - console.log("data", formRef.current?.isValid()); - if (!formRef.current?.isValid()) return; - const data = formRef.current?.getData(); + async function handleDelete() {} + async function handleUpdate(data: any) { + console.log("data", data); const success = await actions.roles.patch(roleName, data); - if (success) { + console.log("success", success); + /* if (success) { navigate(routes.auth.roles.list()); - } - } - - async function handleDelete() { - if (await actions.roles.delete(roleName)) { - navigate(routes.auth.roles.list()); - } + } */ } return ( - <> + @@ -69,9 +104,23 @@ function AuthRolesEditInternal({ params }) { {!readonly && ( - + ({ + dirty: state.dirty, + errors: state.errors.length > 0, + submitting: state.submitting, + })} + > + {({ dirty, errors, submitting }) => ( + + )} + )} } @@ -85,8 +134,120 @@ function AuthRolesEditInternal({ params }) { /> - +

+
+ +
+ +
+ + +
+
+ - + ); } + +type PermissionsData = Exclude; +type PermissionData = PermissionsData[number]; + +const Permissions = () => { + const { permissions } = useBknd(); + + const grouped = permissions.reduce( + (acc, permission, index) => { + const [group, name] = permission.name.split(".") as [string, string]; + if (!acc[group]) acc[group] = []; + acc[group].push({ index, permission }); + return acc; + }, + {} as Record, + ); + + return ( +
+ {Object.entries(grouped).map(([group, rows]) => { + return ( +
+

{ucFirst(group)} Permissions

+
+ {rows.map(({ index, permission }) => ( + + ))} +
+
+ ); + })} +
+ ); +}; + +const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => { + const path = `permissions.${index}`; + const { value } = useFormValue(path); + const { setValue, deleteValue } = useFormContext(); + const [open, setOpen] = useState(false); + const data = value as PermissionData | undefined; + + async function handleSwitch() { + if (data) { + deleteValue(path); + } else { + setValue(path, { + permission: permission.name, + policies: [], + effect: "allow", + }); + } + } + + return ( + <> +
+
+
{permission.name}
+
+ + + setOpen((o) => !o)} + /> + +
+
+ {open && ( +
+ +
+ )} +
+ + ); +}; diff --git a/app/src/ui/routes/auth/auth.roles.tsx b/app/src/ui/routes/auth/auth.roles.tsx index 59c7e22e..15596bf3 100644 --- a/app/src/ui/routes/auth/auth.roles.tsx +++ b/app/src/ui/routes/auth/auth.roles.tsx @@ -12,8 +12,21 @@ import { CellValue, DataTable } from "../../components/table/DataTable"; import * as AppShell from "../../layouts/AppShell/AppShell"; import { routes, useNavigate } from "../../lib/routes"; import { useBknd } from "ui/client/bknd"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; +import { Message } from "ui/components/display/Message"; -export function AuthRolesList() { +export function AuthRolesList(props) { + useBrowserTitle(["Auth", "Roles"]); + + const { hasSecrets } = useBknd({ withSecrets: true }); + if (!hasSecrets) { + return ; + } + + return ; +} + +function AuthRolesListInternal() { const [navigate] = useNavigate(); const { config, actions } = useBkndAuth(); const { readonly } = useBknd(); @@ -21,7 +34,7 @@ export function AuthRolesList() { const data = Object.values( transformObject(config.roles ?? {}, (role, name) => ({ role: name, - permissions: role.permissions, + permissions: role.permissions?.map((p) => p.permission) as string[], is_default: role.is_default ?? false, implicit_allow: role.implicit_allow ?? false, })), diff --git a/app/src/ui/routes/auth/forms/role.form.tsx b/app/src/ui/routes/auth/forms/role.form.tsx index 0a16d2de..d1b7f510 100644 --- a/app/src/ui/routes/auth/forms/role.form.tsx +++ b/app/src/ui/routes/auth/forms/role.form.tsx @@ -34,7 +34,11 @@ export const AuthRoleForm = forwardRef< getValues, } = useForm({ resolver: standardSchemaResolver(schema), - defaultValues: role, + defaultValues: { + ...role, + // compat + permissions: role?.permissions?.map((p) => p.permission), + }, }); useImperativeHandle(ref, () => ({ @@ -47,7 +51,7 @@ export const AuthRoleForm = forwardRef<
{/*

Role Permissions

*/} - + p.name)} />
, ); - console.log("grouped", grouped); - //console.log("fieldState", fieldState, value); return (
{Object.entries(grouped).map(([group, permissions]) => { @@ -121,7 +123,7 @@ const Permissions = ({

{ucFirst(group)} Permissions

{permissions.map((permission) => { - const selected = data.includes(permission); + const selected = data.includes(permission as any); return (
diff --git a/app/src/ui/routes/settings/routes/auth.settings.tsx b/app/src/ui/routes/settings/routes/auth.settings.tsx index 6432570a..a5faf5fd 100644 --- a/app/src/ui/routes/settings/routes/auth.settings.tsx +++ b/app/src/ui/routes/settings/routes/auth.settings.tsx @@ -63,10 +63,10 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => { } catch (e) {} console.log("_s", _s); const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" }; - if (_s.permissions) { + /* if (_s.permissions) { roleSchema.properties.permissions.items.enum = _s.permissions; roleSchema.properties.permissions.uniqueItems = true; - } + } */ return ( diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index 71bb87fc..95681fd7 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -27,6 +27,7 @@ import SortableTest from "./tests/sortable-test"; import { SqlAiTest } from "./tests/sql-ai-test"; import Themes from "./tests/themes"; import ErrorBoundary from "ui/components/display/ErrorBoundary"; +import CodeEditorTest from "./tests/code-editor-test"; const tests = { DropdownTest, @@ -52,6 +53,7 @@ const tests = { JsonSchemaForm3, FormyTest, HtmlFormTest, + CodeEditorTest, } as const; export default function TestRoutes() { From 0347efa592d16743544619ae4d49551808e81b77 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 14 Oct 2025 16:49:42 +0200 Subject: [PATCH 14/48] fix Role creation method and permission checks in tests --- app/__test__/app/mcp/mcp.auth.test.ts | 11 ++++++++--- app/__test__/auth/authorize/authorize.spec.ts | 2 +- app/__test__/auth/authorize/permissions.spec.ts | 4 ++-- app/src/auth/AppAuth.ts | 5 ++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts index e7658b45..2636730e 100644 --- a/app/__test__/app/mcp/mcp.auth.test.ts +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -201,7 +201,10 @@ describe("mcp auth", async () => { }, return_config: true, }); - expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]); + expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([ + "read", + "write", + ]); // update role await tool(server, "config_auth_roles_update", { @@ -210,13 +213,15 @@ describe("mcp auth", async () => { permissions: ["read"], }, }); - expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]); + expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([ + "read", + ]); // get role const getGuestRole = await tool(server, "config_auth_roles_get", { key: "guest", }); - expect(getGuestRole.value.permissions).toEqual(["read"]); + expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]); // remove role await tool(server, "config_auth_roles_remove", { diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index 5e39fb8c..b13935a2 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -11,7 +11,7 @@ function createGuard( ) { const _roles = roles ? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => { - return Role.create({ name, permissions, is_default, implicit_allow }); + return Role.create(name, { permissions, is_default, implicit_allow }); }) : {}; const _permissions = permissionNames.map((name) => new Permission(name)); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index f885d6e1..78abdd07 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -252,7 +252,7 @@ describe("permission middleware", () => { it("allows if user has (plain) role", async () => { const p = new Permission("test"); - const r = Role.create({ name: "test", permissions: [p.name] }); + const r = Role.create("test", { permissions: [p.name] }); const hono = makeApp([p], [r]) .use(async (c, next) => { // @ts-expect-error @@ -512,7 +512,7 @@ describe("Role", () => { true, ); const json = JSON.parse(JSON.stringify(r.toJSON())); - const r2 = Role.create(json); + const r2 = Role.create(p.name, json); expect(r2.toJSON()).toEqual(r.toJSON()); }); }); diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index cead597c..a73f9ec8 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -2,7 +2,7 @@ import type { DB, PrimaryFieldType } from "bknd"; import * as AuthPermissions from "auth/auth-permissions"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; -import { $console, secureRandomString, transformObject } from "bknd/utils"; +import { $console, secureRandomString, transformObject, pick } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { Module } from "modules/Module"; @@ -211,12 +211,11 @@ export class AppAuth extends Module { const strategies = this.authenticator.getStrategies(); const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()])); - console.log("roles", roles); return { ...this.config, ...this.authenticator.toJSON(secrets), - roles: secrets ? roles : undefined, + roles, strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), ...strategy.toJSON(secrets), From 9070f965710356b37d23fefdcd801c6c73c65c8b Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 15 Oct 2025 18:41:04 +0200 Subject: [PATCH 15/48] feat: enhance API and AuthApi with credentials support and async storage handling - Added `credentials` option to `ApiOptions` and `BaseModuleApiOptions` for better request handling. - Updated `AuthApi` to pass `verified` status during token updates. - Refactored storage handling in `Api` to support async operations using a Proxy. - Improved `Authenticator` to handle cookie domain configuration and JSON request detection. - Adjusted `useAuth` to ensure logout and verify methods return promises for better async handling. - Fixed navigation URL construction in `useNavigate` and updated context menu actions in `_data.root.tsx`. --- app/src/Api.ts | 78 +++++++++++++--------- app/src/auth/api/AuthApi.ts | 21 +++--- app/src/auth/authenticate/Authenticator.ts | 7 +- app/src/modules/ModuleApi.ts | 2 + app/src/modules/server/AppServer.ts | 9 ++- app/src/ui/client/ClientProvider.tsx | 9 ++- app/src/ui/client/schema/auth/use-auth.ts | 9 +-- app/src/ui/lib/routes.ts | 2 +- app/src/ui/routes/data/_data.root.tsx | 4 +- 9 files changed, 85 insertions(+), 56 deletions(-) diff --git a/app/src/Api.ts b/app/src/Api.ts index 28d2ef05..fd4394af 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -40,6 +40,7 @@ export type ApiOptions = { data?: SubApiOptions; auth?: SubApiOptions; media?: SubApiOptions; + credentials?: RequestCredentials; } & ( | { token?: string; @@ -67,7 +68,7 @@ export class Api { public auth!: AuthApi; public media!: MediaApi; - constructor(private options: ApiOptions = {}) { + constructor(public options: ApiOptions = {}) { // only mark verified if forced this.verified = options.verified === true; @@ -129,29 +130,45 @@ export class Api { } else if (this.storage) { this.storage.getItem(this.tokenKey).then((token) => { this.token_transport = "header"; - this.updateToken(token ? String(token) : undefined); + this.updateToken(token ? String(token) : undefined, { + verified: true, + trigger: false, + }); }); } } + /** + * Make storage async to allow async storages even if sync given + * @private + */ private get storage() { - if (!this.options.storage) return null; - return { - getItem: async (key: string) => { - return await this.options.storage!.getItem(key); + const storage = this.options.storage; + return new Proxy( + {}, + { + get(_, prop) { + return (...args: any[]) => { + const response = storage ? storage[prop](...args) : undefined; + if (response instanceof Promise) { + return response; + } + return { + // biome-ignore lint/suspicious/noThenProperty: it's a promise :) + then: (fn) => fn(response), + }; + }; + }, }, - setItem: async (key: string, value: string) => { - return await this.options.storage!.setItem(key, value); - }, - removeItem: async (key: string) => { - return await this.options.storage!.removeItem(key); - }, - }; + ) as any; } - updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { + updateToken( + token?: string, + opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean }, + ) { this.token = token; - this.verified = false; + this.verified = opts?.verified === true; if (token) { this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any; @@ -159,21 +176,22 @@ export class Api { this.user = undefined; } + const emit = () => { + if (opts?.trigger !== false) { + this.options.onAuthStateChange?.(this.getAuthState()); + } + }; if (this.storage) { const key = this.tokenKey; if (token) { - this.storage.setItem(key, token).then(() => { - this.options.onAuthStateChange?.(this.getAuthState()); - }); + this.storage.setItem(key, token).then(emit); } else { - this.storage.removeItem(key).then(() => { - this.options.onAuthStateChange?.(this.getAuthState()); - }); + this.storage.removeItem(key).then(emit); } } else { if (opts?.trigger !== false) { - this.options.onAuthStateChange?.(this.getAuthState()); + emit(); } } @@ -182,6 +200,7 @@ export class Api { private markAuthVerified(verfied: boolean) { this.verified = verfied; + this.options.onAuthStateChange?.(this.getAuthState()); return this; } @@ -208,11 +227,6 @@ export class Api { } async verifyAuth() { - if (!this.token) { - this.markAuthVerified(false); - return; - } - try { const { ok, data } = await this.auth.me(); const user = data?.user; @@ -221,10 +235,10 @@ export class Api { } this.user = user; - this.markAuthVerified(true); } catch (e) { - this.markAuthVerified(false); this.updateToken(undefined); + } finally { + this.markAuthVerified(true); } } @@ -239,6 +253,7 @@ export class Api { headers: this.options.headers, token_transport: this.token_transport, verbose: this.options.verbose, + credentials: this.options.credentials, }); } @@ -257,10 +272,9 @@ export class Api { this.auth = new AuthApi( { ...baseParams, - credentials: this.options.storage ? "omit" : "include", ...this.options.auth, - onTokenUpdate: (token) => { - this.updateToken(token, { rebuild: true }); + onTokenUpdate: (token, verified) => { + this.updateToken(token, { rebuild: true, verified, trigger: true }); this.options.auth?.onTokenUpdate?.(token); }, }, diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index cd22ada4..e3c0843f 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; export type AuthApiOptions = BaseModuleApiOptions & { - onTokenUpdate?: (token?: string) => void | Promise; + onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise; credentials?: "include" | "same-origin" | "omit"; }; @@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi { } async login(strategy: string, input: any) { - const res = await this.post([strategy, "login"], input, { - credentials: this.options.credentials, - }); + const res = await this.post([strategy, "login"], input); if (res.ok && res.body.token) { - await this.options.onTokenUpdate?.(res.body.token); + await this.options.onTokenUpdate?.(res.body.token, true); } return res; } async register(strategy: string, input: any) { - const res = await this.post([strategy, "register"], input, { - credentials: this.options.credentials, - }); + const res = await this.post([strategy, "register"], input); if (res.ok && res.body.token) { - await this.options.onTokenUpdate?.(res.body.token); + await this.options.onTokenUpdate?.(res.body.token, true); } return res; } @@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi { } async logout() { - await this.options.onTokenUpdate?.(undefined); + return this.get(["logout"], undefined, { + headers: { + // this way bknd detects a json request and doesn't redirect back + Accept: "application/json", + }, + }).then(() => this.options.onTokenUpdate?.(undefined, true)); } } diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 52a2e42e..465cbd12 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -44,6 +44,7 @@ export interface UserPool { const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds export const cookieConfig = s .strictObject({ + domain: s.string().optional(), path: s.string({ default: "/" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), secure: s.boolean({ default: true }), @@ -290,6 +291,7 @@ export class Authenticator< return { ...cookieConfig, + domain: cookieConfig.domain ?? undefined, expires: new Date(Date.now() + expires * 1000), }; } @@ -354,7 +356,10 @@ export class Authenticator< // @todo: move this to a server helper isJsonRequest(c: Context): boolean { - return c.req.header("Content-Type") === "application/json"; + return ( + c.req.header("Content-Type") === "application/json" || + c.req.header("Accept") === "application/json" + ); } async getBody(c: Context) { diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index f89fb999..9b9ebb7c 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -8,6 +8,7 @@ export type BaseModuleApiOptions = { host: string; basepath?: string; token?: string; + credentials?: RequestCredentials; headers?: Headers; token_transport?: "header" | "cookie" | "none"; verbose?: boolean; @@ -106,6 +107,7 @@ export abstract class ModuleApi { } override async build() { - const origin = this.config.cors.origin ?? ""; + const origin = this.config.cors.origin ?? "*"; + const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin]; + const all_origins = origins.includes("*"); this.client.use( "*", cors({ - origin: origin.includes(",") ? origin.split(",").map((o) => o.trim()) : origin, + origin: (origin: string) => { + if (all_origins) return origin; + return origins.includes(origin) ? origin : undefined; + }, allowMethods: this.config.cors.allow_methods, allowHeaders: this.config.cors.allow_headers, credentials: this.config.cors.allow_credentials, diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 13352d14..31dbae64 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -44,18 +44,17 @@ export const ClientProvider = ({ ...apiProps, verbose: isDebug(), onAuthStateChange: (state) => { + const { token, ...rest } = state; props.onAuthStateChange?.(state); - if (!authState?.token || state.token !== authState?.token) { - setAuthState(state); + if (!authState?.token || token !== authState?.token) { + setAuthState(rest); } }, }), [JSON.stringify(apiProps)], ); - const [authState, setAuthState] = useState | undefined>( - apiProps.user ? api.getAuthState() : undefined, - ); + const [authState, setAuthState] = useState | undefined>(api.getAuthState()); return ( diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index e3fb4a6f..291c9636 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -16,8 +16,8 @@ type UseAuth = { verified: boolean; login: (data: LoginData) => Promise; register: (data: LoginData) => Promise; - logout: () => void; - verify: () => void; + logout: () => Promise; + verify: () => Promise; setToken: (token: string) => void; }; @@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => { } async function logout() { - api.updateToken(undefined); - invalidate(); + await api.auth.logout(); + await invalidate(); } async function verify() { await api.verifyAuth(); + await invalidate(); } return { diff --git a/app/src/ui/lib/routes.ts b/app/src/ui/lib/routes.ts index 7243099a..46ed4fbe 100644 --- a/app/src/ui/lib/routes.ts +++ b/app/src/ui/lib/routes.ts @@ -95,7 +95,7 @@ export function useNavigate() { window.location.href = url; return; } else if ("target" in options) { - const _url = window.location.origin + basepath + router.base + url; + const _url = window.location.origin + router.base + url; window.open(_url, options.target); return; } diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index fb4bc2f1..ba27bf91 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -215,7 +215,9 @@ const EntityContextMenu = ({ href && { icon: IconExternalLink, label: "Open in tab", - onClick: () => navigate(href, { target: "_blank" }), + onClick: () => { + navigate(href, { target: "_blank", absolute: true }); + }, }, separator, !$data.system(entity.name).any && { From 511c6539fb85635771948f244e4baae2abbe2857 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 15 Oct 2025 18:46:21 +0200 Subject: [PATCH 16/48] fix: update authentication verification logic in Api tests - Adjusted test cases in Api.spec.ts to reflect the correct authentication verification state. - Updated expectations to ensure that the `isAuthVerified` method returns true when no claims are provided, aligning with the intended behavior of the API. --- app/__test__/api/Api.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/__test__/api/Api.spec.ts b/app/__test__/api/Api.spec.ts index c1041d9c..4384928d 100644 --- a/app/__test__/api/Api.spec.ts +++ b/app/__test__/api/Api.spec.ts @@ -6,13 +6,16 @@ describe("Api", async () => { it("should construct without options", () => { const api = new Api(); expect(api.baseUrl).toBe("http://localhost"); - expect(api.isAuthVerified()).toBe(false); + + // verified is true, because no token, user, headers or request given + // therefore nothing to check, auth state is verified + expect(api.isAuthVerified()).toBe(true); }); it("should ignore force verify if no claims given", () => { const api = new Api({ verified: true }); expect(api.baseUrl).toBe("http://localhost"); - expect(api.isAuthVerified()).toBe(false); + expect(api.isAuthVerified()).toBe(true); }); it("should construct from request (token)", async () => { From e68e5792be22ec8ea38c254a06202d608b52fa37 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 17 Oct 2025 08:47:00 +0200 Subject: [PATCH 17/48] set raw state to ClientProviders auth state --- app/src/ui/client/ClientProvider.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 31dbae64..88a54c1e 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -44,10 +44,9 @@ export const ClientProvider = ({ ...apiProps, verbose: isDebug(), onAuthStateChange: (state) => { - const { token, ...rest } = state; props.onAuthStateChange?.(state); - if (!authState?.token || token !== authState?.token) { - setAuthState(rest); + if (!authState?.token || state.token !== authState?.token) { + setAuthState(state); } }, }), From 22e43c2523142baa778708c837fda2fb9cfcb4bb Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 18 Oct 2025 16:58:54 +0200 Subject: [PATCH 18/48] feat: introduce new modes helpers --- app/src/App.ts | 1 + app/src/adapter/astro/astro.adapter.ts | 9 +- app/src/adapter/bun/bun.adapter.ts | 12 +- app/src/adapter/bun/index.ts | 8 + app/src/adapter/index.ts | 39 ++-- app/src/adapter/nextjs/nextjs.adapter.ts | 6 +- app/src/adapter/node/index.ts | 10 + app/src/adapter/node/node.adapter.ts | 11 +- .../react-router/react-router.adapter.ts | 6 +- app/src/core/types.ts | 4 + app/src/index.ts | 2 +- app/src/modes/code.ts | 49 +++++ app/src/modes/hybrid.ts | 88 +++++++++ app/src/modes/index.ts | 3 + app/src/modes/shared.ts | 183 ++++++++++++++++++ app/src/modules/db/DbModuleManager.ts | 7 +- app/tsconfig.json | 4 +- 17 files changed, 402 insertions(+), 40 deletions(-) create mode 100644 app/src/modes/code.ts create mode 100644 app/src/modes/hybrid.ts create mode 100644 app/src/modes/index.ts create mode 100644 app/src/modes/shared.ts diff --git a/app/src/App.ts b/app/src/App.ts index 0f535f8f..b8cbde7d 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -385,6 +385,7 @@ export class App< } } } + await this.options?.manager?.onModulesBuilt?.(ctx); } } diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 7f24923d..92a86040 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -8,12 +8,15 @@ export type AstroBkndConfig = FrameworkBkndConfig; export async function getApp( config: AstroBkndConfig = {}, - args: Env = {} as Env, + args: Env = import.meta.env as Env, ) { - return await createFrameworkApp(config, args ?? import.meta.env); + return await createFrameworkApp(config, args); } -export function serve(config: AstroBkndConfig = {}, args: Env = {} as Env) { +export function serve( + config: AstroBkndConfig = {}, + args: Env = import.meta.env as Env, +) { return async (fnArgs: TAstro) => { return (await getApp(config, args)).fetch(fnArgs.request); }; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 00b61b55..44e7ccf2 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -12,7 +12,7 @@ export type BunBkndConfig = RuntimeBkndConfig & Omit( { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); registerLocalMediaAdapter(); @@ -26,18 +26,18 @@ export async function createApp( }), ...config, }, - args ?? (process.env as Env), + args, ); } export function createHandler( config: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { let app: App | undefined; return async (req: Request) => { if (!app) { - app = await createApp(config, args ?? (process.env as Env)); + app = await createApp(config, args); } return app.fetch(req); }; @@ -54,9 +54,10 @@ export function serve( buildConfig, adminOptions, serveStatic, + beforeBuild, ...serveOptions }: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { Bun.serve({ ...serveOptions, @@ -71,6 +72,7 @@ export function serve( adminOptions, distPath, serveStatic, + beforeBuild, }, args, ), diff --git a/app/src/adapter/bun/index.ts b/app/src/adapter/bun/index.ts index 5f851352..a0ca1ed0 100644 --- a/app/src/adapter/bun/index.ts +++ b/app/src/adapter/bun/index.ts @@ -1,3 +1,11 @@ export * from "./bun.adapter"; export * from "../node/storage"; export * from "./connection/BunSqliteConnection"; + +export async function writer(path: string, content: string) { + await Bun.write(path, content); +} + +export async function reader(path: string) { + return await Bun.file(path).text(); +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 2548efaf..949aaba1 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -6,18 +6,23 @@ import { guessMimeType, type MaybePromise, registries as $registries, + type Merge, } from "bknd"; import { $console } from "bknd/utils"; import type { Context, MiddlewareHandler, Next } from "hono"; import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; -export type BkndConfig = CreateAppConfig & { - app?: Omit | ((args: Args) => MaybePromise, "app">>); - onBuilt?: (app: App) => MaybePromise; - beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; - buildConfig?: Parameters[0]; -}; +export type BkndConfig = Merge< + CreateAppConfig & { + app?: + | Merge & Additional> + | ((args: Args) => MaybePromise, "app"> & Additional>>); + onBuilt?: (app: App) => MaybePromise; + beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; + buildConfig?: Parameters[0]; + } & Additional +>; export type FrameworkBkndConfig = BkndConfig; @@ -51,11 +56,10 @@ export async function makeConfig( return { ...rest, ...additionalConfig }; } -// a map that contains all apps by id export async function createAdapterApp( config: Config = {} as Config, args?: Args, -): Promise { +): Promise<{ app: App; config: BkndConfig }> { await config.beforeBuild?.(undefined, $registries); const appConfig = await makeConfig(config, args); @@ -65,34 +69,37 @@ export async function createAdapterApp( config: FrameworkBkndConfig = {}, args?: Args, ): Promise { - const app = await createAdapterApp(config, args); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { if (config.onBuilt) { app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); }, "sync", ); } - await config.beforeBuild?.(app, $registries); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -103,7 +110,7 @@ export async function createRuntimeApp( { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, args?: Args, ): Promise { - const app = await createAdapterApp(config, args); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { app.emgr.onEvent( @@ -116,7 +123,7 @@ export async function createRuntimeApp( app.modules.server.get(path, handler); } - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); if (adminOptions !== false) { app.registerAdminController(adminOptions); } @@ -124,7 +131,7 @@ export async function createRuntimeApp( "sync", ); - await config.beforeBuild?.(app, $registries); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index ba0953ba..eed1c353 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -9,9 +9,9 @@ export type NextjsBkndConfig = FrameworkBkndConfig & { export async function getApp( config: NextjsBkndConfig, - args: Env = {} as Env, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? (process.env as Env)); + return await createFrameworkApp(config, args); } function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { @@ -39,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ export function serve( { cleanRequest, ...config }: NextjsBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { return async (req: Request) => { const app = await getApp(config, args); diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index b430450f..befd771e 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,3 +1,13 @@ +import { readFile, writeFile } from "node:fs/promises"; + export * from "./node.adapter"; export * from "./storage"; export * from "./connection/NodeSqliteConnection"; + +export async function writer(path: string, content: string) { + await writeFile(path, content); +} + +export async function reader(path: string) { + return await readFile(path, "utf-8"); +} diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index fd96086c..83feba84 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -17,7 +17,7 @@ export type NodeBkndConfig = RuntimeBkndConfig & { export async function createApp( { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { const root = path.relative( process.cwd(), @@ -33,19 +33,18 @@ export async function createApp( serveStatic: serveStatic({ root }), ...config, }, - // @ts-ignore - args ?? { env: process.env }, + args, ); } export function createHandler( config: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { let app: App | undefined; return async (req: Request) => { if (!app) { - app = await createApp(config, args ?? (process.env as Env)); + app = await createApp(config, args); } return app.fetch(req); }; @@ -53,7 +52,7 @@ export function createHandler( export function serve( { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { honoServe( { diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index f37260d1..f624bdee 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -8,14 +8,14 @@ export type ReactRouterBkndConfig = FrameworkBkndConfig( config: ReactRouterBkndConfig, - args: Env = {} as Env, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? process.env); + return await createFrameworkApp(config, args); } export function serve( config: ReactRouterBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { return async (fnArgs: ReactRouterFunctionArgs) => { return (await getApp(config, args)).fetch(fnArgs.request); diff --git a/app/src/core/types.ts b/app/src/core/types.ts index 03beae5d..c0550dbe 100644 --- a/app/src/core/types.ts +++ b/app/src/core/types.ts @@ -6,3 +6,7 @@ export interface Serializable { export type MaybePromise = T | Promise; export type PartialRec = { [P in keyof T]?: PartialRec }; + +export type Merge = { + [K in keyof T]: T[K]; +}; diff --git a/app/src/index.ts b/app/src/index.ts index ae011517..4f484395 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -41,7 +41,7 @@ export { getSystemMcp } from "modules/mcp/system-mcp"; /** * Core */ -export type { MaybePromise } from "core/types"; +export type { MaybePromise, Merge } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts new file mode 100644 index 00000000..30e4dc32 --- /dev/null +++ b/app/src/modes/code.ts @@ -0,0 +1,49 @@ +import type { BkndConfig } from "bknd/adapter"; +import { makeModeConfig, type BkndModeConfig } from "./shared"; +import { $console } from "bknd/utils"; + +export type BkndCodeModeConfig = BkndModeConfig; + +export type CodeMode = AdapterConfig extends BkndConfig< + infer Args +> + ? BkndModeConfig + : never; + +export function code(config: BkndCodeModeConfig): BkndConfig { + return { + ...config, + app: async (args) => { + const { + config: appConfig, + plugins, + isProd, + syncSchemaOptions, + } = await makeModeConfig(config, args); + + if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { + $console.warn("You should not set a different mode than `db` when using code mode"); + } + + return { + ...appConfig, + options: { + ...appConfig?.options, + mode: "code", + plugins, + manager: { + // skip validation in prod for a speed boost + skipValidation: isProd, + onModulesBuilt: async (ctx) => { + if (!isProd && syncSchemaOptions.force) { + $console.log("[code] syncing schema"); + await ctx.em.schema().sync(syncSchemaOptions); + } + }, + ...appConfig?.options?.manager, + }, + }, + }; + }, + }; +} diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts new file mode 100644 index 00000000..b5452706 --- /dev/null +++ b/app/src/modes/hybrid.ts @@ -0,0 +1,88 @@ +import type { BkndConfig } from "bknd/adapter"; +import { makeModeConfig, type BkndModeConfig } from "./shared"; +import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import type { DbModuleManager } from "modules/db/DbModuleManager"; +import { invariant, $console } from "bknd/utils"; + +export type BkndHybridModeOptions = { + /** + * Reader function to read the configuration from the file system. + * This is required for hybrid mode to work. + */ + reader?: (path: string) => MaybePromise; + /** + * Provided secrets to be merged into the configuration + */ + secrets?: Record; +}; + +export type HybridBkndConfig = BkndModeConfig; +export type HybridMode = AdapterConfig extends BkndConfig< + infer Args +> + ? BkndModeConfig> + : never; + +export function hybrid({ + configFilePath = "bknd-config.json", + ...rest +}: HybridBkndConfig): BkndConfig { + return { + ...rest, + config: undefined, + app: async (args) => { + const { + config: appConfig, + isProd, + plugins, + syncSchemaOptions, + } = await makeModeConfig( + { + ...rest, + configFilePath, + }, + args, + ); + + if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") { + $console.warn("You should not set a different mode than `db` when using hybrid mode"); + } + invariant( + typeof appConfig.reader === "function", + "You must set the `reader` option when using hybrid mode", + ); + + let fileConfig: ModuleConfigs; + try { + fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs; + } catch (e) { + const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs; + await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2)); + fileConfig = defaultConfig; + } + + return { + ...(appConfig as any), + beforeBuild: async (app) => { + if (app && !isProd) { + const mm = app.modules as DbModuleManager; + mm.buildSyncConfig = syncSchemaOptions; + } + }, + config: fileConfig, + options: { + ...appConfig?.options, + mode: isProd ? "code" : "db", + plugins, + manager: { + // skip validation in prod for a speed boost + skipValidation: isProd, + // secrets are required for hybrid mode + secrets: appConfig.secrets, + ...appConfig?.options?.manager, + }, + }, + }; + }, + }; +} diff --git a/app/src/modes/index.ts b/app/src/modes/index.ts new file mode 100644 index 00000000..b0536711 --- /dev/null +++ b/app/src/modes/index.ts @@ -0,0 +1,3 @@ +export * from "./code"; +export * from "./hybrid"; +export * from "./shared"; diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts new file mode 100644 index 00000000..afc2c6aa --- /dev/null +++ b/app/src/modes/shared.ts @@ -0,0 +1,183 @@ +import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd"; +import { syncTypes, syncConfig } from "bknd/plugins"; +import { syncSecrets } from "plugins/dev/sync-secrets.plugin"; +import { invariant, $console } from "bknd/utils"; + +export type BkndModeOptions = { + /** + * Whether the application is running in production. + */ + isProduction?: boolean; + /** + * Writer function to write the configuration to the file system + */ + writer?: (path: string, content: string) => MaybePromise; + /** + * Configuration file path + */ + configFilePath?: string; + /** + * Types file path + * @default "bknd-types.d.ts" + */ + typesFilePath?: string; + /** + * Syncing secrets options + */ + syncSecrets?: { + /** + * Whether to enable syncing secrets + */ + enabled?: boolean; + /** + * Output file path + */ + outFile?: string; + /** + * Format of the output file + * @default "env" + */ + format?: "json" | "env"; + /** + * Whether to include secrets in the output file + * @default false + */ + includeSecrets?: boolean; + }; + /** + * Determines whether to automatically sync the schema if not in production. + * @default true + */ + syncSchema?: boolean | { force?: boolean; drop?: boolean }; +}; + +export type BkndModeConfig = BkndConfig< + Args, + Merge +>; + +export async function makeModeConfig< + Args = any, + Config extends BkndModeConfig = BkndModeConfig, +>(_config: Config, args: Args) { + const appConfig = typeof _config.app === "function" ? await _config.app(args) : _config.app; + + const config = { + ..._config, + ...appConfig, + } as Omit; + + if (typeof config.isProduction !== "boolean") { + $console.warn( + "You should set `isProduction` option when using managed modes to prevent accidental issues", + ); + } + + invariant( + typeof config.writer === "function", + "You must set the `writer` option when using managed modes", + ); + + const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; + + const isProd = config.isProduction; + const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const syncSchemaOptions = + typeof config.syncSchema === "object" + ? config.syncSchema + : { + force: config.syncSchema !== false, + drop: true, + }; + + if (!isProd) { + if (typesFilePath) { + if (plugins.some((p) => p.name === "bknd-sync-types")) { + throw new Error("You have to unregister the `syncTypes` plugin"); + } + plugins.push( + syncTypes({ + enabled: true, + includeFirstBoot: true, + write: async (et) => { + try { + await config.writer?.(typesFilePath, et.toString()); + } catch (e) { + console.error(`Error writing types to"${typesFilePath}"`, e); + } + }, + }) as any, + ); + } + + if (configFilePath) { + if (plugins.some((p) => p.name === "bknd-sync-config")) { + throw new Error("You have to unregister the `syncConfig` plugin"); + } + plugins.push( + syncConfig({ + enabled: true, + includeFirstBoot: true, + write: async (config) => { + try { + await writer?.(configFilePath, JSON.stringify(config, null, 2)); + } catch (e) { + console.error(`Error writing config to "${configFilePath}"`, e); + } + }, + }) as any, + ); + } + + if (syncSecretsOptions?.enabled) { + if (plugins.some((p) => p.name === "bknd-sync-secrets")) { + throw new Error("You have to unregister the `syncSecrets` plugin"); + } + + let outFile = syncSecretsOptions.outFile; + const format = syncSecretsOptions.format ?? "env"; + if (!outFile) { + outFile = ["env", !syncSecretsOptions.includeSecrets && "example", format] + .filter(Boolean) + .join("."); + } + + plugins.push( + syncSecrets({ + enabled: true, + includeFirstBoot: true, + write: async (secrets) => { + const values = Object.fromEntries( + Object.entries(secrets).map(([key, value]) => [ + key, + syncSecretsOptions.includeSecrets ? value : "", + ]), + ); + + try { + if (format === "env") { + await writer?.( + outFile, + Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ); + } else { + await writer?.(outFile, JSON.stringify(values, null, 2)); + } + } catch (e) { + console.error(`Error writing secrets to "${outFile}"`, e); + } + }, + }) as any, + ); + } + } + + return { + config, + isProd, + plugins, + syncSchemaOptions, + }; +} diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts index a7bc903d..8af95e82 100644 --- a/app/src/modules/db/DbModuleManager.ts +++ b/app/src/modules/db/DbModuleManager.ts @@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager { private readonly _booted_with?: "provided" | "partial"; private _stable_configs: ModuleConfigs | undefined; + // config used when syncing database + public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true }; + constructor(connection: Connection, options?: Partial) { let initial = {} as InitialModuleConfigs; let booted_with = "partial" as any; @@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager { const version_before = this.version(); const [_version, _configs] = await migrate(version_before, result.configs.json, { - db: this.db + db: this.db, }); this._version = _version; @@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager { this.logger.log("db sync requested"); // sync db - await ctx.em.schema().sync({ force: true }); + await ctx.em.schema().sync(this.buildSyncConfig); state.synced = true; // save diff --git a/app/tsconfig.json b/app/tsconfig.json index 55264d43..10260b4b 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -33,7 +33,9 @@ "bknd": ["./src/index.ts"], "bknd/utils": ["./src/core/utils/index.ts"], "bknd/adapter": ["./src/adapter/index.ts"], - "bknd/client": ["./src/ui/client/index.ts"] + "bknd/adapter/*": ["./src/adapter/*/index.ts"], + "bknd/client": ["./src/ui/client/index.ts"], + "bknd/modes": ["./src/modes/index.ts"] } }, "include": [ From 38902ebcbae89a78399214770981387a94637a85 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 21 Oct 2025 16:44:08 +0200 Subject: [PATCH 19/48] Update permissions handling and enhance Guard functionality - Bump `jsonv-ts` dependency to 0.8.6. - Refactor permission checks in the `Guard` class to improve context validation and error handling. - Update tests to reflect changes in permission handling, ensuring robust coverage for new scenarios. - Introduce new test cases for data permissions, enhancing overall test coverage and reliability. --- app/__test__/auth/authorize/authorize.spec.ts | 8 +- .../auth/authorize/data.permissions.test.ts | 327 ++++++++++++++++++ .../{ => http}/SystemController.spec.ts | 2 +- .../auth/authorize/permissions.spec.ts | 32 +- app/__test__/core/utils.spec.ts | 88 ++++- app/__test__/data/data.test.ts | 4 +- app/package.json | 2 +- app/src/auth/api/AuthController.ts | 8 +- app/src/auth/authorize/Guard.ts | 76 +++- app/src/auth/authorize/Permission.ts | 2 + app/src/auth/authorize/Policy.ts | 11 +- app/src/core/utils/objects.ts | 19 +- app/src/data/api/DataController.ts | 141 ++++++-- app/src/data/entities/query/Repository.ts | 41 ++- app/src/data/permissions/index.ts | 50 ++- .../form/json-schema-form/Field.tsx | 8 +- .../components/form/json-schema-form/Form.tsx | 7 +- .../ui/routes/auth/auth.roles.edit.$role.tsx | 166 +++++++-- app/src/ui/routes/auth/auth.roles.tsx | 6 + bun.lock | 14 +- 20 files changed, 859 insertions(+), 153 deletions(-) create mode 100644 app/__test__/auth/authorize/data.permissions.test.ts rename app/__test__/auth/authorize/{ => http}/SystemController.spec.ts (93%) diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index b13935a2..caa55663 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -152,12 +152,12 @@ describe("authorize", () => { expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow(); // get the filter for member role - expect(guard.getPolicyFilter(read, { role: "member" })).toEqual({ + expect(guard.filters(read, { role: "member" }).filter).toEqual({ type: "member", }); // get filter for guest - expect(guard.getPolicyFilter(read, {})).toBeUndefined(); + expect(guard.filters(read, {}).filter).toBeUndefined(); }); test("guest should only read posts that are public", () => { @@ -226,7 +226,7 @@ describe("authorize", () => { expect(() => guard.granted(read, {}, { entity: "users" })).toThrow(); // and guests can only read public posts - expect(guard.getPolicyFilter(read, {}, { entity: "posts" })).toEqual({ + expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({ public: true, }); @@ -236,7 +236,7 @@ describe("authorize", () => { // member should not have a filter expect( - guard.getPolicyFilter(read, { role: "member" }, { entity: "posts" }), + guard.filters(read, { role: "member" }, { entity: "posts" }).filter, ).toBeUndefined(); }); }); diff --git a/app/__test__/auth/authorize/data.permissions.test.ts b/app/__test__/auth/authorize/data.permissions.test.ts new file mode 100644 index 00000000..6ff0c3ee --- /dev/null +++ b/app/__test__/auth/authorize/data.permissions.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import * as proto from "data/prototype"; +import { mergeObject } from "core/utils/objects"; +import type { App, DB } from "bknd"; +import type { CreateUserPayload } from "auth/AppAuth"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); + +async function makeApp(config: Partial = {}) { + const app = createApp({ + config: mergeObject( + { + data: proto + .em( + { + users: proto.systemEntity("users", {}), + posts: proto.entity("posts", { + title: proto.text(), + content: proto.text(), + }), + comments: proto.entity("comments", { + content: proto.text(), + }), + }, + ({ relation }, { users, posts, comments }) => { + relation(posts).manyToOne(users); + relation(comments).manyToOne(posts); + }, + ) + .toJSON(), + auth: { + enabled: true, + jwt: { + secret: "secret", + }, + }, + }, + config, + ), + }); + await app.build(); + + return app; +} + +async function createUsers(app: App, users: CreateUserPayload[]) { + return Promise.all( + users.map(async (user) => { + return await app.createUser(user); + }), + ); +} + +async function loadFixtures(app: App, fixtures: Record = {}) { + const results = {} as any; + for (const [entity, data] of Object.entries(fixtures)) { + results[entity] = await app.em + .mutator(entity as any) + .insertMany(data) + .then((result) => result.data); + } + return results; +} + +describe("data permissions", async () => { + const app = await makeApp({ + server: { + mcp: { + enabled: true, + }, + }, + auth: { + guard: { + enabled: true, + }, + roles: { + guest: { + is_default: true, + permissions: [ + { + permission: "system.access.api", + }, + { + permission: "data.entity.read", + policies: [ + { + condition: { + entity: "posts", + }, + effect: "filter", + filter: { + users_id: { $isnull: 1 }, + }, + }, + ], + }, + { + permission: "data.entity.create", + policies: [ + { + condition: { + entity: "posts", + }, + effect: "filter", + filter: { + users_id: { $isnull: 1 }, + }, + }, + ], + }, + { + permission: "data.entity.update", + policies: [ + { + condition: { + entity: "posts", + }, + effect: "filter", + filter: { + users_id: { $isnull: 1 }, + }, + }, + ], + }, + { + permission: "data.entity.delete", + policies: [ + { + condition: { entity: "posts" }, + }, + { + condition: { entity: "posts" }, + effect: "filter", + filter: { + users_id: { $isnull: 1 }, + }, + }, + ], + }, + ], + }, + }, + }, + }); + const users = [ + { email: "foo@example.com", password: "password" }, + { email: "bar@example.com", password: "password" }, + ]; + const fixtures = { + posts: [ + { content: "post 1", users_id: 1 }, + { content: "post 2", users_id: 2 }, + { content: "post 3", users_id: null }, + ], + comments: [ + { content: "comment 1", posts_id: 1 }, + { content: "comment 2", posts_id: 2 }, + { content: "comment 3", posts_id: 3 }, + ], + }; + await createUsers(app, users); + const results = await loadFixtures(app, fixtures); + + describe("http", async () => { + it("read many", async () => { + // many only includes posts with users_id is null + const res = await app.server.request("/api/data/entity/posts"); + const data = await res.json().then((r: any) => r.data); + expect(data).toEqual([results.posts[2]]); + + // same with /query + { + const res = await app.server.request("/api/data/entity/posts/query", { + method: "POST", + }); + const data = await res.json().then((r: any) => r.data); + expect(data).toEqual([results.posts[2]]); + } + }); + + it("read one", async () => { + // one only includes posts with users_id is null + { + const res = await app.server.request("/api/data/entity/posts/1"); + const data = await res.json().then((r: any) => r.data); + expect(res.status).toBe(404); + expect(data).toBeUndefined(); + } + + // read one by allowed id + { + const res = await app.server.request("/api/data/entity/posts/3"); + const data = await res.json().then((r: any) => r.data); + expect(res.status).toBe(200); + expect(data).toEqual(results.posts[2]); + } + }); + + it("read many by reference", async () => { + const res = await app.server.request("/api/data/entity/posts/1/comments"); + const data = await res.json().then((r: any) => r.data); + expect(res.status).toBe(200); + expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1)); + }); + + it("mutation create one", async () => { + // not allowed + { + const res = await app.server.request("/api/data/entity/posts", { + method: "POST", + body: JSON.stringify({ content: "post 4" }), + }); + expect(res.status).toBe(403); + } + // allowed + { + const res = await app.server.request("/api/data/entity/posts", { + method: "POST", + body: JSON.stringify({ content: "post 4", users_id: null }), + }); + expect(res.status).toBe(201); + } + }); + + it("mutation update one", async () => { + // update one: not allowed + const res = await app.server.request("/api/data/entity/posts/1", { + method: "PATCH", + body: JSON.stringify({ content: "post 4" }), + }); + expect(res.status).toBe(403); + + { + // update one: allowed + const res = await app.server.request("/api/data/entity/posts/3", { + method: "PATCH", + body: JSON.stringify({ content: "post 3 (updated)" }), + }); + expect(res.status).toBe(200); + expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)"); + } + }); + + it("mutation update many", async () => { + // update many: not allowed + const res = await app.server.request("/api/data/entity/posts", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + update: { content: "post 4" }, + where: { users_id: { $isnull: 0 } }, + }), + }); + expect(res.status).toBe(200); // because filtered + const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id)); + expect(_data.every((u: any) => u === null)).toBe(true); + + // verify + const data = await app.em + .repo("posts") + .findMany({ select: ["content", "users_id"] }) + .then((r) => r.data); + + // expect non null users_id to not have content "post 4" + expect( + data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"), + ).toBe(true); + // expect null users_id to have content "post 4" + expect( + data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"), + ).toBe(true); + }); + + const count = async () => { + const { + data: { count: _count }, + } = await app.em.repo("posts").count(); + return _count; + }; + it("mutation delete one", async () => { + const initial = await count(); + + // delete one: not allowed + const res = await app.server.request("/api/data/entity/posts/1", { + method: "DELETE", + }); + expect(res.status).toBe(403); + expect(await count()).toBe(initial); + + { + // delete one: allowed + const res = await app.server.request("/api/data/entity/posts/3", { + method: "DELETE", + }); + expect(res.status).toBe(200); + expect(await count()).toBe(initial - 1); + } + }); + + it("mutation delete many", async () => { + // delete many: not allowed + const res = await app.server.request("/api/data/entity/posts", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + where: {}, + }), + }); + expect(res.status).toBe(200); + + // only deleted posts with users_id is null + const remaining = await app.em + .repo("posts") + .findMany() + .then((r) => r.data); + expect(remaining.every((p: any) => p.users_id !== null)).toBe(true); + }); + }); +}); diff --git a/app/__test__/auth/authorize/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts similarity index 93% rename from app/__test__/auth/authorize/SystemController.spec.ts rename to app/__test__/auth/authorize/http/SystemController.spec.ts index 8400f461..40e6493e 100644 --- a/app/__test__/auth/authorize/SystemController.spec.ts +++ b/app/__test__/auth/authorize/http/SystemController.spec.ts @@ -10,7 +10,7 @@ async function makeApp(config: Partial = {}) { return app; } -describe("SystemController", () => { +describe.skip("SystemController", () => { it("...", async () => { const app = await makeApp(); const controller = new SystemController(app); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index 78abdd07..14a01eee 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -122,26 +122,10 @@ describe("Guard", () => { const guard = new Guard([p], [r], { enabled: true, }); - expect( - guard.getPolicyFilter( - p, - { - role: r.name, - }, - { a: 1 }, - ), - ).toEqual({ foo: "bar" }); - expect( - guard.getPolicyFilter( - p, - { - role: r.name, - }, - { a: 2 }, - ), - ).toBeUndefined(); + expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" }); + expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined(); // if no user context given, filter cannot be applied - expect(guard.getPolicyFilter(p, {}, { a: 1 })).toBeUndefined(); + expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined(); }); it("collects filters for default role", () => { @@ -172,26 +156,26 @@ describe("Guard", () => { }); expect( - guard.getPolicyFilter( + guard.filters( p, { role: r.name, }, { a: 1 }, - ), + ).filter, ).toEqual({ foo: "bar" }); expect( - guard.getPolicyFilter( + guard.filters( p, { role: r.name, }, { a: 2 }, - ), + ).filter, ).toBeUndefined(); // if no user context given, the default role is applied // hence it can be found - expect(guard.getPolicyFilter(p, {}, { a: 1 })).toEqual({ foo: "bar" }); + expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" }); }); }); diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index b7d4c96f..8957e9e7 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -272,6 +272,7 @@ describe("Core Utils", async () => { }, /^@([a-z\.]+)$/, variables7, + null, ); expect(result7).toEqual({ number: 123, @@ -288,20 +289,85 @@ describe("Core Utils", async () => { ); expect(result8).toEqual({ message: "The value is 123!" }); - // test mixed scenarios + // test with fallback parameter + const obj9 = { user: "@user.id", config: "@config.theme" }; + const variables9 = {}; // empty context const result9 = utils.recursivelyReplacePlaceholders( - { - fullMatch: "@test.value", // should preserve number type - partialMatch: "Value: @test.value", // should convert to string - noMatch: "static text", - }, + obj9, /^@([a-z\.]+)$/, - variables7, + variables9, + null, ); - expect(result9).toEqual({ - fullMatch: 123, // number preserved - partialMatch: "Value: @test.value", // no replacement (pattern requires full match) - noMatch: "static text", + expect(result9).toEqual({ user: null, config: null }); + + // test with fallback for partial matches + const obj10 = { message: "Hello @user.name, welcome!" }; + const variables10 = {}; // empty context + const result10 = utils.recursivelyReplacePlaceholders( + obj10, + /@([a-z\.]+)/g, + variables10, + "Guest", + ); + expect(result10).toEqual({ message: "Hello Guest, welcome!" }); + + // test with different fallback types + const obj11 = { + stringFallback: "@missing.string", + numberFallback: "@missing.number", + booleanFallback: "@missing.boolean", + objectFallback: "@missing.object", + }; + const variables11 = {}; + const result11 = utils.recursivelyReplacePlaceholders( + obj11, + /^@([a-z\.]+)$/, + variables11, + "default", + ); + expect(result11).toEqual({ + stringFallback: "default", + numberFallback: "default", + booleanFallback: "default", + objectFallback: "default", + }); + + // test fallback with arrays + const obj12 = { items: ["@item1", "@item2", "static"] }; + const variables12 = { item1: "found" }; // item2 is missing + const result12 = utils.recursivelyReplacePlaceholders( + obj12, + /^@([a-zA-Z0-9\.]+)$/, + variables12, + "missing", + ); + expect(result12).toEqual({ items: ["found", "missing", "static"] }); + + // test fallback with nested objects + const obj13 = { + user: "@user.id", + settings: { + theme: "@theme.name", + nested: { + value: "@deep.value", + }, + }, + }; + const variables13 = {}; // empty context + const result13 = utils.recursivelyReplacePlaceholders( + obj13, + /^@([a-z\.]+)$/, + variables13, + null, + ); + expect(result13).toEqual({ + user: null, + settings: { + theme: null, + nested: { + value: null, + }, + }, }); }); }); diff --git a/app/__test__/data/data.test.ts b/app/__test__/data/data.test.ts index 10cf8ac9..416b5c0b 100644 --- a/app/__test__/data/data.test.ts +++ b/app/__test__/data/data.test.ts @@ -30,9 +30,9 @@ describe("some tests", async () => { const query = await em.repository(users).findId(1); expect(query.sql).toBe( - 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?', + 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?', ); - expect(query.parameters).toEqual([1, 1]); + expect(query.parameters).toEqual([1, 1, 0]); expect(query.data).toBeUndefined(); }); diff --git a/app/package.json b/app/package.json index f9325801..c40f3b17 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.5", + "jsonv-ts": "0.8.6", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b94aa4b6..99f1000e 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -61,7 +61,9 @@ export class AuthController extends Controller { hono.post( "/create", permission(AuthPermissions.createUser, {}), - permission(DataPermissions.entityCreate, {}), + permission(DataPermissions.entityCreate, { + context: (c) => ({ entity: this.auth.config.entity_name }), + }), describeRoute({ summary: "Create a new user", tags: ["auth"], @@ -224,7 +226,6 @@ export class AuthController extends Controller { const roles = Object.keys(this.auth.config.roles ?? {}); mcp.tool( - // @todo: needs permission "auth_user_create", { description: "Create a new user", @@ -246,7 +247,6 @@ export class AuthController extends Controller { ); mcp.tool( - // @todo: needs permission "auth_user_token", { description: "Get a user token", @@ -264,7 +264,6 @@ export class AuthController extends Controller { ); mcp.tool( - // @todo: needs permission "auth_user_password_change", { description: "Change a user's password", @@ -286,7 +285,6 @@ export class AuthController extends Controller { ); mcp.tool( - // @todo: needs permission "auth_user_password_test", { description: "Test a user's password", diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 37ee8421..85349a9b 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,5 +1,5 @@ import { Exception } from "core/errors"; -import { $console, type s } from "bknd/utils"; +import { $console, mergeObject, type s } from "bknd/utils"; import type { Permission, PermissionContext } from "auth/authorize/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; @@ -232,41 +232,85 @@ export class Guard { }); } - getPolicyFilter

>( + filters

>( permission: P, c: GuardContext, context: PermissionContext

, - ): PolicySchema["filter"] | undefined; - getPolicyFilter

>( - permission: P, - c: GuardContext, - ): PolicySchema["filter"] | undefined; - getPolicyFilter

>( + ); + filters

>(permission: P, c: GuardContext); + filters

>( permission: P, c: GuardContext, context?: PermissionContext

, - ): PolicySchema["filter"] | undefined { + ) { if (!permission.isFilterable()) { - $console.debug("getPolicyFilter: permission is not filterable, returning undefined"); - return; + throw new GuardPermissionsException(permission, undefined, "Permission is not filterable"); } - const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context); + const { + ctx: _ctx, + exists, + role, + user, + rolePermission, + } = this.collect(permission, c, context); // validate context - let ctx = Object.assign({}, _ctx); + let ctx = Object.assign( + { + user, + }, + _ctx, + ); + if (permission.context) { - ctx = permission.parseContext(ctx); + ctx = permission.parseContext(ctx, { + coerceDropUnknown: false, + }); } + const filters: PolicySchema["filter"][] = []; + const policies: Policy[] = []; if (exists && role && rolePermission && rolePermission.policies.length > 0) { for (const policy of rolePermission.policies) { if (policy.content.effect === "filter") { const meets = policy.meetsCondition(ctx); - return meets ? policy.content.filter : undefined; + if (meets) { + policies.push(policy); + filters.push(policy.getReplacedFilter(ctx)); + } } } } - return; + + const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined; + return { + filters, + filter, + policies, + merge: (givenFilter: object | undefined) => { + return mergeObject(givenFilter ?? {}, filter ?? {}); + }, + matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => { + const subjects = Array.isArray(subject) ? subject : [subject]; + if (policies.length > 0) { + for (const policy of policies) { + for (const subject of subjects) { + if (!policy.meetsFilter(subject, ctx)) { + if (opts?.throwOnError) { + throw new GuardPermissionsException( + permission, + policy, + "Policy filter not met", + ); + } + return false; + } + } + } + } + return true; + }, + }; } } diff --git a/app/src/auth/authorize/Permission.ts b/app/src/auth/authorize/Permission.ts index e18a98c4..cfd59638 100644 --- a/app/src/auth/authorize/Permission.ts +++ b/app/src/auth/authorize/Permission.ts @@ -54,6 +54,8 @@ export class Permission< } parseContext(ctx: ContextValue, opts?: ParseOptions) { + // @todo: allow additional properties + if (!this.context) return ctx; try { return this.context ? parse(this.context!, ctx, opts) : undefined; } catch (e) { diff --git a/app/src/auth/authorize/Policy.ts b/app/src/auth/authorize/Policy.ts index 9995e202..06357f17 100644 --- a/app/src/auth/authorize/Policy.ts +++ b/app/src/auth/authorize/Policy.ts @@ -21,8 +21,15 @@ export class Policy { }) as Schema; } - replace(context: object, vars?: Record) { - return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; + replace(context: object, vars?: Record, fallback?: any) { + return vars + ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars, fallback) + : context; + } + + getReplacedFilter(context: object, fallback?: any) { + if (!this.content.filter) return context; + return this.replace(this.content.filter!, context, fallback); } meetsCondition(context: object, vars?: Record) { diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 65f18f91..33c6a436 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -372,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean { export function getPath( object: object, _path: string | (string | number)[], - defaultValue = undefined, + defaultValue: any = undefined, ): any { const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path; @@ -517,6 +517,7 @@ export function recursivelyReplacePlaceholders( obj: any, pattern: RegExp, variables: Record, + fallback?: any, ) { if (typeof obj === "string") { // check if the entire string matches the pattern @@ -524,24 +525,28 @@ export function recursivelyReplacePlaceholders( if (match && match[0] === obj && match[1]) { // full string match - replace with the actual value (preserving type) const key = match[1]; - const value = getPath(variables, key); - return value !== undefined ? value : obj; + const value = getPath(variables, key, null); + return value !== null ? value : fallback !== undefined ? fallback : obj; } // partial match - use string replacement if (pattern.test(obj)) { return obj.replace(pattern, (match, key) => { - const value = getPath(variables, key); + const value = getPath(variables, key, null); // convert to string for partial replacements - return value !== undefined ? String(value) : match; + return value !== null + ? String(value) + : fallback !== undefined + ? String(fallback) + : match; }); } } if (Array.isArray(obj)) { - return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables)); + return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback)); } if (obj && typeof obj === "object") { return Object.entries(obj).reduce((acc, [key, value]) => { - acc[key] = recursivelyReplacePlaceholders(value, pattern, variables); + acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback); return acc; }, {} as object); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index d4f9cdfe..adceffaf 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -9,6 +9,7 @@ import { pickKeys, mcpTool, convertNumberedObjectToArray, + mergeObject, } from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; @@ -95,7 +96,9 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Retrieve data schema", tags: ["data"], @@ -121,7 +124,9 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Retrieve entity schema", tags: ["data"], @@ -161,7 +166,9 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Retrieve entity info", tags: ["data"], @@ -213,7 +220,9 @@ export class DataController extends Controller { // fn: count hono.post( "/:entity/fn/count", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Count entities", tags: ["data"], @@ -236,7 +245,9 @@ export class DataController extends Controller { // fn: exists hono.post( "/:entity/fn/exists", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Check if entity exists", tags: ["data"], @@ -285,16 +296,26 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), tags: ["data"], }), - permission(DataPermissions.entityRead, {}), jsc("param", s.object({ entity: entitiesEnum })), jsc("query", repoQuery, { skipOpenAPI: true }), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), async (c) => { const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } + + const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, { + entity, + }); + const options = c.req.valid("query") as RepoQuery; - const result = await this.em.repository(entity).findMany(options); + const result = await this.em.repository(entity).findMany({ + ...options, + where: merge(options.where), + }); return c.json(result, { status: result.data ? 200 : 404 }); }, @@ -308,7 +329,9 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["offset", "sort", "select"]), tags: ["data"], }), - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_read_one", { inputSchema: { param: s.object({ entity: entitiesEnum, id: idType }), @@ -326,11 +349,19 @@ export class DataController extends Controller { jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { const { entity, id } = c.req.valid("param"); - if (!this.entityExists(entity)) { + if (!this.entityExists(entity) || !id) { return this.notFound(c); } const options = c.req.valid("query") as RepoQuery; - const result = await this.em.repository(entity).findId(id, options); + const { merge } = this.ctx.guard.filters( + DataPermissions.entityRead, + c, + c.req.valid("param"), + ); + const id_name = this.em.entity(entity).getPrimaryField().name; + const result = await this.em + .repository(entity) + .findOne(merge({ [id_name]: id }), options); return c.json(result, { status: result.data ? 200 : 404 }); }, @@ -344,7 +375,9 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(), tags: ["data"], }), - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ ...c.req.param() }) as any, + }), jsc( "param", s.object({ @@ -361,9 +394,20 @@ export class DataController extends Controller { } const options = c.req.valid("query") as RepoQuery; - const result = await this.em + const { entity: newEntity } = this.em .repository(entity) - .findManyByReference(id, reference, options); + .getEntityByReference(reference); + + const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, { + entity: newEntity.name, + id, + reference, + }); + + const result = await this.em.repository(entity).findManyByReference(id, reference, { + ...options, + where: merge(options.where), + }); return c.json(result, { status: result.data ? 200 : 404 }); }, @@ -390,7 +434,9 @@ export class DataController extends Controller { }, tags: ["data"], }), - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), mcpTool("data_entity_read_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -405,7 +451,13 @@ export class DataController extends Controller { return this.notFound(c); } const options = c.req.valid("json") as RepoQuery; - const result = await this.em.repository(entity).findMany(options); + const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, { + entity, + }); + const result = await this.em.repository(entity).findMany({ + ...options, + where: merge(options.where), + }); return c.json(result, { status: result.data ? 200 : 404 }); }, @@ -421,7 +473,9 @@ export class DataController extends Controller { summary: "Insert one or many", tags: ["data"], }), - permission(DataPermissions.entityCreate, {}), + permission(DataPermissions.entityCreate, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_insert"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), @@ -438,6 +492,12 @@ export class DataController extends Controller { // to transform all validation targets into a single object const body = convertNumberedObjectToArray(_body); + this.ctx.guard + .filters(DataPermissions.entityCreate, c, { + entity, + }) + .matches(body, { throwOnError: true }); + if (Array.isArray(body)) { const result = await this.em.mutator(entity).insertMany(body); return c.json(result, 201); @@ -455,7 +515,9 @@ export class DataController extends Controller { summary: "Update many", tags: ["data"], }), - permission(DataPermissions.entityUpdate, {}), + permission(DataPermissions.entityUpdate, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_update_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -482,7 +544,10 @@ export class DataController extends Controller { update: EntityData; where: RepoQuery["where"]; }; - const result = await this.em.mutator(entity).updateWhere(update, where); + const { merge } = this.ctx.guard.filters(DataPermissions.entityUpdate, c, { + entity, + }); + const result = await this.em.mutator(entity).updateWhere(update, merge(where)); return c.json(result); }, @@ -495,7 +560,9 @@ export class DataController extends Controller { summary: "Update one", tags: ["data"], }), - permission(DataPermissions.entityUpdate, {}), + permission(DataPermissions.entityUpdate, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_update_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("json", s.object({})), @@ -505,6 +572,17 @@ export class DataController extends Controller { return this.notFound(c); } const body = (await c.req.json()) as EntityData; + const fns = this.ctx.guard.filters(DataPermissions.entityUpdate, c, { + entity, + id, + }); + + // if it has filters attached, fetch entry and make the check + if (fns.filters.length > 0) { + const { data } = await this.em.repository(entity).findId(id); + fns.matches(data, { throwOnError: true }); + } + const result = await this.em.mutator(entity).updateOne(id, body); return c.json(result); @@ -518,7 +596,9 @@ export class DataController extends Controller { summary: "Delete one", tags: ["data"], }), - permission(DataPermissions.entityDelete, {}), + permission(DataPermissions.entityDelete, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_delete_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), async (c) => { @@ -526,6 +606,18 @@ export class DataController extends Controller { if (!this.entityExists(entity)) { return this.notFound(c); } + + const fns = this.ctx.guard.filters(DataPermissions.entityDelete, c, { + entity, + id, + }); + + // if it has filters attached, fetch entry and make the check + if (fns.filters.length > 0) { + const { data } = await this.em.repository(entity).findId(id); + fns.matches(data, { throwOnError: true }); + } + const result = await this.em.mutator(entity).deleteOne(id); return c.json(result); @@ -539,7 +631,9 @@ export class DataController extends Controller { summary: "Delete many", tags: ["data"], }), - permission(DataPermissions.entityDelete, {}), + permission(DataPermissions.entityDelete, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_delete_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -554,7 +648,10 @@ export class DataController extends Controller { return this.notFound(c); } const where = (await c.req.json()) as RepoQuery["where"]; - const result = await this.em.mutator(entity).deleteWhere(where); + const { merge } = this.ctx.guard.filters(DataPermissions.entityDelete, c, { + entity, + }); + const result = await this.em.mutator(entity).deleteWhere(merge(where)); return c.json(result); }, diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 13554a6f..3d8f432c 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -1,4 +1,4 @@ -import type { DB as DefaultDB, PrimaryFieldType } from "bknd"; +import type { DB as DefaultDB, EntityRelation, PrimaryFieldType } from "bknd"; import { $console } from "bknd/utils"; import { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; @@ -280,16 +280,11 @@ export class Repository>, ): Promise> { - const { qb, options } = this.buildQuery( - { - ..._options, - where: { [this.entity.getPrimaryField().name]: id }, - limit: 1, - }, - ["offset", "sort"], - ); + if (typeof id === "undefined" || id === null) { + throw new InvalidSearchParamsException("id is required"); + } - return this.single(qb, options) as any; + return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options); } async findOne( @@ -315,23 +310,27 @@ export class Repository r.ref(reference).reference === reference); + if (!relation) { + throw new Error( + `Relation "${reference}" not found or not listable on entity "${this.entity.name}"`, + ); + } + return { + entity: relation.other(this.entity).entity, + relation, + }; + } + // @todo: add unit tests, specially for many to many async findManyByReference( id: PrimaryFieldType, reference: string, _options?: Partial>, ): Promise> { - const entity = this.entity; - const listable_relations = this.em.relations.listableRelationsOf(entity); - const relation = listable_relations.find((r) => r.ref(reference).reference === reference); - - if (!relation) { - throw new Error( - `Relation "${reference}" not found or not listable on entity "${entity.name}"`, - ); - } - - const newEntity = relation.other(entity).entity; + const { entity: newEntity, relation } = this.getEntityByReference(reference); const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference); if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) { throw new Error( diff --git a/app/src/data/permissions/index.ts b/app/src/data/permissions/index.ts index 124980e8..f832716a 100644 --- a/app/src/data/permissions/index.ts +++ b/app/src/data/permissions/index.ts @@ -1,9 +1,51 @@ import { Permission } from "auth/authorize/Permission"; +import { s } from "bknd/utils"; -export const entityRead = new Permission("data.entity.read"); -export const entityCreate = new Permission("data.entity.create"); -export const entityUpdate = new Permission("data.entity.update"); -export const entityDelete = new Permission("data.entity.delete"); +export const entityRead = new Permission( + "data.entity.read", + { + filterable: true, + }, + s.object({ + entity: s.string(), + id: s.anyOf([s.number(), s.string()]).optional(), + }), +); +/** + * Filter filters content given + */ +export const entityCreate = new Permission( + "data.entity.create", + { + filterable: true, + }, + s.object({ + entity: s.string(), + }), +); +/** + * Filter filters where clause + */ +export const entityUpdate = new Permission( + "data.entity.update", + { + filterable: true, + }, + s.object({ + entity: s.string(), + id: s.anyOf([s.number(), s.string()]).optional(), + }), +); +export const entityDelete = new Permission( + "data.entity.delete", + { + filterable: true, + }, + s.object({ + entity: s.string(), + id: s.anyOf([s.number(), s.string()]).optional(), + }), +); export const databaseSync = new Permission("data.database.sync"); export const rawQuery = new Permission("data.raw.query"); export const rawMutate = new Permission("data.raw.mutate"); diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index bc81a83c..4669022a 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -217,14 +217,14 @@ export type CustomFieldProps = { ) => React.ReactNode; }; -export const CustomField = ({ +export function CustomField({ path: _path, valueStrict = true, deriveFn, children, -}: CustomFieldProps) => { +}: CustomFieldProps) { const ctx = useDerivedFieldContext(_path, deriveFn); - const $value = useFormValue(ctx.path, { strict: valueStrict }); + const $value = useFormValue(_path, { strict: valueStrict }); const setValue = (value: any) => ctx.setValue(ctx.path, value); return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue }); -}; +} diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index 274c1624..acfa25b4 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -80,6 +80,7 @@ export function Form< onInvalidSubmit, validateOn = "submit", hiddenSubmit = true, + beforeSubmit, ignoreKeys = [], options = {}, readOnly = false, @@ -90,6 +91,7 @@ export function Form< initialOpts?: LibTemplateOptions; ignoreKeys?: string[]; onChange?: (data: Partial, name: string, value: any, context: FormContext) => void; + beforeSubmit?: (data: Data) => Data; onSubmit?: (data: Data) => void | Promise; onInvalidSubmit?: (errors: JsonError[], data: Partial) => void; hiddenSubmit?: boolean; @@ -177,7 +179,8 @@ export function Form< }); const validate = useEvent((_data?: Partial) => { - const actual = _data ?? getCurrentState()?.data; + const before = beforeSubmit ?? ((a: any) => a); + const actual = before((_data as any) ?? getCurrentState()?.data); const errors = lib.validate(actual, schema); setFormState((prev) => ({ ...prev, errors })); return { data: actual, errors }; @@ -378,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) { if (options?.debug !== true && force !== true) return null; const ctx = useFormStateSelector((s) => s); - return ; + return ; } diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index ff7c39d3..54a1bfd8 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -2,12 +2,12 @@ import { useBknd } from "ui/client/bknd"; import { Message } from "ui/components/display/Message"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { useNavigate } from "ui/lib/routes"; import { isDebug } from "core/env"; import { Dropdown } from "ui/components/overlay/Dropdown"; import { IconButton } from "ui/components/buttons/IconButton"; -import { TbAdjustments, TbDots, TbLock, TbLockOpen, TbLockOpen2 } from "react-icons/tb"; +import { TbAdjustments, TbDots, TbFilter, TbTrash } from "react-icons/tb"; import { Button } from "ui/components/buttons/Button"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { routes } from "ui/lib/routes"; @@ -18,17 +18,23 @@ import { ucFirst, type s } from "bknd/utils"; import type { ModuleSchemas } from "bknd"; import { ArrayField, + CustomField, Field, + FieldWrapper, Form, + FormContextOverride, FormDebug, + ObjectField, Subscribe, + useDerivedFieldContext, useFormContext, useFormValue, } from "ui/components/form/json-schema-form"; import type { TPermission } from "auth/authorize/Permission"; import type { RoleSchema } from "auth/authorize/Role"; -import { SegmentedControl, Tooltip } from "@mantine/core"; +import { Indicator, SegmentedControl, Tooltip } from "@mantine/core"; import { cn } from "ui/lib/utils"; +import type { PolicySchema } from "auth/authorize/Policy"; export function AuthRolesEdit(props) { useBrowserTitle(["Auth", "Roles", props.params.role]); @@ -66,21 +72,39 @@ function AuthRolesEditInternal({ params }) { const { config, schema: authSchema, actions } = useBkndAuth(); const roleName = params.role; const role = config.roles?.[roleName]; - const { readonly } = useBknd(); + const { readonly, permissions } = useBknd(); const schema = getSchema(authSchema); + const data = { + ...role, + // this is to maintain array structure + permissions: permissions.map((p) => { + return role?.permissions?.find((v: any) => v.permission === p.name); + }), + }; - async function handleDelete() {} - async function handleUpdate(data: any) { - console.log("data", data); - const success = await actions.roles.patch(roleName, data); - console.log("success", success); - /* if (success) { + async function handleDelete() { + const success = await actions.roles.delete(roleName); + if (success) { navigate(routes.auth.roles.list()); - } */ + } + } + async function handleUpdate(data: any) { + await actions.roles.patch(roleName, data); } return ( -

+ { + return { + ...data, + permissions: [...Object.values(data.permissions)], + }; + }} + onSubmit={handleUpdate} + > @@ -196,14 +220,21 @@ const Permissions = () => { const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => { const path = `permissions.${index}`; - const { value } = useFormValue(path); + const { value } = useDerivedFieldContext("permissions", (ctx) => { + const v = ctx.value; + if (!Array.isArray(v)) return undefined; + return v.find((v) => v && v.permission === permission.name); + }); const { setValue, deleteValue } = useFormContext(); const [open, setOpen] = useState(false); const data = value as PermissionData | undefined; + const policiesCount = data?.policies?.length ?? 0; + const hasContext = !!permission.context; async function handleSwitch() { if (data) { - deleteValue(path); + setValue(path, undefined); + setOpen(false); } else { setValue(path, { permission: permission.name, @@ -220,34 +251,125 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu className={cn("flex flex-col border border-muted", open && "border-primary/20")} >
-
{permission.name}
+
+ {permission.name} + {permission.filterable && ( + + + + )} +
+
- - +
+ {policiesCount > 0 && ( +
+ {policiesCount} +
+ )} setOpen((o) => !o)} /> - +
+
{open && (
- + {/* + /> */}
)}
); }; + +const Policies = ({ path, permission }: { path: string; permission: TPermission }) => { + const { value: _value } = useFormValue(path); + const { setValue, schema: policySchema, lib, deleteValue } = useDerivedFieldContext(path); + const value = _value ?? []; + + function handleAdd() { + setValue( + `${path}.${value.length}`, + lib.getTemplate(undefined, policySchema!.items, { + addOptionalProps: true, + }), + ); + } + + function handleDelete(index: number) { + deleteValue(`${path}.${index}`); + } + + return ( +
0 && "gap-8")}> +
+ {value.map((policy, i) => ( + + {i > 0 &&
} +
+
+ +
+ handleDelete(i)} size="sm" /> +
+ + ))} +
+
+ +
+
+ ); +}; + +const Policy = ({ + permission, +}: { + permission: TPermission; +}) => { + const { value } = useFormValue(""); + return ( +
+ + + + {({ value, setValue }) => ( + + setValue(value)} + data={ + ["allow", "deny", permission.filterable ? "filter" : undefined] + .filter(Boolean) + .map((effect) => ({ + label: ucFirst(effect ?? ""), + value: effect, + })) as any + } + /> + + )} + + + {value?.effect === "filter" && ( + + )} +
+ ); +}; diff --git a/app/src/ui/routes/auth/auth.roles.tsx b/app/src/ui/routes/auth/auth.roles.tsx index 15596bf3..75793fe2 100644 --- a/app/src/ui/routes/auth/auth.roles.tsx +++ b/app/src/ui/routes/auth/auth.roles.tsx @@ -35,6 +35,9 @@ function AuthRolesListInternal() { transformObject(config.roles ?? {}, (role, name) => ({ role: name, permissions: role.permissions?.map((p) => p.permission) as string[], + policies: role.permissions + ?.flatMap((p) => p.policies?.length ?? 0) + .reduce((acc, curr) => acc + curr, 0), is_default: role.is_default ?? false, implicit_allow: role.implicit_allow ?? false, })), @@ -107,6 +110,9 @@ const renderValue = ({ value, property }) => { if (["is_default", "implicit_allow"].includes(property)) { return value ? Yes : No; } + if (property === "policies") { + return value ? {value} : 0; + } if (property === "permissions") { const max = 3; diff --git a/bun.lock b/bun.lock index 8b5995e2..ab638563 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.18.0-rc.6", + "version": "0.18.1", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -35,7 +35,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.4", + "jsonv-ts": "0.8.6", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -1243,7 +1243,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -2529,7 +2529,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.8.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-TZOyAVGBZxHuzk09NgJCx2dbeh0XqVWVKHU1PtIuvjT9XO7zhvAD02RcVisJoUdt2rJNt3zlyeNQ2b8MMPc+ug=="], + "jsonv-ts": ["jsonv-ts@0.8.6", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-z5jJ017LFOvAFFVodAIiCY024yW72RWc/K0Sct+OtuiLN+lKy+g0pI0jaz5JmuXaMIePc6HyopeeYHi8ffbYhw=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], @@ -3847,6 +3847,8 @@ "@bknd/plasmic/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@bknd/postgres/@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + "@bknd/sqlocal/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -4093,7 +4095,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], "@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], @@ -4701,6 +4703,8 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], + "@bknd/postgres/@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], "@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250917.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0kL/kFnKUSycoo7b3PgM0nRyZ+1MGQAKaXtE6a2+SAeUkZ2FLnuFWmASi0s4rlWGsf/rlTw4AwXROePir9dUcQ=="], From eb0822bbffa765125a383035b3a2b7ace197543e Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 09:14:31 +0200 Subject: [PATCH 20/48] Enhance authentication and authorization components - Refactored `AppAuth` to introduce `getGuardContextSchema` for improved user context handling. - Updated `Authenticator` to utilize `pickKeys` for user data extraction in JWT generation. - Enhanced `Guard` class to improve permission checks and error handling. - Modified `SystemController` to return context schema alongside permissions in API responses. - Added new `permissions` method in `SystemApi` for fetching permissions. - Improved UI components with additional props and tooltip support for better user experience. --- app/package.json | 2 +- app/src/auth/AppAuth.ts | 15 +- app/src/auth/authenticate/Authenticator.ts | 8 +- app/src/auth/authorize/Guard.ts | 12 +- app/src/modules/ModuleHelper.ts | 2 +- app/src/modules/SystemApi.ts | 5 + app/src/modules/server/SystemController.ts | 9 +- app/src/ui/client/api/use-api.ts | 24 ++- app/src/ui/components/buttons/Button.tsx | 4 +- app/src/ui/components/code/CodePreview.tsx | 73 +++++++++ .../form/json-schema-form/FieldWrapper.tsx | 24 ++- .../ui/routes/auth/auth.roles.edit.$role.tsx | 152 +++++++++++++++--- app/src/ui/routes/tools/mcp/mcp.tsx | 2 +- app/src/ui/routes/tools/mcp/tools.tsx | 7 +- bun.lock | 8 +- 15 files changed, 290 insertions(+), 57 deletions(-) create mode 100644 app/src/ui/components/code/CodePreview.tsx diff --git a/app/package.json b/app/package.json index c40f3b17..c256cc9b 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.6", + "jsonv-ts": "0.9.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index a73f9ec8..4b239191 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -2,7 +2,7 @@ import type { DB, PrimaryFieldType } from "bknd"; import * as AuthPermissions from "auth/auth-permissions"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; -import { $console, secureRandomString, transformObject, pick } from "bknd/utils"; +import { $console, secureRandomString, transformObject, pickKeys } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { Module } from "modules/Module"; @@ -113,6 +113,19 @@ export class AppAuth extends Module { return authConfigSchema; } + getGuardContextSchema() { + const userschema = this.getUsersEntity().toSchema() as any; + return { + type: "object", + properties: { + user: { + type: "object", + properties: pickKeys(userschema.properties, this.config.jwt.fields as any), + }, + }, + }; + } + get authenticator(): Authenticator { this.throwIfNotBuilt(); return this._authenticator!; diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 711099ba..b7ececc2 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -6,10 +6,8 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { sign, verify } from "hono/jwt"; import { type CookieOptions, serializeSigned } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Controller"; -import { pick } from "lodash-es"; import { InvalidConditionsException } from "auth/errors"; -import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils"; -import { $object } from "modules/mcp"; +import { s, parse, secret, runtimeSupports, truncate, $console, pickKeys } from "bknd/utils"; import type { AuthStrategy } from "./strategies/Strategy"; type Input = any; // workaround @@ -229,7 +227,7 @@ export class Authenticator< // @todo: add jwt tests async jwt(_user: SafeUser | ProfileExchange): Promise { - const user = pick(_user, this.config.jwt.fields); + const user = pickKeys(_user, this.config.jwt.fields as any); const payload: JWTPayload = { ...user, @@ -255,7 +253,7 @@ export class Authenticator< } async safeAuthResponse(_user: User): Promise { - const user = pick(_user, this.config.jwt.fields) as SafeUser; + const user = pickKeys(_user, this.config.jwt.fields as any) as SafeUser; return { user, token: await this.jwt(user), diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 85349a9b..e66dba14 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -125,7 +125,7 @@ export class Guard { return this.config?.enabled === true; } - private collect(permission: Permission, c: GuardContext, context: any) { + private collect(permission: Permission, c: GuardContext | undefined, context: any) { const user = c && "get" in c ? c.get("auth")?.user : c; const ctx = { ...((context ?? {}) as any), @@ -181,15 +181,15 @@ export class Guard { } if (!role) { - $console.debug("guard: user has no role, denying"); throw new GuardPermissionsException(permission, undefined, "User has no role"); - } else if (role.implicit_allow === true) { - $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); - return; } if (!rolePermission) { - $console.debug("guard: rolePermission not found, denying"); + if (role.implicit_allow === true) { + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); + return; + } + throw new GuardPermissionsException( permission, undefined, diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 29c4172e..c227ba1d 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -137,6 +137,6 @@ export class ModuleHelper { } const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); - this.ctx.guard.granted(permission, { user }, context as any); + this.ctx.guard.granted(permission, user as any, context as any); } } diff --git a/app/src/modules/SystemApi.ts b/app/src/modules/SystemApi.ts index dc2e5c64..ab26baeb 100644 --- a/app/src/modules/SystemApi.ts +++ b/app/src/modules/SystemApi.ts @@ -1,6 +1,7 @@ import type { ConfigUpdateResponse } from "modules/server/SystemController"; import { ModuleApi } from "./ModuleApi"; import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager"; +import type { TPermission } from "auth/authorize/Permission"; export type ApiSchemaResponse = { version: number; @@ -54,4 +55,8 @@ export class SystemApi extends ModuleApi { removeConfig(module: Module, path: string) { return this.delete(["config", "remove", module, path]); } + + permissions() { + return this.get<{ permissions: TPermission[]; context: object }>("permissions"); + } } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 45787bf9..1190d551 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -69,10 +69,13 @@ export class SystemController extends Controller { if (!config.mcp.enabled) { return; } + const { permission } = this.middlewares; this.registerMcp(); - app.server.use( + app.server.all( + config.mcp.path, + permission(SystemPermissions.mcp, {}), mcpMiddleware({ setup: async () => { if (!this._mcpServer) { @@ -110,7 +113,6 @@ export class SystemController extends Controller { explainEndpoint: true, }, endpoint: { - path: config.mcp.path as any, // @ts-ignore _init: isNode() ? { duplex: "half" } : {}, }, @@ -415,7 +417,6 @@ export class SystemController extends Controller { schema, config: config ? this.app.toJSON(secrets) : undefined, permissions: this.app.modules.ctx().guard.getPermissions(), - //permissions: this.app.modules.ctx().guard.getPermissionNames(), }); }, ); @@ -428,7 +429,7 @@ export class SystemController extends Controller { }), (c) => { const permissions = this.app.modules.ctx().guard.getPermissions(); - return c.json({ permissions }); + return c.json({ permissions, context: this.app.module.auth.getGuardContextSchema() }); }, ); diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 6b6d5467..573b9900 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -1,6 +1,6 @@ import type { Api } from "Api"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; -import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; +import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWRInfinite from "swr/infinite"; import { useApi } from "ui/client"; import { useState } from "react"; @@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => { return mutate((k) => typeof k === "string" && k.startsWith(key)); }; }; + +const mountOnceCache = new Map(); + +/** + * Simple middleware to only load on first mount. + */ +export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => { + if (typeof key === "string") { + if (mountOnceCache.has(key)) { + return useSWRNext(key, fetcher, { + ...config, + revalidateOnMount: false, + }); + } + const swr = useSWRNext(key, fetcher, config); + if (swr.data) { + mountOnceCache.set(key, true); + } + return swr; + } + return useSWRNext(key, fetcher, config); +}; diff --git a/app/src/ui/components/buttons/Button.tsx b/app/src/ui/components/buttons/Button.tsx index b80f0060..79ee3cc4 100644 --- a/app/src/ui/components/buttons/Button.tsx +++ b/app/src/ui/components/buttons/Button.tsx @@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge"; import { Link } from "ui/components/wouter/Link"; const sizes = { + smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs", small: "px-2 py-1.5 rounded-md gap-1 text-sm", default: "px-3 py-2.5 rounded-md gap-1.5", large: "px-4 py-3 rounded-md gap-2.5 text-lg", }; const iconSizes = { - small: 12, + smaller: 12, + small: 14, default: 16, large: 20, }; diff --git a/app/src/ui/components/code/CodePreview.tsx b/app/src/ui/components/code/CodePreview.tsx new file mode 100644 index 00000000..9796e930 --- /dev/null +++ b/app/src/ui/components/code/CodePreview.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { useTheme } from "ui/client/use-theme"; +import { cn } from "ui/lib/utils"; + +export type CodePreviewProps = { + code: string; + className?: string; + lang?: string; + theme?: string; + enabled?: boolean; +}; + +export const CodePreview = ({ + code, + className, + lang = "typescript", + theme: _theme, + enabled = true, +}: CodePreviewProps) => { + const [highlightedHtml, setHighlightedHtml] = useState(null); + const $theme = useTheme(); + const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light"; + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + setHighlightedHtml(null); + + async function highlightCode() { + try { + // Dynamically import Shiki from CDN + // @ts-expect-error - Dynamic CDN import + const { codeToHtml } = await import("https://esm.sh/shiki@3.13.0"); + + if (cancelled) return; + + const html = await codeToHtml(code, { + lang, + theme, + structure: "inline", + }); + + if (cancelled) return; + + setHighlightedHtml(html); + } catch (error) { + console.error("Failed to load Shiki:", error); + // Fallback to plain text if Shiki fails to load + if (!cancelled) { + setHighlightedHtml(code); + } + } + } + + highlightCode(); + + return () => { + cancelled = true; + }; + }, [code, enabled]); + + if (!highlightedHtml) { + return
{code}
; + } + + return ( +
+   );
+};
diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
index af4607ca..334dfe5b 100644
--- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
+++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
@@ -1,4 +1,4 @@
-import { IconBug } from "@tabler/icons-react";
+import { IconBug, IconInfoCircle } from "@tabler/icons-react";
 import type { JsonSchema } from "json-schema-library";
 import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
 import { IconButton } from "ui/components/buttons/IconButton";
@@ -12,6 +12,7 @@ import {
 import { Popover } from "ui/components/overlay/Popover";
 import { getLabel } from "./utils";
 import { twMerge } from "tailwind-merge";
+import { Tooltip } from "@mantine/core";
 
 export type FieldwrapperProps = {
    name: string;
@@ -24,7 +25,7 @@ export type FieldwrapperProps = {
    children: ReactElement | ReactNode;
    errorPlacement?: "top" | "bottom";
    description?: string;
-   descriptionPlacement?: "top" | "bottom";
+   descriptionPlacement?: "top" | "bottom" | "label";
    fieldId?: string;
    className?: string;
 };
@@ -53,11 +54,17 @@ export function FieldWrapper({
       {errors.map((e) => e.message).join(", ")}
    );
 
-   const Description = description && (
-      
-         {description}
-      
-   );
+   const Description = description ? (
+      ["top", "bottom"].includes(descriptionPlacement) ? (
+         
+            {description}
+         
+      ) : (
+         
+            
+         
+      )
+   ) : null;
 
    return (
       
                {label} {required && *}
+               {descriptionPlacement === "label" && Description}
             
          )}
          {descriptionPlacement === "top" && Description}
diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx
index 54a1bfd8..9637ed18 100644
--- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx
+++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx
@@ -7,34 +7,36 @@ import { useNavigate } from "ui/lib/routes";
 import { isDebug } from "core/env";
 import { Dropdown } from "ui/components/overlay/Dropdown";
 import { IconButton } from "ui/components/buttons/IconButton";
-import { TbAdjustments, TbDots, TbFilter, TbTrash } from "react-icons/tb";
+import { TbAdjustments, TbDots, TbFilter, TbTrash, TbInfoCircle, TbCodeDots } from "react-icons/tb";
 import { Button } from "ui/components/buttons/Button";
 import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
 import { routes } from "ui/lib/routes";
 import * as AppShell from "ui/layouts/AppShell/AppShell";
 import * as Formy from "ui/components/form/Formy";
-
-import { ucFirst, type s } from "bknd/utils";
+import { ucFirst, s, transformObject, isObject } from "bknd/utils";
 import type { ModuleSchemas } from "bknd";
 import {
-   ArrayField,
    CustomField,
    Field,
    FieldWrapper,
    Form,
    FormContextOverride,
    FormDebug,
-   ObjectField,
+   ObjectJsonField,
    Subscribe,
    useDerivedFieldContext,
    useFormContext,
+   useFormError,
    useFormValue,
 } from "ui/components/form/json-schema-form";
 import type { TPermission } from "auth/authorize/Permission";
 import type { RoleSchema } from "auth/authorize/Role";
-import { Indicator, SegmentedControl, Tooltip } from "@mantine/core";
+import { SegmentedControl, Tooltip } from "@mantine/core";
+import { Popover } from "ui/components/overlay/Popover";
 import { cn } from "ui/lib/utils";
-import type { PolicySchema } from "auth/authorize/Policy";
+import { JsonViewer } from "ui/components/code/JsonViewer";
+import { mountOnce, useApiQuery } from "ui/client";
+import { CodePreview } from "ui/components/code/CodePreview";
 
 export function AuthRolesEdit(props) {
    useBrowserTitle(["Auth", "Roles", props.params.role]);
@@ -67,7 +69,7 @@ const formConfig = {
    },
 };
 
-function AuthRolesEditInternal({ params }) {
+function AuthRolesEditInternal({ params }: { params: { role: string } }) {
    const [navigate] = useNavigate();
    const { config, schema: authSchema, actions } = useBkndAuth();
    const roleName = params.role;
@@ -225,11 +227,10 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu
       if (!Array.isArray(v)) return undefined;
       return v.find((v) => v && v.permission === permission.name);
    });
-   const { setValue, deleteValue } = useFormContext();
+   const { setValue } = useFormContext();
    const [open, setOpen] = useState(false);
    const data = value as PermissionData | undefined;
    const policiesCount = data?.policies?.length ?? 0;
-   const hasContext = !!permission.context;
 
    async function handleSwitch() {
       if (data) {
@@ -270,9 +271,9 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu
                       setOpen((o) => !o)}
                      />
                   
@@ -282,14 +283,6 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu {open && (
- {/* */}
)}
@@ -337,19 +330,68 @@ const Policies = ({ path, permission }: { path: string; permission: TPermission ); }; +const mergeSchemas = (...schemas: object[]) => { + const schema = s.allOf(schemas.filter(Boolean).map(s.fromSchema)); + return s.toTypes(schema, "Context"); +}; + +function replaceEntitiesEnum(schema: Record, entities: string[]) { + if (!isObject(schema) || !Array.isArray(entities) || entities.length === 0) return schema; + return transformObject(schema, (sub, name) => { + if (name === "properties") { + return transformObject(sub as Record, (propConfig, propKey) => { + if (propKey === "entity" && propConfig.type === "string") { + return { + ...propConfig, + enum: entities, + }; + } + return propConfig; + }); + } + return sub; + }); +} + const Policy = ({ permission, }: { permission: TPermission; }) => { const { value } = useFormValue(""); + const $bknd = useBknd(); + const $permissions = useApiQuery((api) => api.system.permissions(), { + use: [mountOnce], + }); + const entities = Object.keys($bknd.config.data.entities ?? {}); + const ctx = $permissions.data + ? mergeSchemas( + $permissions.data.context, + replaceEntitiesEnum(permission.context ?? null, entities), + ) + : undefined; + return (
- + + + + + {({ value, setValue }) => ( - + {value?.effect === "filter" && ( - + + + )}
); }; + +const CustomFieldWrapper = ({ children, name, label, description, schema }: any) => { + const errors = useFormError(name, { strict: true }); + const Errors = errors.length > 0 && ( + {errors.map((e) => e.message).join(", ")} + ); + + return ( + + +
+ {label} + {description && ( + + + + )} +
+ {schema && ( +
+ + typeof schema === "object" ? ( + + ) : ( + + ) + } + > + + +
+ )} +
+ {children} + {Errors} +
+ ); +}; diff --git a/app/src/ui/routes/tools/mcp/mcp.tsx b/app/src/ui/routes/tools/mcp/mcp.tsx index c06668b5..ccde4c63 100644 --- a/app/src/ui/routes/tools/mcp/mcp.tsx +++ b/app/src/ui/routes/tools/mcp/mcp.tsx @@ -39,7 +39,7 @@ export default function ToolsMcp() {
- + {window.location.origin + mcpPath}
diff --git a/app/src/ui/routes/tools/mcp/tools.tsx b/app/src/ui/routes/tools/mcp/tools.tsx index d439fc1a..6c475dd1 100644 --- a/app/src/ui/routes/tools/mcp/tools.tsx +++ b/app/src/ui/routes/tools/mcp/tools.tsx @@ -12,6 +12,7 @@ import * as Formy from "ui/components/form/Formy"; import { appShellStore } from "ui/store"; import { Icon } from "ui/components/display/Icon"; import { useMcpClient } from "./hooks/use-mcp-client"; +import { Tooltip } from "@mantine/core"; export function Sidebar({ open, toggle }) { const client = useMcpClient(); @@ -48,7 +49,11 @@ export function Sidebar({ open, toggle }) { toggle={toggle} renderHeaderRight={() => (
- {error && } + {error && ( + + + + )} {tools.length} diff --git a/bun.lock b/bun.lock index ab638563..fa63422d 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.6", + "jsonv-ts": "0.9.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -1243,7 +1243,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -2529,7 +2529,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.8.6", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-z5jJ017LFOvAFFVodAIiCY024yW72RWc/K0Sct+OtuiLN+lKy+g0pI0jaz5JmuXaMIePc6HyopeeYHi8ffbYhw=="], + "jsonv-ts": ["jsonv-ts@0.9.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-sQZn7kdSMK9m3hLWvTLyNk2zCUmte2lVWIcK02633EwMosk/VAdRgpMyfMDMV6/ZzSMI0/SwevkUbkxdGQrWtg=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], @@ -4095,7 +4095,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + "@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], "@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], From 5d4a77fb10ddfdcc08d83318f968250ef7e7f17a Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 09:20:59 +0200 Subject: [PATCH 21/48] Update permission context handling and improve JSON field component - Enhanced `MediaController` to include context in the `entityCreate` permission for better access control. - Refactored permission checks in `useBkndAuth` to ensure correct validation of role permissions. - Modified `JsonField` component to directly use `formData` in `JsonEditor`, simplifying data handling and improving user experience. --- app/src/media/api/MediaController.ts | 4 +++- app/src/ui/client/schema/auth/use-bknd-auth.ts | 2 +- .../components/form/json-schema/fields/JsonField.tsx | 12 +----------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index e1f795b5..0523b6ac 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -189,7 +189,9 @@ export class MediaController extends Controller { }), ), jsc("query", s.object({ overwrite: s.boolean().optional() })), - permission(DataPermissions.entityCreate, {}), + permission(DataPermissions.entityCreate, { + context: (c) => ({ entity: c.req.param("entity") }), + }), permission(MediaPermissions.uploadFile, {}), async (c) => { const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param"); diff --git a/app/src/ui/client/schema/auth/use-bknd-auth.ts b/app/src/ui/client/schema/auth/use-bknd-auth.ts index b48d1e1d..7f83358f 100644 --- a/app/src/ui/client/schema/auth/use-bknd-auth.ts +++ b/app/src/ui/client/schema/auth/use-bknd-auth.ts @@ -49,7 +49,7 @@ export function useBkndAuth() { has_admin: Object.entries(config.auth.roles ?? {}).some( ([name, role]) => role.implicit_allow || - minimum_permissions.every((p) => role.permissions?.includes(p)), + minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)), ), }, routes: { diff --git a/app/src/ui/components/form/json-schema/fields/JsonField.tsx b/app/src/ui/components/form/json-schema/fields/JsonField.tsx index 1517a298..9fd2d5a7 100644 --- a/app/src/ui/components/form/json-schema/fields/JsonField.tsx +++ b/app/src/ui/components/form/json-schema/fields/JsonField.tsx @@ -10,23 +10,13 @@ export default function JsonField({ readonly, ...props }: FieldProps) { - const value = JSON.stringify(formData, null, 2); - - function handleChange(data) { - try { - onChange(JSON.parse(data)); - } catch (err) { - console.error(err); - } - } - const isDisabled = disabled || readonly; const id = props.idSchema.$id; return (
); } From 2d56b54e0c1423b5a323017b750c747cacbc28c4 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 09:40:02 +0200 Subject: [PATCH 22/48] Enhance Guard and Form components with improved error handling and debugging - Added debug logging in the `Guard` class to track policy evaluations and conditions. - Updated error logging in the `Form` component to provide more context on invalid submissions. - Introduced state management for form errors in the `AuthRolesEdit` component, displaying alerts for invalid data submissions. --- app/src/auth/authorize/Guard.ts | 5 +++++ app/src/ui/components/form/json-schema-form/Form.tsx | 2 +- app/src/ui/routes/auth/auth.roles.edit.$role.tsx | 10 +++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index e66dba14..6336a597 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -203,14 +203,17 @@ export class Guard { // set the default effect of the role permission let allowed = rolePermission.effect === "allow"; for (const policy of rolePermission.policies) { + $console.debug("guard: checking policy", { policy: policy.toJSON(), ctx }); // skip filter policies if (policy.content.effect === "filter") continue; // if condition is met, check the effect const meets = policy.meetsCondition(ctx); if (meets) { + $console.debug("guard: policy meets condition"); // if deny, then break early if (policy.content.effect === "deny") { + $console.debug("guard: policy is deny, setting allowed to false"); allowed = false; break; @@ -218,6 +221,8 @@ export class Guard { } else if (policy.content.effect === "allow") { allowed = true; } + } else { + $console.debug("guard: policy does not meet condition"); } } diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index acfa25b4..56087960 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -130,7 +130,7 @@ export function Form< if (errors.length === 0) { await onSubmit(data as Data); } else { - console.log("invalid", errors); + console.error("form: invalid", { data, errors }); onInvalidSubmit?.(errors, data); } } catch (e) { diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index 9637ed18..e1be5dd9 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -37,6 +37,8 @@ import { cn } from "ui/lib/utils"; import { JsonViewer } from "ui/components/code/JsonViewer"; import { mountOnce, useApiQuery } from "ui/client"; import { CodePreview } from "ui/components/code/CodePreview"; +import type { JsonError } from "json-schema-library"; +import { Alert } from "ui/components/display/Alert"; export function AuthRolesEdit(props) { useBrowserTitle(["Auth", "Roles", props.params.role]); @@ -72,6 +74,7 @@ const formConfig = { function AuthRolesEditInternal({ params }: { params: { role: string } }) { const [navigate] = useNavigate(); const { config, schema: authSchema, actions } = useBkndAuth(); + const [error, setError] = useState(); const roleName = params.role; const role = config.roles?.[roleName]; const { readonly, permissions } = useBknd(); @@ -91,6 +94,7 @@ function AuthRolesEditInternal({ params }: { params: { role: string } }) { } } async function handleUpdate(data: any) { + setError(undefined); await actions.roles.patch(roleName, data); } @@ -102,10 +106,13 @@ function AuthRolesEditInternal({ params }: { params: { role: string } }) { beforeSubmit={(data) => { return { ...data, - permissions: [...Object.values(data.permissions)], + permissions: [...Object.values(data.permissions).filter(Boolean)], }; }} onSubmit={handleUpdate} + onInvalidSubmit={(errors) => { + setError(errors); + }} > + {error && }
From cfb4b0e336944a90e48077c9b009e8a80873b889 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 09:59:00 +0200 Subject: [PATCH 23/48] Refactor JsonEditor and Permission components for improved state management and performance - Implemented debounced input handling in `JsonEditor` to enhance user experience and reduce unnecessary updates. - Updated `Permission` component to streamline permission state management and improve clarity in policy handling. - Refactored `Policies` component to utilize derived field context for better data handling and rendering efficiency. --- app/src/ui/components/code/JsonEditor.tsx | 5 +- .../ui/routes/auth/auth.roles.edit.$role.tsx | 66 ++++++++++++------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx index c65e59ac..93bb8d18 100644 --- a/app/src/ui/components/code/JsonEditor.tsx +++ b/app/src/ui/components/code/JsonEditor.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useState } from "react"; import { twMerge } from "tailwind-merge"; import type { CodeEditorProps } from "./CodeEditor"; +import { useDebouncedCallback } from "@mantine/hooks"; const CodeEditor = lazy(() => import("./CodeEditor")); export type JsonEditorProps = Omit & { @@ -21,13 +22,13 @@ export function JsonEditor({ const [editorValue, setEditorValue] = useState( JSON.stringify(value, null, 2), ); - const handleChange = (given: string) => { + const handleChange = useDebouncedCallback((given: string) => { const value = given === "" ? (emptyAs === "null" ? null : undefined) : given; try { setEditorValue(value); onChange?.(value ? JSON.parse(value) : value); } catch (e) {} - }; + }, 500); const handleBlur = (e) => { setEditorValue(JSON.stringify(value, null, 2)); onBlur?.(e); diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index e1be5dd9..51d909db 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -233,15 +233,19 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu const { value } = useDerivedFieldContext("permissions", (ctx) => { const v = ctx.value; if (!Array.isArray(v)) return undefined; - return v.find((v) => v && v.permission === permission.name); + const v2 = v.find((v) => v && v.permission === permission.name); + return { + set: !!v2, + policies: (v2?.policies?.length ?? 0) as number, + }; }); const { setValue } = useFormContext(); const [open, setOpen] = useState(false); - const data = value as PermissionData | undefined; - const policiesCount = data?.policies?.length ?? 0; + const policiesCount = value?.policies ?? 0; + const isSet = value?.set ?? false; async function handleSwitch() { - if (data) { + if (isSet) { setValue(path, undefined); setOpen(false); } else { @@ -253,6 +257,10 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu } } + function toggleOpen() { + setOpen((o) => !o); + } + return ( <>
setOpen((o) => !o)} + onClick={toggleOpen} />
- +
{open && ( @@ -299,13 +307,22 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu }; const Policies = ({ path, permission }: { path: string; permission: TPermission }) => { - const { value: _value } = useFormValue(path); - const { setValue, schema: policySchema, lib, deleteValue } = useDerivedFieldContext(path); - const value = _value ?? []; + const { + setValue, + schema: policySchema, + lib, + deleteValue, + value, + } = useDerivedFieldContext(path, ({ value }) => { + return { + policies: (value?.length ?? 0) as number, + }; + }); + const policiesCount = value?.policies ?? 0; function handleAdd() { setValue( - `${path}.${value.length}`, + `${path}.${policiesCount}`, lib.getTemplate(undefined, policySchema!.items, { addOptionalProps: true, }), @@ -317,19 +334,20 @@ const Policies = ({ path, permission }: { path: string; permission: TPermission } return ( -
0 && "gap-8")}> +
0 && "gap-8")}>
- {value.map((policy, i) => ( - - {i > 0 &&
} -
-
- + {policiesCount > 0 && + Array.from({ length: policiesCount }).map((_, i) => ( + + {i > 0 &&
} +
+
+ +
+ handleDelete(i)} size="sm" />
- handleDelete(i)} size="sm" /> -
-
- ))} + + ))}
@@ -366,7 +384,9 @@ const Policy = ({ }: { permission: TPermission; }) => { - const { value } = useFormValue(""); + const { value } = useDerivedFieldContext("", ({ value }) => ({ + effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter", + })); const $bknd = useBknd(); const $permissions = useApiQuery((api) => api.system.permissions(), { use: [mountOnce], From 88e5c06e9d284a68ff6b0493f34b12f0e04b90c5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 10:37:52 +0200 Subject: [PATCH 24/48] Enhance SystemController to improve config modification checks Updated the `SystemController` to include additional checks for read-only status and user permissions when modifying configurations. --- app/src/modules/server/SystemController.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 1190d551..9cdbd99e 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -17,6 +17,7 @@ import { mcp as mcpMiddleware, isNode, type McpServer, + threw, } from "bknd/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; @@ -380,7 +381,11 @@ export class SystemController extends Controller { async (c) => { const module = c.req.param("module") as ModuleKey | undefined; const { config, secrets, fresh } = c.req.valid("query"); - const readonly = this.app.isReadOnly(); + const readonly = + // either if app is read only in general + this.app.isReadOnly() || + // or if user is not allowed to modify the config + threw(() => this.ctx.guard.granted(SystemPermissions.configWrite, c, { module })); if (config) { this.ctx.guard.granted(SystemPermissions.configRead, c, { From 869031bbfa4b8b5015c91afa9a727bf0231640fe Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 12:43:32 +0200 Subject: [PATCH 25/48] Refactor CustomFieldWrapper and enhance schema handling in Policy component - Updated `CustomFieldWrapper` to accept a more structured schema object, improving clarity and type safety. - Modified schema handling in the `Policy` component to ensure proper context and variable naming, enhancing the overall user experience. - Introduced `autoFormatString` utility for dynamic button labeling based on schema name. --- .../ui/routes/auth/auth.roles.edit.$role.tsx | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index 51d909db..d24ac9b6 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -13,7 +13,7 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { routes } from "ui/lib/routes"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as Formy from "ui/components/form/Formy"; -import { ucFirst, s, transformObject, isObject } from "bknd/utils"; +import { ucFirst, s, transformObject, isObject, autoFormatString } from "bknd/utils"; import type { ModuleSchemas } from "bknd"; import { CustomField, @@ -357,8 +357,7 @@ const Policies = ({ path, permission }: { path: string; permission: TPermission }; const mergeSchemas = (...schemas: object[]) => { - const schema = s.allOf(schemas.filter(Boolean).map(s.fromSchema)); - return s.toTypes(schema, "Context"); + return s.allOf(schemas.filter(Boolean).map(s.fromSchema)); }; function replaceEntitiesEnum(schema: Record, entities: string[]) { @@ -407,7 +406,12 @@ const Policy = ({ name="condition" label="Condition" description="The condition that must be met for the policy to be applied." - schema={ctx} + schema={ + ctx && { + name: "Context", + content: s.toTypes(ctx, "Context"), + } + } > @@ -442,7 +446,12 @@ const Policy = ({ name="filter" label="Filter" description="Filter to apply to all queries on met condition." - schema={ctx} + schema={ + ctx && { + name: "Variables", + content: s.toTypes(ctx, "Variables"), + } + } > @@ -451,7 +460,22 @@ const Policy = ({ ); }; -const CustomFieldWrapper = ({ children, name, label, description, schema }: any) => { +const CustomFieldWrapper = ({ + children, + name, + label, + description, + schema, +}: { + children: React.ReactNode; + name: string; + label: string; + description: string; + schema?: { + name: string; + content: string | object; + }; +}) => { const errors = useFormError(name, { strict: true }); const Errors = errors.length > 0 && ( {errors.map((e) => e.message).join(", ")} @@ -480,15 +504,16 @@ const CustomFieldWrapper = ({ children, name, label, description, schema }: any) }} position="bottom-end" target={() => - typeof schema === "object" ? ( + typeof schema.content === "object" ? ( ) : ( @@ -496,7 +521,7 @@ const CustomFieldWrapper = ({ children, name, label, description, schema }: any) } >
From 292e4595ea1243a67f77c4d283d0aac44ffe1c92 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 12:49:39 +0200 Subject: [PATCH 26/48] feat: add endpoint/tool to retrieve TypeScript definitions for data entities Implemented a new endpoint at "/types" in the DataController to return TypeScript definitions for data entities, enhancing type safety and developer experience. --- app/src/data/api/DataController.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 163f0aff..e2608d20 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -15,6 +15,7 @@ import type { AppDataConfig } from "../data-schema"; import type { EntityManager, EntityData } from "data/entities"; import * as DataPermissions from "data/permissions"; import { repoQuery, type RepoQuery } from "data/server/query"; +import { EntityTypescript } from "data/entities/EntityTypescript"; export class DataController extends Controller { constructor( @@ -153,6 +154,20 @@ export class DataController extends Controller { }, ); + hono.get( + "/types", + permission(DataPermissions.entityRead), + describeRoute({ + summary: "Retrieve data typescript definitions", + tags: ["data"], + }), + mcpTool("data_types"), + async (c) => { + const et = new EntityTypescript(this.em); + return c.text(et.toString()); + }, + ); + // entity endpoints hono.route("/entity", this.getEntityRoutes()); From f2aad9caacac14c28d3d099859fb25275fabc1dd Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 14:07:37 +0200 Subject: [PATCH 27/48] make non-fillable fields visible but disabled in UI --- .../modules/migrations/migrations.spec.ts | 16 +- .../modules/migrations/samples/v10.json | 270 ++++++++++++++++++ app/src/data/entities/Entity.ts | 22 +- app/src/data/entities/mutation/Mutator.ts | 6 +- app/src/data/fields/Field.ts | 22 +- app/src/data/fields/field-test-suite.ts | 7 +- app/src/data/schema/SchemaManager.ts | 2 +- app/src/modules/db/migrations.ts | 24 ++ .../ui/components/form/Formy/components.tsx | 11 +- .../ui/modules/data/components/EntityForm.tsx | 8 +- .../ui/modules/data/hooks/useEntityForm.tsx | 2 +- 11 files changed, 353 insertions(+), 37 deletions(-) create mode 100644 app/__test__/modules/migrations/samples/v10.json diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index 12667460..f68b462b 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -7,7 +7,9 @@ import v7 from "./samples/v7.json"; import v8 from "./samples/v8.json"; import v8_2 from "./samples/v8-2.json"; import v9 from "./samples/v9.json"; +import v10 from "./samples/v10.json"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { CURRENT_VERSION } from "modules/db/migrations"; beforeAll(() => disableConsoleLog()); afterAll(enableConsoleLog); @@ -61,7 +63,7 @@ async function getRawConfig( return await db .selectFrom("__bknd") .selectAll() - .$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version)) + .where("version", "=", opts?.version ?? CURRENT_VERSION) .$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types)) .execute(); } @@ -115,7 +117,6 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); const [config, secrets] = (await getRawConfig(app, { - version: 10, types: ["config", "secrets"], })) as any; @@ -129,4 +130,15 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); }); + + test("migration from 10 to 11", async () => { + expect(v10.version).toBe(10); + expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]); + + const app = await createVersionedApp(v10); + + expect(app.version()).toBeGreaterThan(10); + const [config] = (await getRawConfig(app, { types: ["config"] })) as any; + expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true); + }); }); diff --git a/app/__test__/modules/migrations/samples/v10.json b/app/__test__/modules/migrations/samples/v10.json new file mode 100644 index 00000000..022ef2f1 --- /dev/null +++ b/app/__test__/modules/migrations/samples/v10.json @@ -0,0 +1,270 @@ +{ + "version": 10, + "server": { + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "allow_credentials": true + }, + "mcp": { + "enabled": true, + "path": "/api/system/mcp", + "logLevel": "warning" + } + }, + "data": { + "basepath": "/api/data", + "default_primary_format": "integer", + "entities": { + "test": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": ["read", "update"] + } + }, + "status": { + "type": "enum", + "config": { + "default_value": "INACTIVE", + "options": { + "type": "strings", + "values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"] + }, + "required": true, + "fillable": true + } + }, + "created_at": { + "type": "date", + "config": { + "type": "datetime", + "required": true, + "fillable": true + } + }, + "schema": { + "type": "jsonschema", + "config": { + "default_from_schema": true, + "schema": { + "type": "object", + "properties": { + "one": { + "type": "number", + "default": 1 + } + } + }, + "required": true, + "fillable": true + } + }, + "text": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "items": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "media": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "path": { + "type": "text", + "config": { + "required": true, + "fillable": true + } + }, + "folder": { + "type": "boolean", + "config": { + "default_value": false, + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "mime_type": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "size": { + "type": "number", + "config": { + "required": false, + "fillable": true + } + }, + "scope": { + "type": "text", + "config": { + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "etag": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "modified_at": { + "type": "date", + "config": { + "type": "datetime", + "required": false, + "fillable": true + } + }, + "reference": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "entity_id": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "metadata": { + "type": "json", + "config": { + "required": false, + "fillable": true + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": {}, + "indices": { + "idx_unique_media_path": { + "entity": "media", + "fields": ["path"], + "unique": true + }, + "idx_media_reference": { + "entity": "media", + "fields": ["reference"], + "unique": false + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + } + } + }, + "auth": { + "enabled": false, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "", + "alg": "HS256", + "fields": ["id", "email", "role"] + }, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "partitioned": false, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "type": "password", + "enabled": true, + "config": { + "hashing": "sha256" + } + } + }, + "guard": { + "enabled": false + }, + "roles": {} + }, + "media": { + "enabled": false + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index fcbe0929..db7e6f4e 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -136,8 +136,10 @@ export class Entity< .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name)); } - getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] { - return this.getFields(include_virtual).filter((field) => field.isFillable(context)); + getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] { + return this.getFields({ virtual: include_virtual }).filter((field) => + field.isFillable(context), + ); } getRequiredFields(): Field[] { @@ -189,9 +191,15 @@ export class Entity< return this.fields.findIndex((field) => field.name === name) !== -1; } - getFields(include_virtual: boolean = false): Field[] { - if (include_virtual) return this.fields; - return this.fields.filter((f) => !f.isVirtual()); + getFields({ + virtual = false, + primary = true, + }: { virtual?: boolean; primary?: boolean } = {}): Field[] { + return this.fields.filter((f) => { + if (!virtual && f.isVirtual()) return false; + if (!primary && f instanceof PrimaryField) return false; + return true; + }); } addField(field: Field) { @@ -231,7 +239,7 @@ export class Entity< } } - const fields = this.getFillableFields(context, false); + const fields = this.getFillableFields(context as any, false); if (options?.ignoreUnknown !== true) { const field_names = fields.map((f) => f.name); @@ -275,7 +283,7 @@ export class Entity< fields = this.getFillableFields(options.context); break; default: - fields = this.getFields(true); + fields = this.getFields({ virtual: true }); } const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); diff --git a/app/src/data/entities/mutation/Mutator.ts b/app/src/data/entities/mutation/Mutator.ts index 84389c3d..e4bb6f0e 100644 --- a/app/src/data/entities/mutation/Mutator.ts +++ b/app/src/data/entities/mutation/Mutator.ts @@ -83,8 +83,10 @@ export class Mutator< } // we should never get here, but just to be sure (why?) - if (!field.isFillable(context)) { - throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`); + if (!field.isFillable(context as any)) { + throw new Error( + `Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`, + ); } // transform from field diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index 98a1f45d..8451d1df 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s .strictObject({ label: s.string(), description: s.string(), - required: s.boolean({ default: false }), - fillable: s.anyOf([ - s.boolean({ title: "Boolean" }), - s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }), - ]), + required: s.boolean({ default: DEFAULT_REQUIRED }), + fillable: s.anyOf( + [ + s.boolean({ title: "Boolean" }), + s.array(s.string({ enum: ["create", "update"] }), { + title: "Context", + uniqueItems: true, + }), + ], + { + default: DEFAULT_FILLABLE, + }, + ), hidden: s.anyOf([ s.boolean({ title: "Boolean" }), // @todo: tmp workaround @@ -103,7 +111,7 @@ export abstract class Field< return this.config?.default_value; } - isFillable(context?: TActionContext): boolean { + isFillable(context?: "create" | "update"): boolean { if (Array.isArray(this.config.fillable)) { return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE; } @@ -165,7 +173,7 @@ export abstract class Field< // @todo: add field level validation isValid(value: any, context: TActionContext): boolean { if (typeof value !== "undefined") { - return this.isFillable(context); + return this.isFillable(context as any); } else if (context === "create") { return !this.isRequired(); } diff --git a/app/src/data/fields/field-test-suite.ts b/app/src/data/fields/field-test-suite.ts index 369d41ba..bf81fbd3 100644 --- a/app/src/data/fields/field-test-suite.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -99,6 +99,7 @@ export function fieldTestSuite( const _config = { ..._requiredConfig, required: false, + fillable: true, }; function fieldJson(field: Field) { @@ -116,10 +117,7 @@ export function fieldTestSuite( expect(fieldJson(fillable)).toEqual({ type: noConfigField.type, - config: { - ..._config, - fillable: true, - }, + config: _config, }); expect(fieldJson(required)).toEqual({ @@ -150,7 +148,6 @@ export function fieldTestSuite( type: requiredAndDefault.type, config: { ..._config, - fillable: true, required: true, default_value: config.defaultValue, }, diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index 78708d60..8a069572 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -77,7 +77,7 @@ export class SchemaManager { } getIntrospectionFromEntity(entity: Entity): IntrospectedTable { - const fields = entity.getFields(false); + const fields = entity.getFields({ virtual: false }); const indices = this.em.getIndicesOf(entity); // this is intentionally setting values to defaults, like "nullable" and "default" diff --git a/app/src/modules/db/migrations.ts b/app/src/modules/db/migrations.ts index 13f39eea..e97071b4 100644 --- a/app/src/modules/db/migrations.ts +++ b/app/src/modules/db/migrations.ts @@ -1,6 +1,7 @@ import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; +import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -107,6 +108,29 @@ export const migrations: Migration[] = [ return config; }, }, + { + // change field.config.fillable to only "create" and "update" + version: 11, + up: async (config: InitialModuleConfigs) => { + const { data, ...rest } = config; + return { + ...rest, + data: { + ...data, + entities: transformObject(data?.entities ?? {}, (entity) => { + return { + ...entity, + fields: transformObject(entity?.fields ?? {}, (field) => { + const fillable = field!.config?.fillable; + if (!fillable || typeof fillable === "boolean") return field; + return { ...field, config: { ...field!.config, fillable: true } }; + }), + }; + }), + }, + }; + }, + }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index 502a8449..cd85aa43 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -29,7 +29,7 @@ export const Group = ({ > ref={ref} className={twMerge( "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed", - disabledOrReadonly && "bg-muted/50 text-primary/50", + disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed", !disabledOrReadonly && "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", props.className, @@ -153,7 +153,7 @@ export const Textarea = forwardRef @@ -213,7 +213,7 @@ export const BooleanInput = forwardRef { - console.log("setting", bool); props.onChange?.({ target: { value: bool } }); }} {...(props as any)} @@ -293,7 +292,7 @@ export const Select = forwardRef< {...props} ref={ref} className={twMerge( - "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", + "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed", "appearance-none h-11 w-full", !props.multiple && "border-r-8 border-r-transparent", props.className, diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index ff778a17..9943aba1 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -44,7 +44,7 @@ export function EntityForm({ className, action, }: EntityFormProps) { - const fields = entity.getFillableFields(action, true); + const fields = entity.getFields({ virtual: true, primary: false }); const options = useEntityAdminOptions(entity, action); return ( @@ -92,10 +92,6 @@ export function EntityForm({ ); } - if (!field.isFillable(action)) { - return; - } - const _key = `${entity.name}-${field.name}-${key}`; return ( @@ -127,7 +123,7 @@ export function EntityForm({ Date: Fri, 24 Oct 2025 14:08:32 +0200 Subject: [PATCH 28/48] Revert "make non-fillable fields visible but disabled in UI" This reverts commit f2aad9caacac14c28d3d099859fb25275fabc1dd. --- .../modules/migrations/migrations.spec.ts | 16 +- .../modules/migrations/samples/v10.json | 270 ------------------ app/src/data/entities/Entity.ts | 22 +- app/src/data/entities/mutation/Mutator.ts | 6 +- app/src/data/fields/Field.ts | 22 +- app/src/data/fields/field-test-suite.ts | 7 +- app/src/data/schema/SchemaManager.ts | 2 +- app/src/modules/db/migrations.ts | 24 -- .../ui/components/form/Formy/components.tsx | 11 +- .../ui/modules/data/components/EntityForm.tsx | 8 +- .../ui/modules/data/hooks/useEntityForm.tsx | 2 +- 11 files changed, 37 insertions(+), 353 deletions(-) delete mode 100644 app/__test__/modules/migrations/samples/v10.json diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index f68b462b..12667460 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -7,9 +7,7 @@ import v7 from "./samples/v7.json"; import v8 from "./samples/v8.json"; import v8_2 from "./samples/v8-2.json"; import v9 from "./samples/v9.json"; -import v10 from "./samples/v10.json"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; -import { CURRENT_VERSION } from "modules/db/migrations"; beforeAll(() => disableConsoleLog()); afterAll(enableConsoleLog); @@ -63,7 +61,7 @@ async function getRawConfig( return await db .selectFrom("__bknd") .selectAll() - .where("version", "=", opts?.version ?? CURRENT_VERSION) + .$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version)) .$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types)) .execute(); } @@ -117,6 +115,7 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); const [config, secrets] = (await getRawConfig(app, { + version: 10, types: ["config", "secrets"], })) as any; @@ -130,15 +129,4 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); }); - - test("migration from 10 to 11", async () => { - expect(v10.version).toBe(10); - expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]); - - const app = await createVersionedApp(v10); - - expect(app.version()).toBeGreaterThan(10); - const [config] = (await getRawConfig(app, { types: ["config"] })) as any; - expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true); - }); }); diff --git a/app/__test__/modules/migrations/samples/v10.json b/app/__test__/modules/migrations/samples/v10.json deleted file mode 100644 index 022ef2f1..00000000 --- a/app/__test__/modules/migrations/samples/v10.json +++ /dev/null @@ -1,270 +0,0 @@ -{ - "version": 10, - "server": { - "cors": { - "origin": "*", - "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], - "allow_headers": [ - "Content-Type", - "Content-Length", - "Authorization", - "Accept" - ], - "allow_credentials": true - }, - "mcp": { - "enabled": true, - "path": "/api/system/mcp", - "logLevel": "warning" - } - }, - "data": { - "basepath": "/api/data", - "default_primary_format": "integer", - "entities": { - "test": { - "type": "regular", - "fields": { - "id": { - "type": "primary", - "config": { - "format": "integer", - "fillable": false, - "required": false - } - }, - "title": { - "type": "text", - "config": { - "required": false, - "fillable": ["read", "update"] - } - }, - "status": { - "type": "enum", - "config": { - "default_value": "INACTIVE", - "options": { - "type": "strings", - "values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"] - }, - "required": true, - "fillable": true - } - }, - "created_at": { - "type": "date", - "config": { - "type": "datetime", - "required": true, - "fillable": true - } - }, - "schema": { - "type": "jsonschema", - "config": { - "default_from_schema": true, - "schema": { - "type": "object", - "properties": { - "one": { - "type": "number", - "default": 1 - } - } - }, - "required": true, - "fillable": true - } - }, - "text": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - } - }, - "config": { - "sort_field": "id", - "sort_dir": "asc" - } - }, - "items": { - "type": "regular", - "fields": { - "id": { - "type": "primary", - "config": { - "format": "integer", - "fillable": false, - "required": false - } - }, - "title": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - } - }, - "config": { - "sort_field": "id", - "sort_dir": "asc" - } - }, - "media": { - "type": "system", - "fields": { - "id": { - "type": "primary", - "config": { - "format": "integer", - "fillable": false, - "required": false - } - }, - "path": { - "type": "text", - "config": { - "required": true, - "fillable": true - } - }, - "folder": { - "type": "boolean", - "config": { - "default_value": false, - "hidden": true, - "fillable": ["create"], - "required": false - } - }, - "mime_type": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "size": { - "type": "number", - "config": { - "required": false, - "fillable": true - } - }, - "scope": { - "type": "text", - "config": { - "hidden": true, - "fillable": ["create"], - "required": false - } - }, - "etag": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "modified_at": { - "type": "date", - "config": { - "type": "datetime", - "required": false, - "fillable": true - } - }, - "reference": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "entity_id": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "metadata": { - "type": "json", - "config": { - "required": false, - "fillable": true - } - } - }, - "config": { - "sort_field": "id", - "sort_dir": "asc" - } - } - }, - "relations": {}, - "indices": { - "idx_unique_media_path": { - "entity": "media", - "fields": ["path"], - "unique": true - }, - "idx_media_reference": { - "entity": "media", - "fields": ["reference"], - "unique": false - }, - "idx_media_entity_id": { - "entity": "media", - "fields": ["entity_id"], - "unique": false - } - } - }, - "auth": { - "enabled": false, - "basepath": "/api/auth", - "entity_name": "users", - "allow_register": true, - "jwt": { - "secret": "", - "alg": "HS256", - "fields": ["id", "email", "role"] - }, - "cookie": { - "path": "/", - "sameSite": "lax", - "secure": true, - "httpOnly": true, - "expires": 604800, - "partitioned": false, - "renew": true, - "pathSuccess": "/", - "pathLoggedOut": "/" - }, - "strategies": { - "password": { - "type": "password", - "enabled": true, - "config": { - "hashing": "sha256" - } - } - }, - "guard": { - "enabled": false - }, - "roles": {} - }, - "media": { - "enabled": false - }, - "flows": { - "basepath": "/api/flows", - "flows": {} - } -} diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index db7e6f4e..fcbe0929 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -136,10 +136,8 @@ export class Entity< .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name)); } - getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] { - return this.getFields({ virtual: include_virtual }).filter((field) => - field.isFillable(context), - ); + getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] { + return this.getFields(include_virtual).filter((field) => field.isFillable(context)); } getRequiredFields(): Field[] { @@ -191,15 +189,9 @@ export class Entity< return this.fields.findIndex((field) => field.name === name) !== -1; } - getFields({ - virtual = false, - primary = true, - }: { virtual?: boolean; primary?: boolean } = {}): Field[] { - return this.fields.filter((f) => { - if (!virtual && f.isVirtual()) return false; - if (!primary && f instanceof PrimaryField) return false; - return true; - }); + getFields(include_virtual: boolean = false): Field[] { + if (include_virtual) return this.fields; + return this.fields.filter((f) => !f.isVirtual()); } addField(field: Field) { @@ -239,7 +231,7 @@ export class Entity< } } - const fields = this.getFillableFields(context as any, false); + const fields = this.getFillableFields(context, false); if (options?.ignoreUnknown !== true) { const field_names = fields.map((f) => f.name); @@ -283,7 +275,7 @@ export class Entity< fields = this.getFillableFields(options.context); break; default: - fields = this.getFields({ virtual: true }); + fields = this.getFields(true); } const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); diff --git a/app/src/data/entities/mutation/Mutator.ts b/app/src/data/entities/mutation/Mutator.ts index e4bb6f0e..84389c3d 100644 --- a/app/src/data/entities/mutation/Mutator.ts +++ b/app/src/data/entities/mutation/Mutator.ts @@ -83,10 +83,8 @@ export class Mutator< } // we should never get here, but just to be sure (why?) - if (!field.isFillable(context as any)) { - throw new Error( - `Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`, - ); + if (!field.isFillable(context)) { + throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`); } // transform from field diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index 8451d1df..98a1f45d 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -26,19 +26,11 @@ export const baseFieldConfigSchema = s .strictObject({ label: s.string(), description: s.string(), - required: s.boolean({ default: DEFAULT_REQUIRED }), - fillable: s.anyOf( - [ - s.boolean({ title: "Boolean" }), - s.array(s.string({ enum: ["create", "update"] }), { - title: "Context", - uniqueItems: true, - }), - ], - { - default: DEFAULT_FILLABLE, - }, - ), + required: s.boolean({ default: false }), + fillable: s.anyOf([ + s.boolean({ title: "Boolean" }), + s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }), + ]), hidden: s.anyOf([ s.boolean({ title: "Boolean" }), // @todo: tmp workaround @@ -111,7 +103,7 @@ export abstract class Field< return this.config?.default_value; } - isFillable(context?: "create" | "update"): boolean { + isFillable(context?: TActionContext): boolean { if (Array.isArray(this.config.fillable)) { return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE; } @@ -173,7 +165,7 @@ export abstract class Field< // @todo: add field level validation isValid(value: any, context: TActionContext): boolean { if (typeof value !== "undefined") { - return this.isFillable(context as any); + return this.isFillable(context); } else if (context === "create") { return !this.isRequired(); } diff --git a/app/src/data/fields/field-test-suite.ts b/app/src/data/fields/field-test-suite.ts index bf81fbd3..369d41ba 100644 --- a/app/src/data/fields/field-test-suite.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -99,7 +99,6 @@ export function fieldTestSuite( const _config = { ..._requiredConfig, required: false, - fillable: true, }; function fieldJson(field: Field) { @@ -117,7 +116,10 @@ export function fieldTestSuite( expect(fieldJson(fillable)).toEqual({ type: noConfigField.type, - config: _config, + config: { + ..._config, + fillable: true, + }, }); expect(fieldJson(required)).toEqual({ @@ -148,6 +150,7 @@ export function fieldTestSuite( type: requiredAndDefault.type, config: { ..._config, + fillable: true, required: true, default_value: config.defaultValue, }, diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index 8a069572..78708d60 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -77,7 +77,7 @@ export class SchemaManager { } getIntrospectionFromEntity(entity: Entity): IntrospectedTable { - const fields = entity.getFields({ virtual: false }); + const fields = entity.getFields(false); const indices = this.em.getIndicesOf(entity); // this is intentionally setting values to defaults, like "nullable" and "default" diff --git a/app/src/modules/db/migrations.ts b/app/src/modules/db/migrations.ts index e97071b4..13f39eea 100644 --- a/app/src/modules/db/migrations.ts +++ b/app/src/modules/db/migrations.ts @@ -1,7 +1,6 @@ import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; -import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -108,29 +107,6 @@ export const migrations: Migration[] = [ return config; }, }, - { - // change field.config.fillable to only "create" and "update" - version: 11, - up: async (config: InitialModuleConfigs) => { - const { data, ...rest } = config; - return { - ...rest, - data: { - ...data, - entities: transformObject(data?.entities ?? {}, (entity) => { - return { - ...entity, - fields: transformObject(entity?.fields ?? {}, (field) => { - const fillable = field!.config?.fillable; - if (!fillable || typeof fillable === "boolean") return field; - return { ...field, config: { ...field!.config, fillable: true } }; - }), - }; - }), - }, - }; - }, - }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index cd85aa43..502a8449 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -29,7 +29,7 @@ export const Group = ({ > ref={ref} className={twMerge( "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed", - disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed", + disabledOrReadonly && "bg-muted/50 text-primary/50", !disabledOrReadonly && "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", props.className, @@ -153,7 +153,7 @@ export const Textarea = forwardRef @@ -213,7 +213,7 @@ export const BooleanInput = forwardRef { + console.log("setting", bool); props.onChange?.({ target: { value: bool } }); }} {...(props as any)} @@ -292,7 +293,7 @@ export const Select = forwardRef< {...props} ref={ref} className={twMerge( - "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed", + "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", "appearance-none h-11 w-full", !props.multiple && "border-r-8 border-r-transparent", props.className, diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 9943aba1..ff778a17 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -44,7 +44,7 @@ export function EntityForm({ className, action, }: EntityFormProps) { - const fields = entity.getFields({ virtual: true, primary: false }); + const fields = entity.getFillableFields(action, true); const options = useEntityAdminOptions(entity, action); return ( @@ -92,6 +92,10 @@ export function EntityForm({ ); } + if (!field.isFillable(action)) { + return; + } + const _key = `${entity.name}-${field.name}-${key}`; return ( @@ -123,7 +127,7 @@ export function EntityForm({ Date: Fri, 24 Oct 2025 15:12:47 +0200 Subject: [PATCH 29/48] fix typo on AdminController flash message --- app/src/modules/server/AdminController.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 454ad404..e0981016 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -114,7 +114,7 @@ export class AdminController extends Controller { }), permission(SystemPermissions.schemaRead, { onDenied: async (c) => { - addFlashMessage(c, "You not allowed to read the schema", "warning"); + addFlashMessage(c, "You are not allowed to read the schema", "warning"); }, context: (c) => ({}), }), From 88cc406002c344051b56af03c1bae13a58411574 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 18:22:13 +0200 Subject: [PATCH 30/48] chore: update version to 0.19.0-rc.1 and improve error handling in App class - Bumped version in package.json to 0.19.0-rc.1. - Changed error throw to console.error in fetch method of App class for better debugging. - Updated permissions in DataController for the "/types" endpoint to include context for schemaRead. --- app/package.json | 2 +- app/src/App.ts | 3 +-- app/src/data/api/DataController.ts | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index c256cc9b..e7b522b0 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.18.1", + "version": "0.19.0-rc.1", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/App.ts b/app/src/App.ts index b8cbde7d..ed6d765f 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -245,9 +245,8 @@ export class App< get fetch(): Hono["fetch"] { if (!this.isBuilt()) { - throw new Error("App is not built yet, run build() first"); + console.error("App is not built yet, run build() first"); } - return this.server.fetch as any; } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index afdab4bc..315a58d5 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -161,7 +161,9 @@ export class DataController extends Controller { hono.get( "/types", - permission(DataPermissions.entityRead), + permission(SystemPermissions.schemaRead, { + context: (c) => ({ module: "data" }), + }), describeRoute({ summary: "Retrieve data typescript definitions", tags: ["data"], From 1fc6e810aea69d115210c014c7f1b6bfdb477b77 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 18:22:58 +0200 Subject: [PATCH 31/48] feat: improve Deno support and enhance serveStaticViaImport function - Introduced support for Deno as a runtime in the documentation. - Updated serveStaticViaImport function to accept additional options: appendRaw and package. - Improved error logging in serveStaticViaImport for better debugging. - Added new Deno integration documentation with examples for serving static assets. --- app/src/adapter/index.ts | 19 +++- .../integration/(runtimes)/deno.mdx | 105 ++++++++++++++++++ .../integration/(runtimes)/meta.json | 2 +- .../integration/introduction.mdx | 6 + docs/content/docs/(documentation)/start.mdx | 6 + 5 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 949aaba1..79f4c97c 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -154,23 +154,32 @@ export async function createRuntimeApp( * }); * ``` */ -export function serveStaticViaImport(opts?: { manifest?: Manifest }) { +export function serveStaticViaImport(opts?: { + manifest?: Manifest; + appendRaw?: boolean; + package?: string; +}) { let files: string[] | undefined; + const pkg = opts?.package ?? "bknd"; // @ts-ignore return async (c: Context, next: Next) => { if (!files) { const manifest = opts?.manifest || - ((await import("bknd/dist/manifest.json", { with: { type: "json" } })) - .default as Manifest); + (( + await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, { + with: { type: "json" }, + }) + ).default as Manifest); files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]); } const path = c.req.path.substring(1); if (files.includes(path)) { try { - const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, { + const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`; + const content = await import(/* @vite-ignore */ url, { with: { type: "text" }, }).then((m) => m.default); @@ -183,7 +192,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) { }); } } catch (e) { - console.error("Error serving static file:", e); + console.error(`Error serving static file "${path}":`, String(e)); return c.text("File not found", 404); } } diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx b/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx new file mode 100644 index 00000000..a26ada23 --- /dev/null +++ b/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx @@ -0,0 +1,105 @@ +--- +title: "Deno" +description: "Run bknd inside Deno" +tags: ["documentation"] +--- + +Deno is fully supported as a runtime for bknd. If you plan to solely use the API, the setup is pretty straightforward. + +```ts title="main.ts" +import { createAdapterApp } from "npm:bknd/adapter"; + +const app = await createAdapterApp({ + connection: { + url: "file:data.db", + }, +}); + +export default { + fetch: app.fetch, +}; +``` + +## Serve the Admin UI + +In order to also serve the static assets of the admin UI, you have 3 choices: + +1. Use the `serveStaticViaImport` function to serve the static assets from the `bknd` package directly (requires unstable `raw-imports`). +2. Copy the static assets to your local project and use Hono's `serveStatic` middleware. +3. Use the `adminOptions.assetsPath` property to point to a remote address with the static assets. + +### `serveStaticViaImport` + +The `serveStaticViaImport` function is a middleware that serves the static assets from the `bknd` package directly using dynamic raw imports. It requires the unstable `raw-imports` feature to be enabled. You can enable it by adding the following to your `deno.json`: + +```json title="deno.json" +{ + "unstable": ["raw-imports"] +} +``` + +Or by using the `--unstable-raw-imports` flag when running your script. Now create a `main.ts` file to serve the API and static assets: + +```ts title="main.ts" +import { createRuntimeApp, serveStaticViaImport } from "bknd/adapter"; + +const app = await createRuntimeApp({ + connection: { + url: "file:data.db", + }, + serveStatic: serveStaticViaImport() +}); + +export default { + fetch: app.fetch, +}; +``` + +### `serveStatic` from local files + +You can also serve the static assets from your local project by using Hono's `serveStatic` middleware. You can do so by copying the static assets to your local project and using the `serveStatic` middleware. First, you have to copy the static assets, by running the following command: + +```bash +deno run npm:bknd copy-assets --out public +``` + +This will copy the static assets to the `public` directory and then serve them from there: + +```ts title="main.ts" +import { createRuntimeApp, serveStatic } from "bknd/adapter"; +import { serveStatic } from "npm:hono/deno"; + +const app = await createRuntimeApp({ + connection: { + url: "file:data.db", + }, + serveStatic: serveStatic({ + root: "./public", + }), +}); + +export default { + fetch: app.fetch, +}; +``` + +### `adminOptions.assetsPath` + +You can also use the `adminOptions.assetsPath` property to point to a remote address with the static assets. This is useful in case none of the other methods work for you. + +```ts title="main.ts" +import { createRuntimeApp } from "bknd/adapter"; + +const app = await createRuntimeApp({ + connection: { + url: "file:data.db", + }, + adminOptions: { + assetsPath: "https://...", + }, +}); + +export default { + fetch: app.fetch, +}; +``` \ No newline at end of file diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/meta.json b/docs/content/docs/(documentation)/integration/(runtimes)/meta.json index 9083adca..33c50b3c 100644 --- a/docs/content/docs/(documentation)/integration/(runtimes)/meta.json +++ b/docs/content/docs/(documentation)/integration/(runtimes)/meta.json @@ -1,3 +1,3 @@ { - "pages": ["node", "bun", "cloudflare", "aws", "docker"] + "pages": ["node", "bun", "cloudflare", "deno", "aws", "docker"] } diff --git a/docs/content/docs/(documentation)/integration/introduction.mdx b/docs/content/docs/(documentation)/integration/introduction.mdx index 6d67d949..63302088 100644 --- a/docs/content/docs/(documentation)/integration/introduction.mdx +++ b/docs/content/docs/(documentation)/integration/introduction.mdx @@ -61,6 +61,12 @@ If you prefer to use a runtime instead of a framework, you can choose from the f href="/integration/cloudflare" /> +} + title="Deno" + href="/integration/deno" +/> + } title="AWS Lambda" diff --git a/docs/content/docs/(documentation)/start.mdx b/docs/content/docs/(documentation)/start.mdx index 8e664c6c..91bfdd7e 100644 --- a/docs/content/docs/(documentation)/start.mdx +++ b/docs/content/docs/(documentation)/start.mdx @@ -97,6 +97,12 @@ Start by using the integration guide for these popular frameworks/runtimes. Ther href="/integration/bun" /> +} + title="Deno" + href="/integration/deno" +/> + } title="AWS Lambda" From b787837dd298216064f10bea0a06d2cb9083c03f Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 25 Oct 2025 10:18:43 +0200 Subject: [PATCH 32/48] add deno to the cli starters --- app/src/cli/commands/create/create.ts | 3 ++- app/src/cli/commands/create/templates/deno.ts | 21 +++++++++++++++++++ .../cli/commands/create/templates/index.ts | 8 +++++++ examples/.gitignore | 5 ++++- examples/deno/deno.json | 8 +++++++ examples/deno/main.ts | 12 +++++------ examples/deno/package.json | 7 ------- 7 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 app/src/cli/commands/create/templates/deno.ts create mode 100644 examples/deno/deno.json delete mode 100644 examples/deno/package.json diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index 217b07dc..d3caae67 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -20,6 +20,7 @@ const config = { node: "Node.js", bun: "Bun", cloudflare: "Cloudflare", + deno: "Deno", aws: "AWS Lambda", }, framework: { @@ -269,7 +270,7 @@ async function action(options: { ); $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); - { + if (template.installDeps !== false) { const install = options.yes ?? (await $p.confirm({ diff --git a/app/src/cli/commands/create/templates/deno.ts b/app/src/cli/commands/create/templates/deno.ts new file mode 100644 index 00000000..eb17269a --- /dev/null +++ b/app/src/cli/commands/create/templates/deno.ts @@ -0,0 +1,21 @@ +import { overrideJson } from "cli/commands/create/npm"; +import type { Template } from "cli/commands/create/templates"; +import { getVersion } from "cli/utils/sys"; + +export const deno = { + key: "deno", + title: "Deno Basic", + integration: "deno", + description: "A basic bknd Deno server with static assets", + path: "gh:bknd-io/bknd/examples/deno", + installDeps: false, + ref: true, + setup: async (ctx) => { + const version = await getVersion(); + await overrideJson( + "deno.json", + (json) => ({ ...json, links: undefined, imports: { bknd: `npm:bknd@${version}` } }), + { dir: ctx.dir }, + ); + }, +} satisfies Template; diff --git a/app/src/cli/commands/create/templates/index.ts b/app/src/cli/commands/create/templates/index.ts index ed0f9e18..7aab8d51 100644 --- a/app/src/cli/commands/create/templates/index.ts +++ b/app/src/cli/commands/create/templates/index.ts @@ -1,3 +1,4 @@ +import { deno } from "cli/commands/create/templates/deno"; import { cloudflare } from "./cloudflare"; export type TemplateSetupCtx = { @@ -15,6 +16,7 @@ export type Integration = | "react-router" | "astro" | "aws" + | "deno" | "custom"; type TemplateScripts = "install" | "dev" | "build" | "start"; @@ -34,6 +36,11 @@ export type Template = { * adds a ref "#{ref}" to the path. If "true", adds the current version of bknd */ ref?: true | string; + /** + * control whether to install dependencies automatically + * e.g. on deno, this is not needed + */ + installDeps?: boolean; scripts?: Partial>; preinstall?: (ctx: TemplateSetupCtx) => Promise; postinstall?: (ctx: TemplateSetupCtx) => Promise; @@ -90,4 +97,5 @@ export const templates: Template[] = [ path: "gh:bknd-io/bknd/examples/aws-lambda", ref: true, }, + deno, ]; diff --git a/examples/.gitignore b/examples/.gitignore index f0f60a69..d3058467 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1,2 +1,5 @@ */package-lock.json -*/bun.lock \ No newline at end of file +*/bun.lock +*/deno.lock +*/node_modules +*/*.db \ No newline at end of file diff --git a/examples/deno/deno.json b/examples/deno/deno.json new file mode 100644 index 00000000..0f8edde3 --- /dev/null +++ b/examples/deno/deno.json @@ -0,0 +1,8 @@ +{ + "nodeModulesDir": "auto", + "imports": { + "bknd": "npm:bknd@0.19.0-rc.1" + }, + "links": ["../../app/"], + "unstable": ["raw-imports"] +} diff --git a/examples/deno/main.ts b/examples/deno/main.ts index 58e052f2..68aba30f 100644 --- a/examples/deno/main.ts +++ b/examples/deno/main.ts @@ -1,14 +1,12 @@ -import { createRuntimeApp } from "bknd/adapter"; +import { createRuntimeApp, serveStaticViaImport } from "bknd/adapter"; const app = await createRuntimeApp({ connection: { url: "file:./data.db", }, - adminOptions: { - // currently needs a hosted version of the static assets - assetsPath: "https://cdn.bknd.io/bknd/static/0.15.0-rc.9/", - }, + serveStatic: serveStaticViaImport(), }); -// @ts-ignore -Deno.serve(app.fetch); +export default { + fetch: app.fetch, +}; diff --git a/examples/deno/package.json b/examples/deno/package.json deleted file mode 100644 index 97faf720..00000000 --- a/examples/deno/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "bknd-deno-example", - "private": true, - "dependencies": { - "bknd": "file:../../app" - } -} From 2fd5e71574a39c5f3ce0611053b718e9791af03a Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 25 Oct 2025 10:26:05 +0200 Subject: [PATCH 33/48] finalize deno addition to the cli starters --- app/src/cli/commands/create/create.ts | 21 +++++++++++--------- app/src/cli/commands/create/npm.ts | 28 ++++++++++++++------------- examples/deno/deno.json | 3 +++ 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index d3caae67..3fca3b2b 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -10,6 +10,7 @@ import color from "picocolors"; import { overridePackageJson, updateBkndPackages } from "./npm"; import { type Template, templates, type TemplateSetupCtx } from "./templates"; import { createScoped, flush } from "cli/utils/telemetry"; +import path from "node:path"; const config = { types: { @@ -260,15 +261,17 @@ async function action(options: { } } - // update package name - await overridePackageJson( - (pkg) => ({ - ...pkg, - name: ctx.name, - }), - { dir: ctx.dir }, - ); - $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); + // update package name if there is a package.json + if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) { + await overridePackageJson( + (pkg) => ({ + ...pkg, + name: ctx.name, + }), + { dir: ctx.dir }, + ); + $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); + } if (template.installDeps !== false) { const install = diff --git a/app/src/cli/commands/create/npm.ts b/app/src/cli/commands/create/npm.ts index 7722e1c9..964ee47f 100644 --- a/app/src/cli/commands/create/npm.ts +++ b/app/src/cli/commands/create/npm.ts @@ -93,17 +93,19 @@ export async function replacePackageJsonVersions( } export async function updateBkndPackages(dir?: string, map?: Record) { - const versions = { - bknd: await sysGetVersion(), - ...(map ?? {}), - }; - await replacePackageJsonVersions( - async (pkg) => { - if (pkg in versions) { - return versions[pkg]; - } - return; - }, - { dir }, - ); + try { + const versions = { + bknd: await sysGetVersion(), + ...(map ?? {}), + }; + await replacePackageJsonVersions( + async (pkg) => { + if (pkg in versions) { + return versions[pkg]; + } + return; + }, + { dir }, + ); + } catch (e) {} } diff --git a/examples/deno/deno.json b/examples/deno/deno.json index 0f8edde3..6e8656b2 100644 --- a/examples/deno/deno.json +++ b/examples/deno/deno.json @@ -1,5 +1,8 @@ { "nodeModulesDir": "auto", + "tasks": { + "dev": "deno serve -A --watch main.ts" + }, "imports": { "bknd": "npm:bknd@0.19.0-rc.1" }, From ebad3d15eca80d1e0b352fb1d70af86e09d607da Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 25 Oct 2025 10:34:24 +0200 Subject: [PATCH 34/48] Enhance Deno integration documentation with installation instructions and versioning options --- .../integration/(runtimes)/deno.mdx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx b/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx index a26ada23..dbebcaa6 100644 --- a/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx +++ b/docs/content/docs/(documentation)/integration/(runtimes)/deno.mdx @@ -4,6 +4,20 @@ description: "Run bknd inside Deno" tags: ["documentation"] --- +## Installation + +To get started with Deno and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter: + +### CLI Starter + +Create a new Deno CLI starter project by running the following command: + +```sh +deno run npm:bknd create -i deno +``` + +### Manual + Deno is fully supported as a runtime for bknd. If you plan to solely use the API, the setup is pretty straightforward. ```ts title="main.ts" @@ -24,7 +38,7 @@ export default { In order to also serve the static assets of the admin UI, you have 3 choices: -1. Use the `serveStaticViaImport` function to serve the static assets from the `bknd` package directly (requires unstable `raw-imports`). +1. Use the `serveStaticViaImport` function to serve the static assets from the `bknd` package directly. Requires unstable `raw-imports`, but it's the easiest way to serve the static assets. 2. Copy the static assets to your local project and use Hono's `serveStatic` middleware. 3. Use the `adminOptions.assetsPath` property to point to a remote address with the static assets. @@ -55,6 +69,28 @@ export default { }; ``` +In case you don't want to point your bknd dependency to the latest version, either add an `imports` section to your `deno.json` file: + +```json title="deno.json" +{ + "imports": { + "bknd": "npm:bknd@" // [!code highlight] + } +} +``` + +Or specify the package with the version specified to the `serveStaticViaImport` function: + +```ts +const app = await createRuntimeApp({ + serveStatic: serveStaticViaImport({ + package: "bknd@", // [!code highlight] + }), +}); +``` + +Replace `` with the version you want to use. + ### `serveStatic` from local files You can also serve the static assets from your local project by using Hono's `serveStatic` middleware. You can do so by copying the static assets to your local project and using the `serveStatic` middleware. First, you have to copy the static assets, by running the following command: From 0dbf71e6b520ec3a8c1d8767973fa6c48f64f97e Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 26 Oct 2025 16:00:15 +0100 Subject: [PATCH 35/48] fix pagination on entity relations for softscan false --- app/src/ui/routes/data/data.$entity.$id.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index a21d444a..bb238bf5 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -301,7 +301,11 @@ function EntityDetailInner({ // @todo: add custom key for invalidation const $q = useApiQuery( - (api) => api.data.readManyByReference(entity.name, id, other.reference, search), + (api) => + api.data.readManyByReference(entity.name, id, other.reference, { + ...search, + limit: search.limit + 1 /* overfetch for softscan=false */, + }), { keepPreviousData: true, revalidateOnFocus: true, @@ -320,7 +324,6 @@ function EntityDetailInner({ navigate(routes.data.entity.create(other.entity.name), { query: ref.where, }); - //navigate(routes.data.entity.create(other.entity.name) + `?${query}`); }; } } catch (e) {} @@ -330,6 +333,7 @@ function EntityDetailInner({ } const isUpdating = $q.isValidating || $q.isLoading; + const meta = $q.data?.body.meta; return (
{ setSearch((s) => ({ ...s, From 574b37abcdf7375df38e558c05bd0f4fcdab1446 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 26 Oct 2025 21:04:37 +0100 Subject: [PATCH 36/48] fix: update key handling in StorageR2Adapter to conditionally prepend keyPrefix - Modified getKey method to prepend keyPrefix only if it is not empty, ensuring correct key formatting. --- app/src/adapter/cloudflare/storage/StorageR2Adapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index 7f1250a6..756e562d 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -177,7 +177,10 @@ export class StorageR2Adapter extends StorageAdapter { } protected getKey(key: string) { - return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/"); + if (this.keyPrefix.length > 0) { + return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/"); + } + return key; } toJSON(secrets?: boolean) { From 0b58cadbd0b1335c70b7ce954934f80dd78eaa63 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 26 Oct 2025 21:05:11 +0100 Subject: [PATCH 37/48] feat: implement mergeFilters function and enhance query object merging - Added mergeFilters function to combine filter objects with priority handling. - Introduced comprehensive tests for mergeFilters in permissions.spec.ts. - Created query.spec.ts to validate query structure and expression handling. - Enhanced error messages in query.ts for better debugging and clarity. --- .../auth/authorize/permissions.spec.ts | 43 ++++++- app/src/auth/authorize/Guard.ts | 22 +++- app/src/core/object/query/query.spec.ts | 111 ++++++++++++++++++ app/src/core/object/query/query.ts | 27 ++++- 4 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 app/src/core/object/query/query.spec.ts diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index 14a01eee..411ad20a 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -5,9 +5,10 @@ import { Policy } from "auth/authorize/Policy"; import { Hono } from "hono"; import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware"; import { auth } from "auth/middlewares/auth.middleware"; -import { Guard, type GuardConfig } from "auth/authorize/Guard"; +import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard"; import { Role, RolePermission } from "auth/authorize/Role"; import { Exception } from "bknd"; +import { convert } from "core/object/query/object-query"; describe("Permission", () => { it("works with minimal schema", () => { @@ -177,6 +178,46 @@ describe("Guard", () => { // hence it can be found expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" }); }); + + it("merges filters correctly", () => { + expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({ + foo: { $eq: "bar" }, + baz: { $eq: "qux" }, + }); + expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({ + foo: { $eq: "bar" }, + baz: { $eq: "qux" }, + }); + expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } }); + + expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({ + foo: { $eq: "bar", $lt: 1 }, + }); + + // overwrite base $or with priority + expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({ + $or: { + foo: { + $eq: "bar", + }, + }, + foo: { + $eq: "bar", + }, + }); + + // ignore base $or if priority has different key + expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({ + $or: { + other: { + $eq: "one", + }, + }, + foo: { + $eq: "bar", + }, + }); + }); }); describe("permission middleware", () => { diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 6336a597..a8f91e3f 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -6,6 +6,7 @@ import type { ServerEnv } from "modules/Controller"; import type { Role } from "./Role"; import { HttpStatus } from "bknd/utils"; import type { Policy, PolicySchema } from "./Policy"; +import { convert, type ObjectQuery } from "core/object/query/object-query"; export type GuardUserContext = { role?: string | null; @@ -294,7 +295,7 @@ export class Guard { filter, policies, merge: (givenFilter: object | undefined) => { - return mergeObject(givenFilter ?? {}, filter ?? {}); + return mergeFilters(givenFilter ?? {}, filter ?? {}); }, matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => { const subjects = Array.isArray(subject) ? subject : [subject]; @@ -319,3 +320,22 @@ export class Guard { }; } } + +export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) { + const base_converted = convert(base); + const priority_converted = convert(priority); + const merged = mergeObject(base_converted, priority_converted); + + // in case priority filter is also contained in base's $and, merge priority in + if ("$or" in base_converted && base_converted.$or) { + const $ors = base_converted.$or as ObjectQuery; + const priority_keys = Object.keys(priority_converted); + for (const key of priority_keys) { + if (key in $ors) { + merged.$or[key] = mergeObject($ors[key], priority_converted[key]); + } + } + } + + return merged; +} diff --git a/app/src/core/object/query/query.spec.ts b/app/src/core/object/query/query.spec.ts new file mode 100644 index 00000000..27e247c9 --- /dev/null +++ b/app/src/core/object/query/query.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { makeValidator, exp, Expression, isPrimitive, type Primitive } from "./query"; + +describe("query", () => { + test("isPrimitive", () => { + expect(isPrimitive(1)).toBe(true); + expect(isPrimitive("1")).toBe(true); + expect(isPrimitive(true)).toBe(true); + expect(isPrimitive(false)).toBe(true); + + // not primitives + expect(isPrimitive(null)).toBe(false); + expect(isPrimitive(undefined)).toBe(false); + expect(isPrimitive([])).toBe(false); + expect(isPrimitive({})).toBe(false); + expect(isPrimitive(Symbol("test"))).toBe(false); + expect(isPrimitive(new Date())).toBe(false); + expect(isPrimitive(new Error())).toBe(false); + expect(isPrimitive(new Set())).toBe(false); + expect(isPrimitive(new Map())).toBe(false); + }); + + test("strict expression creation", () => { + // @ts-expect-error + expect(() => exp()).toThrow(); + // @ts-expect-error + expect(() => exp("")).toThrow(); + // @ts-expect-error + expect(() => exp("invalid")).toThrow(); + // @ts-expect-error + expect(() => exp("$eq")).toThrow(); + // @ts-expect-error + expect(() => exp("$eq", 1)).toThrow(); + // @ts-expect-error + expect(() => exp("$eq", () => null)).toThrow(); + // @ts-expect-error + expect(() => exp("$eq", () => null, 1)).toThrow(); + expect( + exp( + "$eq", + () => true, + () => null, + ), + ).toBeInstanceOf(Expression); + }); + + test("$eq is required", () => { + expect(() => makeValidator([])).toThrow(); + expect(() => + makeValidator([ + exp( + "$valid", + () => true, + () => null, + ), + ]), + ).toThrow(); + expect( + makeValidator([ + exp( + "$eq", + () => true, + () => null, + ), + ]), + ).toBeDefined(); + }); + + test("validates filter structure", () => { + const validator = makeValidator([ + exp( + "$eq", + (v: Primitive) => isPrimitive(v), + (e, a) => e === a, + ), + exp( + "$like", + (v: string) => typeof v === "string", + (e, a) => e === a, + ), + ]); + + // @ts-expect-error intentionally typed as union of given expression keys + expect(validator.expressionKeys).toEqual(["$eq", "$like"]); + + // @ts-expect-error "$and" is not allowed + expect(() => validator.convert({ $and: {} })).toThrow(); + + // @ts-expect-error "$or" must be an object + expect(() => validator.convert({ $or: [] })).toThrow(); + + // @ts-expect-error "invalid" is not a valid expression key + expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow(); + + // @ts-expect-error "invalid" is not a valid expression key + expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow(); + + // @ts-expect-error "null" is not a valid value + expect(() => validator.convert({ foo: null })).toThrow(); + + // @ts-expect-error only primitives are allowed for $eq + expect(() => validator.convert({ foo: { $eq: [] } })).toThrow(); + + // @ts-expect-error only strings are allowed for $like + expect(() => validator.convert({ foo: { $like: 1 } })).toThrow(); + + expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } }); + expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } }); + expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } }); + }); +}); diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index 24352c40..58a50cc8 100644 --- a/app/src/core/object/query/query.ts +++ b/app/src/core/object/query/query.ts @@ -1,5 +1,5 @@ import type { PrimaryFieldType } from "core/config"; -import { getPath, invariant } from "bknd/utils"; +import { getPath, invariant, isPlainObject } from "bknd/utils"; export type Primitive = PrimaryFieldType | string | number | boolean; export function isPrimitive(value: any): value is Primitive { @@ -26,6 +26,10 @@ export function exp( valid: (v: Expect) => boolean, validate: (e: Expect, a: unknown, ctx: CTX) => any, ): Expression { + invariant(typeof key === "string", "key must be a string"); + invariant(key[0] === "$", "key must start with '$'"); + invariant(typeof valid === "function", "valid must be a function"); + invariant(typeof validate === "function", "validate must be a function"); return new Expression(key, valid, validate); } @@ -85,13 +89,16 @@ function _convert( function validate(key: string, value: any, path: string[] = []) { const exp = getExpression(expressions, key as any); if (exp.valid(value) === false) { - throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`); + throw new Error( + `Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`, + ); } } for (const [key, value] of Object.entries($query)) { // if $or, convert each value if (key === "$or") { + invariant(isPlainObject(value), "$or must be an object"); newQuery.$or = _convert(value, expressions, [...path, key]); // if primitive, assume $eq @@ -100,7 +107,7 @@ function _convert( newQuery[key] = { $eq: value }; // if object, check for expressions - } else if (typeof value === "object") { + } else if (isPlainObject(value)) { // when object is given, check if all keys are expressions const invalid = Object.keys(value).filter( (f) => !ExpressionConditionKeys.includes(f as any), @@ -114,9 +121,13 @@ function _convert( } } else { throw new Error( - `Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`, + `Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expression key: ${ExpressionConditionKeys.join(", ")}.`, ); } + } else { + throw new Error( + `Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`, + ); } } @@ -151,7 +162,9 @@ function _build( throw new Error(`Expression does not exist: "${$op}"`); } if (!exp.valid(expected)) { - throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`); + throw new Error( + `Invalid value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`, + ); } return exp.validate(expected, actual, options.exp_ctx); } @@ -191,6 +204,10 @@ function _validate(results: ValidationResults): boolean { } export function makeValidator(expressions: Exps) { + if (!expressions.some((e) => e.key === "$eq")) { + throw new Error("'$eq' expression is required"); + } + return { convert: (query: FilterQuery) => _convert(query, expressions), build: (query: FilterQuery, options: BuildOptions) => From 28390b0b8409d16b7ae5a989605f63be4c9212b1 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 26 Oct 2025 21:08:11 +0100 Subject: [PATCH 38/48] refactor: move query.spec.ts --- .../object/query => __test__/core/object}/query.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) rename app/{src/core/object/query => __test__/core/object}/query.spec.ts (96%) diff --git a/app/src/core/object/query/query.spec.ts b/app/__test__/core/object/query.spec.ts similarity index 96% rename from app/src/core/object/query/query.spec.ts rename to app/__test__/core/object/query.spec.ts index 27e247c9..fac73e26 100644 --- a/app/src/core/object/query/query.spec.ts +++ b/app/__test__/core/object/query.spec.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { makeValidator, exp, Expression, isPrimitive, type Primitive } from "./query"; +import { + makeValidator, + exp, + Expression, + isPrimitive, + type Primitive, +} from "../../../src/core/object/query/query"; describe("query", () => { test("isPrimitive", () => { From 2847e64b77e492d9d3276d1cf74c2378e55633a4 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 26 Oct 2025 21:22:42 +0100 Subject: [PATCH 39/48] feat: enhance query handling by ignoring undefined values - Updated query conversion logic to skip undefined values, improving robustness. - Added tests to validate that undefined values are correctly ignored in query specifications. --- app/__test__/core/object/query.spec.ts | 3 +++ app/src/core/object/query/query.ts | 9 ++++++- app/src/data/server/query.spec.ts | 33 ++++++++++++++++++++++++++ app/src/data/server/query.ts | 16 +------------ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/app/__test__/core/object/query.spec.ts b/app/__test__/core/object/query.spec.ts index fac73e26..80383c67 100644 --- a/app/__test__/core/object/query.spec.ts +++ b/app/__test__/core/object/query.spec.ts @@ -110,6 +110,9 @@ describe("query", () => { // @ts-expect-error only strings are allowed for $like expect(() => validator.convert({ foo: { $like: 1 } })).toThrow(); + // undefined values are ignored + expect(validator.convert({ foo: undefined })).toEqual({}); + expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } }); expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } }); expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } }); diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index 58a50cc8..110cce6c 100644 --- a/app/src/core/object/query/query.ts +++ b/app/src/core/object/query/query.ts @@ -55,7 +55,7 @@ function getExpression( } type LiteralExpressionCondition = { - [key: string]: Primitive | ExpressionCondition; + [key: string]: undefined | Primitive | ExpressionCondition; }; const OperandOr = "$or" as const; @@ -96,6 +96,11 @@ function _convert( } for (const [key, value] of Object.entries($query)) { + // skip undefined values + if (value === undefined) { + continue; + } + // if $or, convert each value if (key === "$or") { invariant(isPlainObject(value), "$or must be an object"); @@ -171,6 +176,8 @@ function _build( // check $and for (const [key, value] of Object.entries($and)) { + if (value === undefined) continue; + for (const [$op, $v] of Object.entries(value)) { const objValue = options.value_is_kv ? key : getPath(options.object, key); result.$and.push(__validate($op, $v, objValue, [key])); diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index 89585a34..eb2eb2b1 100644 --- a/app/src/data/server/query.spec.ts +++ b/app/src/data/server/query.spec.ts @@ -1,6 +1,8 @@ import { test, describe, expect } 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"; const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, { @@ -186,4 +188,35 @@ describe("server/query", () => { decode({ with: { images: {}, comments: {} } }, output); } }); + + test("types", () => { + const id = 1 as PrimaryFieldType; + const id2 = "1" as unknown as Generated; + + const c: q.RepoQueryIn = { + where: { + // @ts-expect-error only primitives are allowed for $eq + something: [], + // this gets ignored + another: undefined, + // @ts-expect-error null is not a valid value + null_is_okay: null, + some_id: id, + another_id: id2, + }, + }; + + const d: q.RepoQuery = { + where: { + // @ts-expect-error only primitives are allowed for $eq + something: [], + // this gets ignored + another: undefined, + // @ts-expect-error null is not a valid value + null_is_okay: null, + some_id: id, + another_id: id2, + }, + }; + }); }); diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index cb4defeb..9a01e2a8 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -84,8 +84,6 @@ const where = s.anyOf([s.string(), s.object({})], { return WhereBuilder.convert(q); }, }); -//type WhereSchemaIn = s.Static; -//type WhereSchema = s.StaticCoerced; // ------ // with @@ -128,7 +126,7 @@ const withSchema = (self: s.Schema): s.Schema<{}, Type, Type> => } } - return value as unknown as any; + return value as any; }, }) as any; @@ -167,15 +165,3 @@ export type RepoQueryIn = { export type RepoQuery = s.StaticCoerced & { sort: SortSchema; }; - -//export type RepoQuery = s.StaticCoerced; -// @todo: CURRENT WORKAROUND -/* export type RepoQuery = { - limit?: number; - offset?: number; - sort?: { by: string; dir: "asc" | "desc" }; - select?: string[]; - with?: Record; - join?: string[]; - where?: WhereQuery; -}; */ From ef41b71921563defe85480495d752b36fc728721 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 09:18:16 +0100 Subject: [PATCH 40/48] fix: add modes export, fix event firing with modes and cloudflare --- app/build.ts | 7 ++++++- app/package.json | 5 +++++ app/src/App.ts | 1 + .../adapter/cloudflare/cloudflare-workers.adapter.ts | 10 +++++----- app/src/adapter/cloudflare/config.ts | 1 + app/src/core/events/EventManager.ts | 12 +++++++++++- app/src/data/api/DataController.ts | 1 - app/src/index.ts | 1 + app/src/modes/hybrid.ts | 1 + app/src/modes/shared.ts | 4 ++-- 10 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/build.ts b/app/build.ts index b8729bdd..4a30da91 100644 --- a/app/build.ts +++ b/app/build.ts @@ -85,7 +85,12 @@ async function buildApi() { sourcemap, watch, define, - entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"], + entry: [ + "src/index.ts", + "src/core/utils/index.ts", + "src/plugins/index.ts", + "src/modes/index.ts", + ], outDir: "dist", external: [...external], metafile: true, diff --git a/app/package.json b/app/package.json index e7b522b0..7d2f2fc8 100644 --- a/app/package.json +++ b/app/package.json @@ -180,6 +180,11 @@ "import": "./dist/plugins/index.js", "require": "./dist/plugins/index.js" }, + "./modes": { + "types": "./dist/types/modes/index.d.ts", + "import": "./dist/modes/index.js", + "require": "./dist/modes/index.js" + }, "./adapter/sqlite": { "types": "./dist/types/adapter/sqlite/edge.d.ts", "import": { diff --git a/app/src/App.ts b/app/src/App.ts index ed6d765f..5658d901 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -295,6 +295,7 @@ export class App< return this.module.auth.createUser(p); } + // @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners getApi(options?: LocalApiOptions) { const fetcher = this.server.request as typeof fetch; if (options && options instanceof Request) { diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index fe278c4a..e263756b 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -37,19 +37,19 @@ export async function createApp( config: CloudflareBkndConfig = {}, ctx: Partial> = {}, ) { - const appConfig = await makeConfig( + const appConfig = await makeConfig(config, ctx); + return await createRuntimeApp( { - ...config, + ...appConfig, onBuilt: async (app) => { if (ctx.ctx) { registerAsyncsExecutionContext(app, ctx?.ctx); } - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); }, }, - ctx, + ctx?.env, ); - return await createRuntimeApp(appConfig, ctx?.env); } // compatiblity diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 86a7722e..58037574 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -158,6 +158,7 @@ export async function makeConfig( sessionHelper.set(c, session); await next(); }); + appConfig.options?.manager?.onServerInit?.(server); }, }, }; diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 8370f2bd..1ac8bc41 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -205,7 +205,17 @@ export class EventManager< if (listener.mode === "sync") { syncs.push(listener); } else { - asyncs.push(async () => await listener.handler(event, listener.event.slug)); + asyncs.push(async () => { + try { + await listener.handler(event, listener.event.slug); + } catch (e) { + if (this.options?.onError) { + this.options.onError(event, e); + } else { + $console.error("Error executing async listener", listener, e); + } + } + }); } // Remove if `once` is true, otherwise keep return !listener.once; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 315a58d5..082ae0c9 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -9,7 +9,6 @@ import { pickKeys, mcpTool, convertNumberedObjectToArray, - mergeObject, } from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; diff --git a/app/src/index.ts b/app/src/index.ts index 22c2368d..e30af8a9 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -50,6 +50,7 @@ export { getFlashMessage } from "core/server/flash"; export * from "core/drivers"; export { Event, InvalidEventReturn } from "core/events/Event"; export type { + EventListener, ListenerMode, ListenerHandler, } from "core/events/EventListener"; diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts index b5452706..7a8022b2 100644 --- a/app/src/modes/hybrid.ts +++ b/app/src/modes/hybrid.ts @@ -68,6 +68,7 @@ export function hybrid({ const mm = app.modules as DbModuleManager; mm.buildSyncConfig = syncSchemaOptions; } + await appConfig.beforeBuild?.(app); }, config: fileConfig, options: { diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts index afc2c6aa..706a4118 100644 --- a/app/src/modes/shared.ts +++ b/app/src/modes/shared.ts @@ -59,8 +59,8 @@ export type BkndModeConfig = BkndConfig< export async function makeModeConfig< Args = any, Config extends BkndModeConfig = BkndModeConfig, ->(_config: Config, args: Args) { - const appConfig = typeof _config.app === "function" ? await _config.app(args) : _config.app; +>({ app, ..._config }: Config, args: Args) { + const appConfig = typeof app === "function" ? await app(args) : app; const config = { ..._config, From 42f340b189f04e61eed7c2dc40faae093a53703c Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 10:42:06 +0100 Subject: [PATCH 41/48] fix: update syncSecretsOptions condition and enhance documentation for mode configuration - Changed the condition for syncSecretsOptions to check for both existence and false value. - Added details about explicit fetch export and integration specifics in the introduction documentation. - Clarified behavior of UI-only and code-only modes, including configuration application and schema syncing. --- app/src/modes/shared.ts | 2 +- .../(documentation)/usage/introduction.mdx | 87 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts index 706a4118..f1bc4ff7 100644 --- a/app/src/modes/shared.ts +++ b/app/src/modes/shared.ts @@ -129,7 +129,7 @@ export async function makeModeConfig< ); } - if (syncSecretsOptions?.enabled) { + if (syncSecretsOptions && syncSecretsOptions.enabled !== false) { if (plugins.some((p) => p.name === "bknd-sync-secrets")) { throw new Error("You have to unregister the `syncSecrets` plugin"); } diff --git a/docs/content/docs/(documentation)/usage/introduction.mdx b/docs/content/docs/(documentation)/usage/introduction.mdx index ebac844d..adf68109 100644 --- a/docs/content/docs/(documentation)/usage/introduction.mdx +++ b/docs/content/docs/(documentation)/usage/introduction.mdx @@ -41,15 +41,23 @@ await app.build(); export default app; ``` -In Web API compliant environments, all you have to do is to default exporting the app, as it -implements the `Fetch` API. +In Web API compliant environments, all you have to do is to default exporting the app, as it implements the `Fetch` API. In case an explicit `fetch` export is needed, you can use the `app.fetch` property. + +```typescript +const app = /* ... */; +export default { + fetch: app.fetch, +} +``` + +Check the integration details for your specific runtime or framework in the [integration](/integration/introduction) section. ## Modes Main project goal is to provide a backend that can be configured visually with the built-in Admin UI. However, you may instead want to configure your backend programmatically, and define your data structure with a Drizzle-like API: - }> + }> This is the default mode, it allows visual configuration and saves the configuration to the database. Expects you to deploy your backend separately from your frontend. }> @@ -117,6 +125,11 @@ export default { } satisfies BkndConfig; ``` + + Note that when using the default UI-mode, the initial configuration using the `config` property will only be applied if the database is empty. + + + ### Code-only mode This mode allows you to configure your backend programmatically, and define your data structure with a Drizzle-like API. Visual configuration controls are disabled. @@ -150,6 +163,8 @@ export default { } satisfies BkndConfig; ``` +Unlike the UI-only mode, the configuration passed to `config` is always applied. In case you make data structure changes, you may need to sync the schema to the database manually, e.g. using the [sync command](/usage/cli#syncing-the-database-sync). + ### Hybrid mode This mode allows you to configure your backend visually while in development, and uses the produced configuration in a code-only mode for maximum performance. It gives you the best of both worlds. @@ -183,5 +198,71 @@ To keep your config, secrets and types in sync, you can either use the CLI or th | Types | [`syncTypes`](/extending/plugins/#synctypes) | [`types`](/usage/cli/#generating-types-types) | +## Mode helpers +To make the setup using your preferred mode easier, there are mode helpers for [`code`](/usage/introduction#code-only-mode) and [`hybrid`](/usage/introduction#hybrid-mode) modes. +* built-in syncing of config, types and secrets +* let bknd automatically sync the data schema in development +* automatically switch modes in hybrid (from db to code) in production +* automatically skip config validation in production to boost performance + +To use it, you have to wrap your configuration in a mode helper, e.g. for `code` mode using the Bun adapter: + +```typescript title="bknd.config.ts" +import { code, type CodeMode } from "bknd/modes"; +import { type BunBkndConfig, writer } from "bknd/adapter/bun"; + +const config = { + // some normal bun bknd config + connection: { url: "file:test.db" }, + // ... + // a writer is required, to sync the types + writer, + // (optional) mode specific config + isProduction: Bun.env.NODE_ENV === "production", + typesFilePath: "bknd-types.d.ts", + // (optional) e.g. have the schema synced if !isProduction + syncSchema: { + force: true, + drop: true, + } +} satisfies CodeMode; + +export default code(config); +``` + +Similarily, for `hybrid` mode: + +```typescript title="bknd.config.ts" +import { hybrid, type HybridMode } from "bknd/modes"; +import { type BunBkndConfig, writer, reader } from "bknd/adapter/bun"; + +const config = { + // some normal bun bknd config + connection: { url: "file:test.db" }, + // ... + // reader/writer are required, to sync the types and config + writer, + reader, + // supply secrets + secrets: await Bun.file(".env.local").json(), + // (optional) mode specific config + isProduction: Bun.env.NODE_ENV === "production", + typesFilePath: "bknd-types.d.ts", + configFilePath: "bknd-config.json", + // (optional) and have them automatically written if !isProduction + syncSecrets: { + outFile: ".env.local", + format: "env", + includeSecrets: true, + }, + // (optional) also have the schema synced if !isProduction + syncSchema: { + force: true, + drop: true, + }, +} satisfies HybridMode; + +export default hybrid(config); +``` \ No newline at end of file From 0a50f9850cb3b1764c5028e4271b0693eb26c99e Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 10:44:22 +0100 Subject: [PATCH 42/48] docs: add timestamps plugin description --- .../(documentation)/extending/plugins.mdx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx index 1ab0fa1a..850629de 100644 --- a/docs/content/docs/(documentation)/extending/plugins.mdx +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -239,7 +239,25 @@ Now you can add query parameters for the transformations, e.g. `?width=1000&heig }} /> - - +### `timestamps` + +A plugin that adds `created_at` and `updated_at` fields to the specified entities. + +```typescript title="bknd.config.ts" +import { timestamps } from "bknd/plugins"; + +export default { + options: { + plugins: [ + timestamps({ + // the entities to add timestamps to + entities: ["pages"], + // whether to set the `updated_at` field on create, defaults to true + setUpdatedOnCreate: true, + }) + ], + }, +} satisfies BkndConfig; +``` From 422f7893b50f3dbdb562324913c6e2cf860301d9 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 11:09:37 +0100 Subject: [PATCH 43/48] docs: updated mcp tools --- app/internal/docs.build-assets.ts | 9 +- app/src/App.ts | 3 +- app/src/modules/server/SystemController.ts | 3 +- .../usage/mcp/tools-resources.mdx | 36 +++-- docs/mcp.json | 152 +++++++++++++++++- 5 files changed, 175 insertions(+), 28 deletions(-) diff --git a/app/internal/docs.build-assets.ts b/app/internal/docs.build-assets.ts index 4a4db736..2bedbee0 100644 --- a/app/internal/docs.build-assets.ts +++ b/app/internal/docs.build-assets.ts @@ -3,11 +3,14 @@ import { createApp } from "bknd/adapter/bun"; async function generate() { console.info("Generating MCP documentation..."); const app = await createApp({ + connection: { + url: ":memory:", + }, config: { server: { mcp: { enabled: true, - path: "/mcp", + path: "/mcp2", }, }, auth: { @@ -25,9 +28,9 @@ async function generate() { }, }); await app.build(); + await app.getMcpClient().ping(); - const res = await app.server.request("/mcp?explain=1"); - const { tools, resources } = await res.json(); + const { tools, resources } = app.mcp!.toJSON(); await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2)); console.info("MCP documentation generated."); diff --git a/app/src/App.ts b/app/src/App.ts index 5658d901..633b9fa5 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -311,8 +311,9 @@ export class App< throw new Error("MCP is not enabled"); } + const url = new URL(config.path, "http://localhost").toString(); return new McpClient({ - url: "http://localhost" + config.path, + url, fetch: this.server.request, }); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 9cdbd99e..3ae6cd27 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -70,12 +70,13 @@ export class SystemController extends Controller { if (!config.mcp.enabled) { return; } - const { permission } = this.middlewares; + const { permission, auth } = this.middlewares; this.registerMcp(); app.server.all( config.mcp.path, + auth(), permission(SystemPermissions.mcp, {}), mcpMiddleware({ setup: async () => { diff --git a/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx b/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx index 78e1b45d..40a7e970 100644 --- a/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx +++ b/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx @@ -24,7 +24,7 @@ Get the available authentication strategies Create a new user - + ### `auth_user_password_change` @@ -48,61 +48,61 @@ Get a user token Delete many - + ### `data_entity_delete_one` Delete one - + ### `data_entity_fn_count` Count entities - + ### `data_entity_fn_exists` Check if entity exists - + ### `data_entity_info` Retrieve entity info - + ### `data_entity_insert` Insert one or many - + ### `data_entity_read_many` Query entities - + ### `data_entity_read_one` Read one - + ### `data_entity_update_many` Update many - + ### `data_entity_update_one` Update one - + ### `data_sync` @@ -110,6 +110,12 @@ Sync database schema +### `data_types` + +Retrieve data typescript definitions + + + ### `system_build` Build the app @@ -144,7 +150,7 @@ Ping the server - + ### `config_auth_roles_get` @@ -162,7 +168,7 @@ Ping the server - + ### `config_auth_strategies_add` @@ -192,7 +198,7 @@ Ping the server - + ### `config_data_entities_add` @@ -306,7 +312,7 @@ Get Server configuration Update Server configuration - + ## Resources diff --git a/docs/mcp.json b/docs/mcp.json index 7075b1e6..afd9b12f 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -612,6 +612,13 @@ "destructiveHint": true } }, + { + "name": "data_types", + "description": "Retrieve data typescript definitions", + "inputSchema": { + "type": "object" + } + }, { "name": "system_build", "description": "Build the app", @@ -714,10 +721,66 @@ "additionalProperties": false, "properties": { "permissions": { - "type": "array", - "items": { - "type": "string" - } + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "permission": { + "type": "string" + }, + "effect": { + "type": "string", + "enum": [ + "allow", + "deny" + ], + "default": "allow" + }, + "policies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "condition": { + "type": "object", + "properties": {} + }, + "effect": { + "type": "string", + "enum": [ + "allow", + "deny", + "filter" + ], + "default": "allow" + }, + "filter": { + "type": "object", + "properties": {} + } + } + } + } + }, + "required": [ + "permission" + ] + } + } + ] }, "is_default": { "type": "boolean" @@ -810,10 +873,66 @@ "additionalProperties": false, "properties": { "permissions": { - "type": "array", - "items": { - "type": "string" - } + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string" + }, + "effect": { + "type": "string", + "enum": [ + "allow", + "deny" + ], + "default": "allow" + }, + "policies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "condition": { + "type": "object", + "properties": {} + }, + "effect": { + "type": "string", + "enum": [ + "allow", + "deny", + "filter" + ], + "default": "allow" + }, + "filter": { + "type": "object", + "properties": {} + } + } + } + } + } + } + } + ] }, "is_default": { "type": "boolean" @@ -1062,6 +1181,9 @@ "type": "object", "additionalProperties": false, "properties": { + "domain": { + "type": "string" + }, "path": { "type": "string", "default": "/" @@ -4067,6 +4189,20 @@ "path": { "type": "string", "default": "/api/system/mcp" + }, + "logLevel": { + "type": "string", + "enum": [ + "emergency", + "alert", + "critical", + "error", + "warning", + "notice", + "info", + "debug" + ], + "default": "warning" } } } From e055e477ae646a97f890d7e0cb27c6b0ac66b582 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 13:14:23 +0100 Subject: [PATCH 44/48] chore: bump version to 0.19.0-rc.2 and remove unused server initialization callback in Cloudflare config --- app/package.json | 2 +- app/src/adapter/cloudflare/config.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/package.json b/app/package.json index 7d2f2fc8..37746bc6 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.19.0-rc.1", + "version": "0.19.0-rc.2", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 58037574..86a7722e 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -158,7 +158,6 @@ export async function makeConfig( sessionHelper.set(c, session); await next(); }); - appConfig.options?.manager?.onServerInit?.(server); }, }, }; From b57f362e3a2296b03c5f5892d17bf66dd1a7ad48 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 16:00:58 +0100 Subject: [PATCH 45/48] fix json field --- app/src/data/fields/JsonField.ts | 11 ++++-- app/src/ui/components/code/JsonEditor.tsx | 37 ++++++++++++++----- .../ui/modules/data/components/EntityForm.tsx | 7 +++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index c54854b9..17ee6b56 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -64,17 +64,20 @@ export class JsonField import("./CodeEditor")); export type JsonEditorProps = Omit & { value?: object; onChange?: (value: object) => void; - emptyAs?: "null" | "undefined"; + emptyAs?: any; + onInvalid?: (error: Error) => void; }; export function JsonEditor({ @@ -16,29 +17,45 @@ export function JsonEditor({ value, onChange, onBlur, - emptyAs = "undefined", + emptyAs = undefined, + onInvalid, ...props }: JsonEditorProps) { const [editorValue, setEditorValue] = useState( - JSON.stringify(value, null, 2), + value ? JSON.stringify(value, null, 2) : emptyAs, ); + const [error, setError] = useState(false); const handleChange = useDebouncedCallback((given: string) => { - const value = given === "" ? (emptyAs === "null" ? null : undefined) : given; try { - setEditorValue(value); - onChange?.(value ? JSON.parse(value) : value); - } catch (e) {} - }, 500); + setError(false); + onChange?.(given ? JSON.parse(given) : emptyAs); + } catch (e) { + onInvalid?.(e as Error); + setError(true); + } + }, 250); const handleBlur = (e) => { - setEditorValue(JSON.stringify(value, null, 2)); + try { + const formatted = JSON.stringify(value, null, 2); + setEditorValue(formatted); + } catch (e) {} + onBlur?.(e); }; + + useEffect(() => { + if (!editorValue) { + setEditorValue(value ? JSON.stringify(value, null, 2) : emptyAs); + } + }, [value]); + return ( (null); const handleUpdate = useEvent((value: any) => { + setError(null); fieldApi.handleChange(value); }); return ( - + From be39e8a3913060b7f5557c7de5b7ae35410c7e5e Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 28 Oct 2025 16:07:43 +0100 Subject: [PATCH 46/48] chore: bump version to 0.19.0-rc.3 and update JsonField handling for improved value parsing --- app/__test__/data/specs/fields/JsonField.spec.ts | 9 ++++----- app/package.json | 2 +- app/src/data/fields/JsonField.ts | 6 +++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/__test__/data/specs/fields/JsonField.spec.ts b/app/__test__/data/specs/fields/JsonField.spec.ts index ff94dc3b..d834f678 100644 --- a/app/__test__/data/specs/fields/JsonField.spec.ts +++ b/app/__test__/data/specs/fields/JsonField.spec.ts @@ -7,7 +7,7 @@ describe("[data] JsonField", async () => { const field = new JsonField("test"); fieldTestSuite(bunTestRunner, JsonField, { defaultValue: { a: 1 }, - sampleValues: ["string", { test: 1 }, 1], + //sampleValues: ["string", { test: 1 }, 1], schemaType: "text", }); @@ -33,9 +33,9 @@ describe("[data] JsonField", async () => { }); test("getValue", async () => { - expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}'); - expect(field.getValue("string", "form")).toBe('"string"'); - expect(field.getValue(1, "form")).toBe("1"); + expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 }); + expect(field.getValue("string", "form")).toBe("string"); + expect(field.getValue(1, "form")).toBe(1); expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 }); expect(field.getValue('"string"', "submit")).toBe("string"); @@ -43,6 +43,5 @@ describe("[data] JsonField", async () => { expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}'); expect(field.getValue("string", "table")).toBe('"string"'); - expect(field.getValue(1, "form")).toBe("1"); }); }); diff --git a/app/package.json b/app/package.json index 37746bc6..ee5b028e 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.19.0-rc.2", + "version": "0.19.0-rc.3", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index 17ee6b56..8ed4802d 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -80,7 +80,11 @@ export class JsonField Date: Fri, 31 Oct 2025 09:24:45 +0100 Subject: [PATCH 47/48] fix CodePreview shiki dynamic load for frameworks like Next.js --- app/src/ui/components/code/CodePreview.tsx | 8 ++++--- app/src/ui/lib/utils.ts | 27 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/ui/components/code/CodePreview.tsx b/app/src/ui/components/code/CodePreview.tsx index 9796e930..d79fd3a6 100644 --- a/app/src/ui/components/code/CodePreview.tsx +++ b/app/src/ui/components/code/CodePreview.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useTheme } from "ui/client/use-theme"; -import { cn } from "ui/lib/utils"; +import { cn, importDynamicBrowserModule } from "ui/lib/utils"; export type CodePreviewProps = { code: string; @@ -30,8 +30,10 @@ export const CodePreview = ({ async function highlightCode() { try { // Dynamically import Shiki from CDN - // @ts-expect-error - Dynamic CDN import - const { codeToHtml } = await import("https://esm.sh/shiki@3.13.0"); + const { codeToHtml } = await importDynamicBrowserModule( + "shiki", + "https://esm.sh/shiki@3.13.0", + ); if (cancelled) return; diff --git a/app/src/ui/lib/utils.ts b/app/src/ui/lib/utils.ts index 1af2c046..29eb301d 100644 --- a/app/src/ui/lib/utils.ts +++ b/app/src/ui/lib/utils.ts @@ -3,3 +3,30 @@ import { type ClassNameValue, twMerge } from "tailwind-merge"; export function cn(...inputs: ClassNameValue[]) { return twMerge(inputs); } + +/** + * Dynamically import a module from a URL in the browser in a way compatible with all react frameworks (nextjs doesn't support dynamic imports) + */ +export async function importDynamicBrowserModule(name: string, url: string): Promise { + if (!(window as any)[name]) { + const script = document.createElement("script"); + script.type = "module"; + script.async = true; + script.textContent = `import * as ${name} from '${url}';window.${name} = ${name};`; + document.head.appendChild(script); + + // poll for the module to be available + const maxAttempts = 50; // 5s + let attempts = 0; + while (!(window as any)[name] && attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 100)); + attempts++; + } + + if (!(window as any)[name]) { + throw new Error(`Browser module "${name}" failed to load`); + } + } + + return (window as any)[name] as T; +} From 5417aa174e85ec1a80df1047bddcd14fc231214f Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 31 Oct 2025 11:55:27 +0100 Subject: [PATCH 48/48] bump 0.19.0, update readme with use cases, small fixes - fix password strategy openapi tags - fix cli run picking up memory if specified - fix view transition chrome crash --- README.md | 20 +++- app/package.json | 2 +- .../strategies/PasswordStrategy.ts | 100 ++++++++++-------- app/src/cli/commands/run/run.ts | 5 +- app/src/ui/client/BkndProvider.tsx | 7 +- 5 files changed, 86 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 8761a0ee..0cba0953 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@

-bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! +bknd simplifies app development by providing a fully functional visual backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! + +It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way. + * **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc. * **Databases**: * SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal * Postgres: Vanilla Postgres, Supabase, Neon, Xata * **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku * **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem +* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc. **For documentation and examples, please visit https://docs.bknd.io.** @@ -24,6 +28,18 @@ bknd simplifies app development by providing a fully functional backend for data > Please keep in mind that **bknd** is still under active development > and therefore full backward compatibility is not guaranteed before reaching v1.0.0. +## Use Cases + +bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to: + +- **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend +- **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server. +- **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider +- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead +- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI +- **IoT & Embedded Devices** where minimal footprint matters + + ## Size ![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) @@ -46,13 +62,13 @@ Creating digital products always requires developing both the backend (the logic * **Media**: Effortlessly manage and serve all your media files. * **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!) * 🌐 Built on Web Standards for maximum compatibility +* 🛠️ MCP server, client and UI built-in to control your backend * 🏃‍♂️ Multiple run modes * standalone using the CLI * using a JavaScript runtime (Node, Bun, workerd) * using a React framework (Next.js, React Router, Astro) * 📦 Official API and React SDK with type-safety * ⚛️ React elements for auto-configured authentication and media components -* 🛠️ MCP server, client and UI built-in to control your backend ## Structure The package is mainly split into 4 parts, each serving a specific purpose: diff --git a/app/package.json b/app/package.json index ee5b028e..e5008fc6 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.19.0-rc.3", + "version": "0.19.0", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index e7e97b8c..0c6066b0 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,7 +1,7 @@ import type { User } from "bknd"; import type { Authenticator } from "auth/authenticate/Authenticator"; import { InvalidCredentialsException } from "auth/errors"; -import { hash, $console, s, parse, jsc } from "bknd/utils"; +import { hash, $console, s, parse, jsc, describeRoute } from "bknd/utils"; import { Hono } from "hono"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { AuthStrategy } from "./Strategy"; @@ -84,51 +84,67 @@ export class PasswordStrategy extends AuthStrategy { }); const payloadSchema = this.getPayloadSchema(); - hono.post("/login", jsc("query", redirectQuerySchema), async (c) => { - try { - const body = parse(payloadSchema, await authenticator.getBody(c), { - onError: (errors) => { - $console.error("Invalid login payload", [...errors]); - throw new InvalidCredentialsException(); - }, - }); - const { redirect } = c.req.valid("query"); - - return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { - redirect, - }); - } catch (e) { - return authenticator.respondWithError(c, e as any); - } - }); - - hono.post("/register", jsc("query", redirectQuerySchema), async (c) => { - try { - const { redirect } = c.req.valid("query"); - const { password, email, ...body } = parse( - payloadSchema, - await authenticator.getBody(c), - { + hono.post( + "/login", + describeRoute({ + summary: "Login with email and password", + tags: ["auth"], + }), + jsc("query", redirectQuerySchema), + async (c) => { + try { + const body = parse(payloadSchema, await authenticator.getBody(c), { onError: (errors) => { - $console.error("Invalid register payload", [...errors]); - new InvalidCredentialsException(); + $console.error("Invalid login payload", [...errors]); + throw new InvalidCredentialsException(); }, - }, - ); + }); + const { redirect } = c.req.valid("query"); - const profile = { - ...body, - email, - strategy_value: await this.hash(password), - }; + return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { + redirect, + }); + } catch (e) { + return authenticator.respondWithError(c, e as any); + } + }, + ); - return await authenticator.resolveRegister(c, this, profile, async () => void 0, { - redirect, - }); - } catch (e) { - return authenticator.respondWithError(c, e as any); - } - }); + hono.post( + "/register", + describeRoute({ + summary: "Register a new user with email and password", + tags: ["auth"], + }), + jsc("query", redirectQuerySchema), + async (c) => { + try { + const { redirect } = c.req.valid("query"); + const { password, email, ...body } = parse( + payloadSchema, + await authenticator.getBody(c), + { + onError: (errors) => { + $console.error("Invalid register payload", [...errors]); + new InvalidCredentialsException(); + }, + }, + ); + + const profile = { + ...body, + email, + strategy_value: await this.hash(password), + }; + + return await authenticator.resolveRegister(c, this, profile, async () => void 0, { + redirect, + }); + } catch (e) { + return authenticator.respondWithError(c, e as any); + } + }, + ); return hono; } diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 154d50c1..dbae9f96 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -110,7 +110,10 @@ export async function makeAppFromEnv(options: Partial = {}) { // try to use an in-memory connection } else if (options.memory) { console.info("Using", c.cyan("in-memory"), "connection"); - app = await makeApp({ server: { platform: options.server } }); + app = await makeApp({ + server: { platform: options.server }, + connection: { url: ":memory:" }, + }); // finally try to use env variables } else { diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index c37d1820..3ac5d5df 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -123,11 +123,14 @@ export function BkndProvider({ fetching.current = Fetching.None; }; - if ("startViewTransition" in document) { + // disable view transitions for now + // because it causes browser crash on heavy pages (e.g. schema) + commit(); + /* if ("startViewTransition" in document) { document.startViewTransition(commit); } else { commit(); - } + } */ }); }