From 0629e1bc5083bcfa2f10776065cb7c763c2a6e0f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:24:37 +0200 Subject: [PATCH 01/47] feat: add role selection and auth checks in user create command integrated role selection prompt during user creation and added an auth-enabled check to ensure correct configuration before executing commands. adjusted CLI commands to include role assignment for newly created users. --- app/src/cli/commands/user.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 3721a2b..726748b 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -3,6 +3,7 @@ import { log as $log, password as $password, text as $text, + select as $select, } from "@clack/prompts"; import type { App } from "App"; import type { PasswordStrategy } from "auth/authenticate/strategies"; @@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig server: "node", }); + if (!app.module.auth.enabled) { + $log.error("Auth is not enabled"); + process.exit(1); + } + switch (action) { case "create": await create(app, options); @@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig } async function create(app: App, options: any) { - const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; + const auth = app.module.auth; + let role: string | null = null; + const roles = Object.keys(auth.config.roles ?? {}); + + const strategy = auth.authenticator.strategy("password") as PasswordStrategy; + if (roles.length > 0) { + role = (await $select({ + message: "Select role", + options: [ + { + value: null, + label: "", + hint: "No role will be assigned to the user", + }, + ...roles.map((role) => ({ + value: role, + label: role, + })), + ], + })) as any; + if ($isCancel(role)) process.exit(1); + } if (!strategy) { $log.error("Password strategy not configured"); @@ -76,6 +103,7 @@ async function create(app: App, options: any) { const created = await app.createUser({ email, password: await strategy.hash(password as string), + role, }); $log.success(`Created user: ${c.cyan(created.email)}`); process.exit(0); From ace9c1b2b958c07ab1b84ff7b7f076e612659072 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:26:07 +0200 Subject: [PATCH 02/47] feat: add helper methods for auth cookie headers introduced `getAuthCookieHeader` and `removeAuthCookieHeader` methods to simplify header management for authentication cookies. added tests to validate the new methods. --- app/__test__/auth/Authenticator.spec.ts | 40 +++++++++++++++++++++- app/src/auth/authenticate/Authenticator.ts | 25 ++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/__test__/auth/Authenticator.spec.ts b/app/__test__/auth/Authenticator.spec.ts index 0794528..fcf8e5c 100644 --- a/app/__test__/auth/Authenticator.spec.ts +++ b/app/__test__/auth/Authenticator.spec.ts @@ -1,3 +1,41 @@ +import { Authenticator } from "auth/authenticate/Authenticator"; import { describe, expect, test } from "bun:test"; -describe("Authenticator", async () => {}); +describe("Authenticator", async () => { + test("should return auth cookie headers", async () => { + const auth = new Authenticator({}, null as any, { + jwt: { + secret: "secret", + fields: [], + }, + cookie: { + sameSite: "strict", + }, + }); + const headers = await auth.getAuthCookieHeader("token"); + const cookie = headers.get("Set-Cookie"); + expect(cookie).toStartWith("auth="); + expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict"); + + // now expect it to be removed + const headers2 = await auth.removeAuthCookieHeader(headers); + const cookie2 = headers2.get("Set-Cookie"); + expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires="); + expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict"); + }); + + test("should return auth cookie string", async () => { + const auth = new Authenticator({}, null as any, { + jwt: { + secret: "secret", + fields: [], + }, + cookie: { + sameSite: "strict", + }, + }); + const cookie = await auth.unsafeGetAuthCookie("token"); + expect(cookie).toStartWith("auth="); + expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict"); + }); +}); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 52a2e42..711099b 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -327,6 +327,31 @@ export class Authenticator< await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } + async getAuthCookieHeader(token: string, headers = new Headers()) { + const c = { + header: (key: string, value: string) => { + headers.set(key, value); + }, + }; + await this.setAuthCookie(c as any, token); + return headers; + } + + async removeAuthCookieHeader(headers = new Headers()) { + const c = { + header: (key: string, value: string) => { + headers.set(key, value); + }, + req: { + raw: { + headers, + }, + }, + }; + this.deleteAuthCookie(c as any); + return headers; + } + async unsafeGetAuthCookie(token: string): Promise { // this works for as long as cookieOptions.prefix is not set return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions); From 7344b1cf3db52a2f821b903ca25d5d88e722879c Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 10:29:03 +0200 Subject: [PATCH 03/47] feat: add media option to module to restrict body max size updated media schema to enforce strict validation, introduced `options` for AppMedia, and added a key prefix feature for StorageR2Adapter to enhance flexibility and control. --- app/src/adapter/cloudflare/storage/StorageR2Adapter.ts | 4 +++- app/src/media/AppMedia.ts | 3 +++ app/src/media/api/MediaController.ts | 5 ++++- app/src/media/media-schema.ts | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index e257b7c..7f1250a 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -49,6 +49,8 @@ export function registerMedia( * @todo: add tests (bun tests won't work, need node native tests) */ export class StorageR2Adapter extends StorageAdapter { + public keyPrefix: string = ""; + constructor(private readonly bucket: R2Bucket) { super(); } @@ -175,7 +177,7 @@ export class StorageR2Adapter extends StorageAdapter { } protected getKey(key: string) { - return key; + return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/"); } toJSON(secrets?: boolean) { diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 0971187..eb92d48 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -21,6 +21,9 @@ declare module "bknd" { // @todo: current workaround to make it all required export class AppMedia extends Module> { private _storage?: Storage; + options = { + body_max_size: null as number | null, + }; override async build() { if (!this.config.enabled) { diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 6a72048..81017aa 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -93,7 +93,10 @@ export class MediaController extends Controller { }, ); - const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; + const maxSize = + this.media.options.body_max_size ?? + this.getStorage().getConfig().body_max_size ?? + Number.POSITIVE_INFINITY; if (isDebug()) { hono.post( diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 4e71d83..eaa2b8d 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -48,7 +48,7 @@ export function buildMediaSchema() { { default: {}, }, - ); + ).strict(); } export const mediaConfigSchema = buildMediaSchema(); From 06d7558c3c451a99c6166dc0d07e67bc7c761207 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 24 Sep 2025 14:48:45 +0200 Subject: [PATCH 04/47] feat: batch schema manager statements run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan. --- app/__test__/data/specs/SchemaManager.spec.ts | 37 ++++++++++++++- .../cloudflare-workers.adapter.spec.ts | 4 +- app/src/data/entities/EntityManager.ts | 7 +-- app/src/data/schema/SchemaManager.ts | 46 +++++++------------ 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/app/__test__/data/specs/SchemaManager.spec.ts b/app/__test__/data/specs/SchemaManager.spec.ts index 679010b..e35df15 100644 --- a/app/__test__/data/specs/SchemaManager.spec.ts +++ b/app/__test__/data/specs/SchemaManager.spec.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line import/no-unresolved -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { randomString } from "core/utils"; import { Entity, EntityManager } from "data/entities"; import { TextField, EntityIndex } from "data/fields"; @@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => { const diffAfter = await em.schema().getDiff(); expect(diffAfter.length).toBe(0); }); + + test("returns statements", async () => { + const amount = 5; + const entities = new Array(amount) + .fill(0) + .map(() => new Entity(randomString(16), [new TextField("text")])); + const em = new EntityManager(entities, dummyConnection); + const statements = await em.schema().sync({ force: true }); + expect(statements.length).toBe(amount); + expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe( + true, + ); + }); + + test("batches statements", async () => { + const { dummyConnection } = getDummyConnection(); + const entities = new Array(20) + .fill(0) + .map(() => new Entity(randomString(16), [new TextField("text")])); + const em = new EntityManager(entities, dummyConnection); + const spy = spyOn(em.connection, "executeQueries"); + const statements = await em.schema().sync(); + expect(statements.length).toBe(entities.length); + expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe( + true, + ); + await em.schema().sync({ force: true }); + expect(spy).toHaveBeenCalledTimes(1); + const tables = await em.connection.kysely + .selectFrom("sqlite_master") + .where("type", "=", "table") + .selectAll() + .execute(); + expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */ + }); }); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 65477b6..6cb0f90 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite"; import { bunTestRunner } from "adapter/bun/test"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; -/* beforeAll(disableConsoleLog); -afterAll(enableConsoleLog); */ +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("cf adapter", () => { const DB_URL = ":memory:"; diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 36168f8..033d51a 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -34,7 +34,6 @@ export class EntityManager { private _entities: Entity[] = []; private _relations: EntityRelation[] = []; private _indices: EntityIndex[] = []; - private _schema?: SchemaManager; readonly emgr: EventManager; static readonly Events = { ...MutatorEvents, ...RepositoryEvents }; @@ -249,11 +248,7 @@ export class EntityManager { } schema() { - if (!this._schema) { - this._schema = new SchemaManager(this); - } - - return this._schema; + return new SchemaManager(this); } // @todo: centralize and add tests diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index ab2d15f..a0618f4 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -247,20 +247,16 @@ export class SchemaManager { async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) { const diff = await this.getDiff(); - let updates: number = 0; const statements: { sql: string; parameters: readonly unknown[] }[] = []; const schema = this.em.connection.kysely.schema; + const qbs: { compile(): CompiledQuery; execute(): Promise }[] = []; for (const table of diff) { - const qbs: { compile(): CompiledQuery; execute(): Promise }[] = []; - let local_updates: number = 0; const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add); const dropFields = table.columns.drop; const dropIndices = table.indices.drop; if (table.isDrop) { - updates++; - local_updates++; if (config.drop) { qbs.push(schema.dropTable(table.name)); } @@ -268,8 +264,6 @@ export class SchemaManager { let createQb = schema.createTable(table.name); // add fields for (const fieldSchema of addFieldSchemas) { - updates++; - local_updates++; // @ts-ignore createQb = createQb.addColumn(...fieldSchema); } @@ -280,8 +274,6 @@ export class SchemaManager { if (addFieldSchemas.length > 0) { // add fields for (const fieldSchema of addFieldSchemas) { - updates++; - local_updates++; // @ts-ignore qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema)); } @@ -291,8 +283,6 @@ export class SchemaManager { if (config.drop && dropFields.length > 0) { // drop fields for (const column of dropFields) { - updates++; - local_updates++; qbs.push(schema.alterTable(table.name).dropColumn(column)); } } @@ -310,35 +300,33 @@ export class SchemaManager { qb = qb.unique(); } qbs.push(qb); - local_updates++; - updates++; } // drop indices if (config.drop) { for (const index of dropIndices) { qbs.push(schema.dropIndex(index)); - local_updates++; - updates++; } } + } - if (local_updates === 0) continue; + if (qbs.length > 0) { + statements.push( + ...qbs.map((qb) => { + const { sql, parameters } = qb.compile(); + return { sql, parameters }; + }), + ); - // iterate through built qbs - // @todo: run in batches - for (const qb of qbs) { - const { sql, parameters } = qb.compile(); - statements.push({ sql, parameters }); + $console.debug( + "[SchemaManager]", + `${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`, + ); - if (config.force) { - try { - $console.debug("[SchemaManager]", sql); - await qb.execute(); - } catch (e) { - throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`); - } - } + try { + await this.em.connection.executeQueries(...qbs); + } catch (e) { + throw new Error(`Failed to execute batch: ${String(e)}`); } } From 1fdee8435d2dfc043c9415ea8aa3cbbcf6e7e4eb Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 29 Sep 2025 22:12:23 +0200 Subject: [PATCH 05/47] feat: add timestamps plugin to manage created_at and updated_at fields Introduced a new timestamps plugin that allows the addition of `created_at` and `updated_at` fields to specified entities. Included tests to verify functionality, ensuring timestamps are correctly set on entity creation and updates. Updated the plugin index to export the new timestamps functionality. --- app/src/plugins/data/timestamp.plugin.spec.ts | 74 ++++++++++++++++ app/src/plugins/data/timestamps.plugin.ts | 86 +++++++++++++++++++ app/src/plugins/index.ts | 1 + 3 files changed, 161 insertions(+) create mode 100644 app/src/plugins/data/timestamp.plugin.spec.ts create mode 100644 app/src/plugins/data/timestamps.plugin.ts diff --git a/app/src/plugins/data/timestamp.plugin.spec.ts b/app/src/plugins/data/timestamp.plugin.spec.ts new file mode 100644 index 0000000..bdf8811 --- /dev/null +++ b/app/src/plugins/data/timestamp.plugin.spec.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { timestamps } from "./timestamps.plugin"; +import { em, entity, text } from "bknd"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +describe("timestamps plugin", () => { + test("should ignore if no or invalid entities are provided", async () => { + const app = createApp({ + options: { + plugins: [timestamps({ entities: [] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual([]); + + { + const app = createApp({ + options: { + plugins: [timestamps({ entities: ["posts"] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual([]); + } + }); + + test("should add timestamps to the specified entities", async () => { + const app = createApp({ + config: { + data: em({ + posts: entity("posts", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [timestamps({ entities: ["posts", "invalid"] })], + }, + }); + await app.build(); + expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]); + expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([ + "id", + "title", + "created_at", + "updated_at", + ]); + + // insert + const mutator = app.em.mutator(app.em.entity("posts")); + const { data } = await mutator.insertOne({ title: "Hello" }); + expect(data.created_at).toBeDefined(); + expect(data.updated_at).toBeDefined(); + expect(data.created_at).toBeInstanceOf(Date); + expect(data.updated_at).toBeInstanceOf(Date); + const diff = data.created_at.getTime() - data.updated_at.getTime(); + expect(diff).toBeLessThan(10); + expect(diff).toBeGreaterThan(-1); + + // update (set updated_at to null, otherwise it's too fast to test) + await app.em.connection.kysely + .updateTable("posts") + .set({ updated_at: null }) + .where("id", "=", data.id) + .execute(); + const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" }); + expect(updatedData.updated_at).toBeDefined(); + expect(updatedData.updated_at).toBeInstanceOf(Date); + }); +}); diff --git a/app/src/plugins/data/timestamps.plugin.ts b/app/src/plugins/data/timestamps.plugin.ts new file mode 100644 index 0000000..0de5a94 --- /dev/null +++ b/app/src/plugins/data/timestamps.plugin.ts @@ -0,0 +1,86 @@ +import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd"; +import { $console } from "bknd/utils"; + +export type TimestampsPluginOptions = { + entities: string[]; + setUpdatedOnCreate?: boolean; +}; + +/** + * This plugin adds `created_at` and `updated_at` fields to the specified entities. + * Add it to your plugins in `bknd.config.ts` like this: + * + * ```ts + * export default { + * plugins: [timestamps({ entities: ["posts"] })], + * } + * ``` + */ +export function timestamps({ + entities = [], + setUpdatedOnCreate = true, +}: TimestampsPluginOptions): AppPlugin { + return (app: App) => ({ + name: "timestamps", + schema: () => { + if (entities.length === 0) { + $console.warn("No entities specified for timestamps plugin"); + return; + } + + const appEntities = app.em.entities.map((e) => e.name); + + return em( + Object.fromEntries( + entities + .filter((e) => appEntities.includes(e)) + .map((e) => [ + e, + entity(e, { + created_at: datetime(), + updated_at: datetime(), + }), + ]), + ), + ); + }, + onBuilt: async () => { + app.emgr.onEvent( + DatabaseEvents.MutatorInsertBefore, + (event) => { + const { entity, data } = event.params; + if (entities.includes(entity.name)) { + return { + ...data, + created_at: new Date(), + updated_at: setUpdatedOnCreate ? new Date() : null, + }; + } + return data; + }, + { + mode: "sync", + id: "bknd-timestamps", + }, + ); + + app.emgr.onEvent( + DatabaseEvents.MutatorUpdateBefore, + async (event) => { + const { entity, data } = event.params; + if (entities.includes(entity.name)) { + return { + ...data, + updated_at: new Date(), + }; + } + return data; + }, + { + mode: "sync", + id: "bknd-timestamps", + }, + ); + }, + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 45db2d5..b0090ff 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin"; export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; +export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; From d6dcfe3acc61ee368b78326e4e6bd6f92befaf9f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 1 Oct 2025 09:46:16 +0200 Subject: [PATCH 06/47] 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 36b4969..66d289e 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 8e812cf..a2093c0 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 ffaa5df..b7cb384 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/47] 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 c0e04ff..5510e73 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 0000000..c6e58fb --- /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 36b4969..b7d4c96 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 ba12d4a..15448be 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 a89b98d..5576d4b 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 702023b..685a9bd 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 86cf46b..5ca4d84 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 41902a9..65f18f9 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 3d3692c..d30aae1 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 6a72048..e20fa2e 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 60a6dfc..d671065 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 b6fbead..5f5ccd1 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 2800781..2cdb2a7 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 93533a2..43d688e 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/47] Bump version --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c946b6a..5159e77 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/47] Update docker builder --- docker/Dockerfile | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5159e77..71c2716 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/47] 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 0000000..8400f46 --- /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 5510e73..fbf787f 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 c6e58fb..2b3a5ce 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 340ccaf..477951f 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 15448be..b94aa4b 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 8b097e7..dce59f5 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 5576d4b..ac97c63 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 0000000..cd7b51b --- /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 0000000..fd873af --- /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 54efaf1..0cf038b 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 685a9bd..eeebe45 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 0000000..ac38a08 --- /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 5ca4d84..0000000 --- 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 0772abd..9b8e385 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 d30aae1..ff8190c 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 163f0af..d4f9cdf 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 3db75ed..124980e 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 ae01151..0f3d980 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 3c67bc7..e1f795b 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 527ce28..0ae0017 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 d671065..29c4172 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 be1ad59..213eb7e 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 5f5ccd1..152072d 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 2cdb2a7..454ad40 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 b9fb531..e774d1e 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 43d688e..4469c7e 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/47] 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 fbf787f..5e39fb8 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 2b3a5ce..f885d6e 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 dc03fb6..215adf8 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 ac97c63..37ee842 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 fd873af..9995e20 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 0cf038b..3f072a1 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 ac38a08..c6e53f4 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 e90921d..24352c4 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 9b8e385..5b943ff 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/47] 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 ec96811..c65e59a 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 502a844..3ad9146 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 60351ca..bc81a83 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 784db35..af4607c 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 59deceb..3cf920b 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 333bba3..7b755cf 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 0000000..99bcee1 --- /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 401ab1f..f1d219c 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/47] 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 a0c6072..cead597 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 4fd40a4..e479ea1 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 3f072a1..7506fc7 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 4469c7e..45787bf 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 abb0020..c37d182 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 3cf920b..748bf23 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 7ee4ea6..ff7c39d 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 59c7e22..15596bf 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 0a16d2d..d1b7f51 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 6432570..a5faf5f 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 71bb87f..95681fd 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/47] 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 e7658b4..2636730 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 5e39fb8..b13935a 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 f885d6e..78abdd0 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 cead597..a73f9ec 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/47] 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 28d2ef0..fd4394a 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 cd22ada..e3c0843 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 52a2e42..465cbd1 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 f89fb99..9b9ebb7 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 13352d1..31dbae6 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 e3fb4a6..291c963 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 7243099..46ed4fb 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 fb4bc2f..ba27bf9 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/47] 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 c1041d9..4384928 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/47] 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 31dbae6..88a54c1 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/47] 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 0f535f8..b8cbde7 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 7f24923..92a8604 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 00b61b5..44e7ccf 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 5f85135..a0ca1ed 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 2548efa..949aaba 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 ba0953b..eed1c35 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 b430450..befd771 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 fd96086..83feba8 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 f37260d..f624bde 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 03beae5..c0550db 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 ae01151..4f48439 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 0000000..30e4dc3 --- /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 0000000..b545270 --- /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 0000000..b053671 --- /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 0000000..afc2c6a --- /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 a7bc903..8af95e8 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 55264d4..10260b4 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/47] 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 b13935a..caa5566 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 0000000..6ff0c3e --- /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 8400f46..40e6493 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 78abdd0..14a01ee 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 b7d4c96..8957e9e 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 10cf8ac..416b5c0 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 f932580..c40f3b1 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 b94aa4b..99f1000 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 37ee842..85349a9 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 e18a98c..cfd5963 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 9995e20..06357f1 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 65f18f9..33c6a43 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 d4f9cdf..adceffa 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 13554a6..3d8f432 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 124980e..f832716 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 bc81a83..4669022 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 274c162..acfa25b 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 ff7c39d..54a1bfd 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 15596bf..75793fe 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 8b5995e..ab63856 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/47] 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 c40f3b1..c256cc9 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 a73f9ec..4b23919 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 711099b..b7ececc 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 85349a9..e66dba1 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 29c4172..c227ba1 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 dc2e5c6..ab26bae 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 45787bf..1190d55 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 6b6d546..573b990 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 b80f006..79ee3cc 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 0000000..9796e93 --- /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 af4607c..334dfe5 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 54a1bfd..9637ed1 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 c06668b..ccde4c6 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 d439fc1..6c475dd 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 ab63856..fa63422 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/47] 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 e1f795b..0523b6a 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 b48d1e1..7f83358 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 1517a29..9fd2d5a 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/47] 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 e66dba1..6336a59 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 acfa25b..5608796 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 9637ed1..e1be5dd 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/47] 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 c65e59a..93bb8d1 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 e1be5dd..51d909d 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/47] 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 1190d55..9cdbd99 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/47] 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 51d909d..d24ac9b 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/47] 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 163f0af..e2608d2 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/47] 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 1266746..f68b462 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 0000000..022ef2f --- /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 fcbe092..db7e6f4 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 84389c3..e4bb6f0 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 98a1f45..8451d1d 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 369d41b..bf81fbd 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 78708d6..8a06957 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 13f39ee..e97071b 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 502a844..cd85aa4 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 ff778a1..9943aba 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/47] 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 f68b462..1266746 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 022ef2f..0000000 --- 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 db7e6f4..fcbe092 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 e4bb6f0..84389c3 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 8451d1d..98a1f45 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 bf81fbd..369d41b 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 8a06957..78708d6 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 e97071b..13f39ee 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 cd85aa4..502a844 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 9943aba..ff778a1 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/47] 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 454ad40..e098101 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/47] 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 c256cc9..e7b522b 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 b8cbde7..ed6d765 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 afdab4b..315a58d 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/47] 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 949aaba..79f4c97 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 0000000..a26ada2 --- /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 9083adc..33c50b3 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 6d67d94..6330208 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 8e664c6..91bfdd7 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/47] 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 217b07d..d3caae6 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 0000000..eb17269 --- /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 ed0f9e1..7aab8d5 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 f0f60a6..d305846 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 0000000..0f8edde --- /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 58e052f..68aba30 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 97faf72..0000000 --- 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/47] 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 d3caae6..3fca3b2 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 7722e1c..964ee47 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 0f8edde..6e8656b 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/47] 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 a26ada2..dbebcaa 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/47] 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 a21d444..bb238bf 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/47] 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 7f1250a..756e562 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/47] 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 14a01ee..411ad20 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 6336a59..a8f91e3 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 0000000..27e247c --- /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 24352c4..58a50cc 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/47] 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 27e247c..fac73e2 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/47] 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 fac73e2..80383c6 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 58a50cc..110cce6 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 89585a3..eb2eb2b 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 cb4defe..9a01e2a 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/47] 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 b8729bd..4a30da9 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 e7b522b..7d2f2fc 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 ed6d765..5658d90 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 fe278c4..e263756 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 86a7722..5803757 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 8370f2b..1ac8bc4 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 315a58d..082ae0c 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 22c2368..e30af8a 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 b545270..7a8022b 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 afc2c6a..706a411 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/47] 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 706a411..f1bc4ff 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 ebac844..adf6810 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/47] 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 1ab0fa1..850629d 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/47] 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 4a4db73..2bedbee 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 5658d90..633b9fa 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 9cdbd99..3ae6cd2 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 78e1b45..40a7e97 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 7075b1e..afd9b12 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/47] 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 7d2f2fc..37746bc 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 5803757..86a7722 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/47] 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 c54854b..17ee6b5 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/47] 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 ff94dc3..d834f67 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 37746bc..ee5b028 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 17ee6b5..8ed4802 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/47] 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 9796e93..d79fd3a 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 1af2c04..29eb301 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; +}