diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..8cd1d99 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "bknd": { + "url": "http://localhost:28623/api/system/mcp" + } + } +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a807d56..bc89cce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.2.19" + bun-version: "1.2.22" - name: Install dependencies working-directory: ./app diff --git a/README.md b/README.md index ab86304..0cba095 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,50 @@ [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd) -![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) +![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/public/assets/poster.png)

- + ⭐ Live Demo

-bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! +bknd simplifies app development by providing a fully functional visual backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! + +It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way. + * **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc. * **Databases**: * SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal * Postgres: Vanilla Postgres, Supabase, Neon, Xata * **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku * **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem +* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc. **For documentation and examples, please visit https://docs.bknd.io.** > [!WARNING] -> This project requires Node.js 22 or higher (because of `node:sqlite`). +> This project requires Node.js 22.13 or higher (because of `node:sqlite`). > > Please keep in mind that **bknd** is still under active development > and therefore full backward compatibility is not guaranteed before reaching v1.0.0. +## Use Cases + +bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to: + +- **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend +- **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server. +- **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider +- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead +- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI +- **IoT & Embedded Devices** where minimal footprint matters + + ## Size ![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) -![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) -![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui) +![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements&t=1) +![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui&t=1) The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets. @@ -46,6 +62,7 @@ Creating digital products always requires developing both the backend (the logic * **Media**: Effortlessly manage and serve all your media files. * **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!) * 🌐 Built on Web Standards for maximum compatibility +* 🛠️ MCP server, client and UI built-in to control your backend * 🏃‍♂️ Multiple run modes * standalone using the CLI * using a JavaScript runtime (Node, Bun, workerd) diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 0b456e1..7213216 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -1,12 +1,15 @@ -import { afterEach, describe, test, expect } from "bun:test"; +import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test"; import { App, createApp } from "core/test/utils"; import { getDummyConnection } from "./helper"; import { Hono } from "hono"; import * as proto from "../src/data/prototype"; import { pick } from "lodash-es"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterEach(afterAllCleanup); +afterEach(async () => (await afterAllCleanup()) && enableConsoleLog()); describe("App tests", async () => { test("boots and pongs", async () => { @@ -19,7 +22,7 @@ describe("App tests", async () => { test("plugins", async () => { const called: string[] = []; const app = createApp({ - initialConfig: { + config: { auth: { enabled: true, }, diff --git a/app/__test__/_assets/test.mp3 b/app/__test__/_assets/test.mp3 new file mode 100644 index 0000000..ab94045 Binary files /dev/null and b/app/__test__/_assets/test.mp3 differ diff --git a/app/__test__/_assets/test.txt b/app/__test__/_assets/test.txt new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/app/__test__/_assets/test.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/app/__test__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts index f7d623c..8527b5d 100644 --- a/app/__test__/adapter/adapter.test.ts +++ b/app/__test__/adapter/adapter.test.ts @@ -1,4 +1,4 @@ -import { expect, describe, it, beforeAll, afterAll } from "bun:test"; +import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test"; import * as adapter from "adapter"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { adapterTestSuite } from "adapter/adapter-test-suite"; @@ -9,60 +9,49 @@ beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("adapter", () => { - it("makes config", () => { - expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({}); - expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual( - {}, - ); + it("makes config", async () => { + expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({}); + expect( + omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]), + ).toEqual({}); // merges everything returned from `app` with the config expect( omitKeys( - adapter.makeConfig( - { app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) }, + await adapter.makeConfig( + { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, { env: { TEST: "test" } }, ), ["connection"], ), ).toEqual({ - initialConfig: { server: { cors: { origin: "test" } } }, + config: { server: { cors: { origin: "test" } } }, }); }); - /* it.only("...", async () => { - const app = await adapter.createAdapterApp(); - }); */ - - it("reuses apps correctly", async () => { - const id = crypto.randomUUID(); - - const first = await adapter.createAdapterApp( + it("allows all properties in app function", async () => { + const called = mock(() => null); + const config = await adapter.makeConfig( { - initialConfig: { server: { cors: { origin: "random" } } }, + app: (env) => ({ + connection: { url: "test" }, + config: { server: { cors: { origin: "test" } } }, + options: { + mode: "db", + }, + onBuilt: () => { + called(); + expect(env).toEqual({ foo: "bar" }); + }, + }), }, - undefined, - { id }, + { foo: "bar" }, ); - const second = await adapter.createAdapterApp(); - const third = await adapter.createAdapterApp(undefined, undefined, { id }); - - await first.build(); - await second.build(); - await third.build(); - - expect(first.toJSON().server.cors.origin).toEqual("random"); - expect(first).toBe(third); - expect(first).not.toBe(second); - expect(second).not.toBe(third); - expect(second.toJSON().server.cors.origin).toEqual("*"); - - // recreate the first one - const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true }); - await first2.build(); - expect(first2).not.toBe(first); - expect(first2).not.toBe(third); - expect(first2).not.toBe(second); - expect(first2.toJSON().server.cors.origin).toEqual("*"); + expect(config.connection).toEqual({ url: "test" }); + expect(config.config).toEqual({ server: { cors: { origin: "test" } } }); + expect(config.options).toEqual({ mode: "db" }); + await config.onBuilt?.(null as any); + expect(called).toHaveBeenCalled(); }); adapterTestSuite(bunTestRunner, { diff --git a/app/__test__/api/Api.spec.ts b/app/__test__/api/Api.spec.ts index dee1e14..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 () => { @@ -42,7 +45,6 @@ describe("Api", async () => { expect(api.isAuthVerified()).toBe(false); const params = api.getParams(); - console.log(params); expect(params.token).toBe(token); expect(params.token_transport).toBe("cookie"); expect(params.host).toBe("http://example.com"); diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index 361bbef..5bbee56 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -1,9 +1,23 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import type { ModuleBuildContext } from "../../src"; import { App, createApp } from "core/test/utils"; -import * as proto from "../../src/data/prototype"; +import * as proto from "data/prototype"; +import { DbModuleManager } from "modules/db/DbModuleManager"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("App", () => { + test("use db mode by default", async () => { + const app = createApp(); + await app.build(); + + expect(app.mode).toBe("db"); + expect(app.isReadOnly()).toBe(false); + expect(app.modules instanceof DbModuleManager).toBe(true); + }); + test("seed includes ctx and app", async () => { const called = mock(() => null); await createApp({ @@ -20,6 +34,7 @@ describe("App", () => { "guard", "flags", "logger", + "mcp", "helper", ]); }, @@ -28,7 +43,7 @@ describe("App", () => { expect(called).toHaveBeenCalled(); const app = createApp({ - initialConfig: { + config: { data: proto .em({ todos: proto.entity("todos", { @@ -135,4 +150,21 @@ describe("App", () => { // expect async listeners to be executed sync after request expect(called).toHaveBeenCalled(); }); + + test("getMcpClient", async () => { + const app = createApp({ + config: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + const client = app.getMcpClient(); + const res = await client.listTools(); + expect(res).toBeDefined(); + expect(res?.tools.length).toBeGreaterThan(0); + }); }); diff --git a/app/__test__/app/AppServer.spec.ts b/app/__test__/app/AppServer.spec.ts index 40ea414..1933787 100644 --- a/app/__test__/app/AppServer.spec.ts +++ b/app/__test__/app/AppServer.spec.ts @@ -13,6 +13,11 @@ describe("AppServer", () => { allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + path: "/api/system/mcp", + logLevel: "warning", + }, }); } @@ -31,6 +36,11 @@ describe("AppServer", () => { allow_methods: ["GET", "POST"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + path: "/api/system/mcp", + logLevel: "warning", + }, }); } }); diff --git a/app/__test__/app/code-only.test.ts b/app/__test__/app/code-only.test.ts new file mode 100644 index 0000000..26fb8e9 --- /dev/null +++ b/app/__test__/app/code-only.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createApp as internalCreateApp, type CreateAppConfig } from "bknd"; +import { getDummyConnection } from "../../__test__/helper"; +import { ModuleManager } from "modules/ModuleManager"; +import { em, entity, text } from "data/prototype"; + +async function createApp(config: CreateAppConfig = {}) { + const app = internalCreateApp({ + connection: getDummyConnection().dummyConnection, + ...config, + options: { + ...config.options, + mode: "code", + }, + }); + await app.build(); + return app; +} + +describe("code-only", () => { + test("should create app with correct manager", async () => { + const app = await createApp(); + await app.build(); + + expect(app.version()).toBeDefined(); + expect(app.modules).toBeInstanceOf(ModuleManager); + }); + + test("should not perform database syncs", async () => { + const app = await createApp({ + config: { + data: em({ + test: entity("test", { + name: text(), + }), + }).toJSON(), + }, + }); + expect(app.em.entities.map((e) => e.name)).toEqual(["test"]); + expect( + await app.em.connection.kysely + .selectFrom("sqlite_master") + .where("type", "=", "table") + .selectAll() + .execute(), + ).toEqual([]); + + // only perform when explicitly forced + await app.em.schema().sync({ force: true }); + expect( + await app.em.connection.kysely + .selectFrom("sqlite_master") + .where("type", "=", "table") + .selectAll() + .execute() + .then((r) => r.map((r) => r.name)), + ).toEqual(["test", "sqlite_sequence"]); + }); + + test("should not perform seeding", async () => { + const called = mock(() => null); + const app = await createApp({ + config: { + data: em({ + test: entity("test", { + name: text(), + }), + }).toJSON(), + }, + options: { + seed: async (ctx) => { + called(); + await ctx.em.mutator("test").insertOne({ name: "test" }); + }, + }, + }); + await app.em.schema().sync({ force: true }); + expect(called).not.toHaveBeenCalled(); + expect( + await app.em + .repo("test") + .findMany({}) + .then((r) => r.data), + ).toEqual([]); + }); + + test("should sync and perform seeding", async () => { + const called = mock(() => null); + const app = await createApp({ + config: { + data: em({ + test: entity("test", { + name: text(), + }), + }).toJSON(), + }, + options: { + seed: async (ctx) => { + called(); + await ctx.em.mutator("test").insertOne({ name: "test" }); + }, + }, + }); + + await app.em.schema().sync({ force: true }); + await app.options?.seed?.({ + ...app.modules.ctx(), + app: app, + }); + expect(called).toHaveBeenCalled(); + expect( + await app.em + .repo("test") + .findMany({}) + .then((r) => r.data), + ).toEqual([{ id: 1, name: "test" }]); + }); + + test("should not allow to modify config", async () => { + const app = await createApp(); + // biome-ignore lint/suspicious/noPrototypeBuiltins: + expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false); + expect(() => { + app.modules.configs().auth.enabled = true; + }).toThrow(); + }); +}); diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts new file mode 100644 index 0000000..2636730 --- /dev/null +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -0,0 +1,232 @@ +import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import type { McpServer } from "bknd/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +/** + * - [x] auth_me + * - [x] auth_strategies + * - [x] auth_user_create + * - [x] auth_user_token + * - [x] auth_user_password_change + * - [x] auth_user_password_test + * - [x] config_auth_get + * - [x] config_auth_update + * - [x] config_auth_strategies_get + * - [x] config_auth_strategies_add + * - [x] config_auth_strategies_update + * - [x] config_auth_strategies_remove + * - [x] config_auth_roles_get + * - [x] config_auth_roles_add + * - [x] config_auth_roles_update + * - [x] config_auth_roles_remove + */ +describe("mcp auth", async () => { + let app: App; + let server: McpServer; + beforeEach(async () => { + app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "secret", + }, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + await app.getMcpClient().ping(); + server = app.mcp!; + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("auth_*", async () => { + const me = await tool(server, "auth_me", {}); + expect(me.user).toBeNull(); + + // strategies + const strategies = await tool(server, "auth_strategies", {}); + expect(Object.keys(strategies.strategies).length).toEqual(1); + expect(strategies.strategies.password.enabled).toBe(true); + + // create user + const user = await tool( + server, + "auth_user_create", + { + email: "test@test.com", + password: "12345678", + }, + new Headers(), + ); + expect(user.email).toBe("test@test.com"); + + // create token + const token = await tool( + server, + "auth_user_token", + { + email: "test@test.com", + }, + new Headers(), + ); + expect(token.token).toBeDefined(); + expect(token.user.email).toBe("test@test.com"); + + // me + const me2 = await tool( + server, + "auth_me", + {}, + new Request("http://localhost", { + headers: new Headers({ + Authorization: `Bearer ${token.token}`, + }), + }), + ); + expect(me2.user.email).toBe("test@test.com"); + + // change password + const changePassword = await tool( + server, + "auth_user_password_change", + { + email: "test@test.com", + password: "87654321", + }, + new Headers(), + ); + expect(changePassword.changed).toBe(true); + + // test password + const testPassword = await tool( + server, + "auth_user_password_test", + { + email: "test@test.com", + password: "87654321", + }, + new Headers(), + ); + expect(testPassword.valid).toBe(true); + }); + + test("config_auth_{get,update}", async () => { + expect(await tool(server, "config_auth_get", {})).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().auth, + }); + + // update + await tool(server, "config_auth_update", { + value: { + allow_register: false, + }, + }); + expect(app.toJSON().auth.allow_register).toBe(false); + }); + + test("config_auth_strategies_{get,add,update,remove}", async () => { + const strategies = await tool(server, "config_auth_strategies_get", { + key: "password", + }); + expect(strategies).toEqual({ + secrets: false, + module: "auth", + key: "password", + value: { + enabled: true, + type: "password", + }, + }); + + // add google oauth + const addGoogleOauth = await tool(server, "config_auth_strategies_add", { + key: "google", + value: { + type: "oauth", + enabled: true, + config: { + name: "google", + type: "oidc", + client: { + client_id: "client_id", + client_secret: "client_secret", + }, + }, + }, + return_config: true, + }); + expect(addGoogleOauth.config.google.enabled).toBe(true); + expect(app.toJSON().auth.strategies.google?.enabled).toBe(true); + + // update (disable) google oauth + await tool(server, "config_auth_strategies_update", { + key: "google", + value: { + enabled: false, + }, + }); + expect(app.toJSON().auth.strategies.google?.enabled).toBe(false); + + // remove google oauth + await tool(server, "config_auth_strategies_remove", { + key: "google", + }); + expect(app.toJSON().auth.strategies.google).toBeUndefined(); + }); + + test("config_auth_roles_{get,add,update,remove}", async () => { + // add role + const addGuestRole = await tool(server, "config_auth_roles_add", { + key: "guest", + value: { + permissions: ["read", "write"], + }, + return_config: true, + }); + expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([ + "read", + "write", + ]); + + // update role + await tool(server, "config_auth_roles_update", { + key: "guest", + value: { + permissions: ["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.map((p) => p.permission)).toEqual(["read"]); + + // remove role + await tool(server, "config_auth_roles_remove", { + key: "guest", + }); + expect(app.toJSON().auth.roles?.guest).toBeUndefined(); + }); +}); diff --git a/app/__test__/app/mcp/mcp.base.test.ts b/app/__test__/app/mcp/mcp.base.test.ts new file mode 100644 index 0000000..48acd2e --- /dev/null +++ b/app/__test__/app/mcp/mcp.base.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { createApp } from "core/test/utils"; +import { registries } from "index"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +describe("mcp", () => { + it("should have tools", async () => { + registries.media.register("local", StorageLocalAdapter); + + const app = createApp({ + config: { + auth: { + enabled: true, + }, + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + + // expect mcp to not be loaded yet + expect(app.mcp).toBeNull(); + + // after first request, mcp should be loaded + await app.getMcpClient().listTools(); + expect(app.mcp?.tools.length).toBeGreaterThan(0); + }); +}); diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts new file mode 100644 index 0000000..43aecf5 --- /dev/null +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -0,0 +1,347 @@ +import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { pickKeys, type McpServer } from "bknd/utils"; +import { entity, text } from "bknd"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +/** + * - [ ] data_sync + * - [x] data_entity_fn_count + * - [x] data_entity_fn_exists + * - [x] data_entity_read_one + * - [x] data_entity_read_many + * - [x] data_entity_insert + * - [x] data_entity_update_many + * - [x] data_entity_update_one + * - [x] data_entity_delete_one + * - [x] data_entity_delete_many + * - [x] data_entity_info + * - [ ] config_data_get + * - [ ] config_data_update + * - [x] config_data_entities_get + * - [x] config_data_entities_add + * - [x] config_data_entities_update + * - [x] config_data_entities_remove + * - [x] config_data_relations_add + * - [x] config_data_relations_get + * - [x] config_data_relations_update + * - [x] config_data_relations_remove + * - [x] config_data_indices_get + * - [x] config_data_indices_add + * - [x] config_data_indices_update + * - [x] config_data_indices_remove + */ +describe("mcp data", async () => { + let app: App; + let server: McpServer; + beforeEach(async () => { + const time = performance.now(); + app = createApp({ + config: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + await app.getMcpClient().ping(); + server = app.mcp!; + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("config_data_entities_{add,get,update,remove}", async () => { + const result = await tool(server, "config_data_entities_add", { + key: "test", + return_config: true, + value: {}, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.test?.type).toEqual("regular"); + + const entities = Object.keys(app.toJSON().data.entities ?? {}); + expect(entities).toContain("test"); + + { + // get + const result = await tool(server, "config_data_entities_get", { + key: "test", + }); + expect(result.module).toBe("data"); + expect(result.key).toBe("test"); + expect(result.value.type).toEqual("regular"); + } + + { + // update + const result = await tool(server, "config_data_entities_update", { + key: "test", + return_config: true, + value: { + config: { + name: "Test", + }, + }, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.test.config?.name).toEqual("Test"); + expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test"); + } + + { + // remove + const result = await tool(server, "config_data_entities_remove", { + key: "test", + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(app.toJSON().data.entities?.test).toBeUndefined(); + } + }); + + test("config_data_relations_{add,get,update,remove}", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: {}, + }); + await tool(server, "config_data_entities_add", { + key: "comments", + value: {}, + }); + + expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]); + + // create relation + await tool(server, "config_data_relations_add", { + key: "", // doesn't matter + value: { + type: "n:1", + source: "comments", + target: "posts", + }, + }); + + const config = app.toJSON().data; + expect( + pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]), + ).toEqual({ + type: "n:1", + source: "comments", + target: "posts", + }); + + expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation"); + + { + // info + const postsInfo = await tool(server, "data_entity_info", { + entity: "posts", + }); + expect(postsInfo.fields).toEqual(["id"]); + expect(postsInfo.relations.all.length).toBe(1); + + const commentsInfo = await tool(server, "data_entity_info", { + entity: "comments", + }); + expect(commentsInfo.fields).toEqual(["id", "posts_id"]); + expect(commentsInfo.relations.all.length).toBe(1); + } + + // update + await tool(server, "config_data_relations_update", { + key: "n1_comments_posts", + value: { + config: { + with_limit: 10, + }, + }, + }); + expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10); + + // delete + await tool(server, "config_data_relations_remove", { + key: "n1_comments_posts", + }); + expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined(); + }); + + test("config_data_indices_update", async () => { + expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false); + }); + + test("config_data_indices_{add,get,remove}", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: entity("posts", { + title: text(), + content: text(), + }).toJSON(), + }); + + // add index on title + await tool(server, "config_data_indices_add", { + key: "", // auto generated + value: { + entity: "posts", + fields: ["title"], + }, + }); + + expect(app.toJSON().data.indices?.idx_posts_title).toEqual({ + entity: "posts", + fields: ["title"], + unique: false, + }); + + // delete + await tool(server, "config_data_indices_remove", { + key: "idx_posts_title", + }); + expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined(); + }); + + test("data_entity_*", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: entity("posts", { + title: text(), + content: text(), + }).toJSON(), + }); + await tool(server, "config_data_entities_add", { + key: "comments", + value: entity("comments", { + content: text(), + }).toJSON(), + }); + + // insert a few posts + for (let i = 0; i < 10; i++) { + await tool(server, "data_entity_insert", { + entity: "posts", + json: { + title: `Post ${i}`, + }, + }); + } + // insert a few comments + for (let i = 0; i < 5; i++) { + await tool(server, "data_entity_insert", { + entity: "comments", + json: { + content: `Comment ${i}`, + }, + }); + } + + const result = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 5, + }); + expect(result.data.length).toBe(5); + expect(result.meta.items).toBe(5); + expect(result.meta.total).toBe(10); + expect(result.data[0].title).toBe("Post 0"); + + { + // count + const result = await tool(server, "data_entity_fn_count", { + entity: "posts", + }); + expect(result.count).toBe(10); + } + + { + // exists + const res = await tool(server, "data_entity_fn_exists", { + entity: "posts", + json: { + id: result.data[0].id, + }, + }); + expect(res.exists).toBe(true); + + const res2 = await tool(server, "data_entity_fn_exists", { + entity: "posts", + json: { + id: "123", + }, + }); + expect(res2.exists).toBe(false); + } + + // update + await tool(server, "data_entity_update_one", { + entity: "posts", + id: result.data[0].id, + json: { + title: "Post 0 updated", + }, + }); + const result2 = await tool(server, "data_entity_read_one", { + entity: "posts", + id: result.data[0].id, + }); + expect(result2.data.title).toBe("Post 0 updated"); + + // delete the second post + await tool(server, "data_entity_delete_one", { + entity: "posts", + id: result.data[1].id, + }); + const result3 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 2, + }); + expect(result3.data.map((p) => p.id)).toEqual([1, 3]); + + // update many + await tool(server, "data_entity_update_many", { + entity: "posts", + update: { + title: "Post updated", + }, + where: { + title: { $isnull: 0 }, + }, + }); + const result4 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 10, + }); + expect(result4.data.length).toBe(9); + expect(result4.data.map((p) => p.title)).toEqual( + Array.from({ length: 9 }, () => "Post updated"), + ); + + // delete many + await tool(server, "data_entity_delete_many", { + entity: "posts", + json: { + title: { $isnull: 0 }, + }, + }); + const result5 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 10, + }); + expect(result5.data.length).toBe(0); + expect(result5.meta.items).toBe(0); + expect(result5.meta.total).toBe(0); + }); +}); diff --git a/app/__test__/app/mcp/mcp.media.test.ts b/app/__test__/app/mcp/mcp.media.test.ts new file mode 100644 index 0000000..ad5383e --- /dev/null +++ b/app/__test__/app/mcp/mcp.media.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { registries } from "index"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import type { McpServer } from "bknd/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +/** + * - [x] config_media_get + * - [x] config_media_update + * - [x] config_media_adapter_get + * - [x] config_media_adapter_update + */ +describe("mcp media", async () => { + let app: App; + let server: McpServer; + beforeEach(async () => { + registries.media.register("local", StorageLocalAdapter); + app = createApp({ + config: { + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + await app.getMcpClient().ping(); + server = app.mcp!; + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("config_media_{get,update}", async () => { + const result = await tool(server, "config_media_get", {}); + expect(result).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().media, + }); + + // partial + expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({ + type: "local", + config: { + path: "./", + }, + }); + + // update + await tool(server, "config_media_update", { + value: { + storage: { + body_max_size: 1024 * 1024 * 10, + }, + }, + return_config: true, + }); + expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10); + }); + + test("config_media_adapter_{get,update}", async () => { + const result = await tool(server, "config_media_adapter_get", {}); + expect(result).toEqual({ + secrets: false, + value: app.toJSON().media.adapter, + }); + + // update + await tool(server, "config_media_adapter_update", { + value: { + type: "local", + config: { + path: "./subdir", + }, + }, + }); + const adapter = app.toJSON().media.adapter as any; + expect(adapter.config.path).toBe("./subdir"); + expect(adapter.type).toBe("local"); + + // set to s3 + { + await tool(server, "config_media_adapter_update", { + value: { + type: "s3", + config: { + access_key: "123", + secret_access_key: "456", + url: "https://example.com/what", + }, + }, + }); + + const adapter = app.toJSON(true).media.adapter as any; + expect(adapter.type).toBe("s3"); + expect(adapter.config.url).toBe("https://example.com/what"); + } + }); +}); diff --git a/app/__test__/app/mcp/mcp.server.test.ts b/app/__test__/app/mcp/mcp.server.test.ts new file mode 100644 index 0000000..ce072b6 --- /dev/null +++ b/app/__test__/app/mcp/mcp.server.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import type { McpServer } from "bknd/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +/** + * - [x] config_server_get + * - [x] config_server_update + */ +describe("mcp system", async () => { + let app: App; + let server: McpServer; + beforeAll(async () => { + app = createApp({ + config: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + await app.getMcpClient().ping(); + server = app.mcp!; + }); + + const tool = createMcpToolCaller(); + + test("config_server_get", async () => { + const result = await tool(server, "config_server_get", {}); + expect(JSON.parse(JSON.stringify(result))).toEqual({ + path: "", + secrets: false, + partial: false, + value: JSON.parse(JSON.stringify(app.toJSON().server)), + }); + }); + + test("config_server_get2", async () => { + const result = await tool(server, "config_server_get", {}); + expect(JSON.parse(JSON.stringify(result))).toEqual({ + path: "", + secrets: false, + partial: false, + value: JSON.parse(JSON.stringify(app.toJSON().server)), + }); + }); + + test("config_server_update", async () => { + const original = JSON.parse(JSON.stringify(app.toJSON().server)); + const result = await tool(server, "config_server_update", { + value: { + cors: { + origin: "http://localhost", + }, + }, + return_config: true, + }); + + expect(JSON.parse(JSON.stringify(result))).toEqual({ + success: true, + module: "server", + config: { + ...original, + cors: { + ...original.cors, + origin: "http://localhost", + }, + }, + }); + expect(app.toJSON().server.cors.origin).toBe("http://localhost"); + }); +}); diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts new file mode 100644 index 0000000..de52198 --- /dev/null +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -0,0 +1,57 @@ +import { AppEvents } from "App"; +import { describe, test, expect, beforeAll, mock } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import type { McpServer } from "bknd/utils"; + +/** + * - [x] system_config + * - [x] system_build + * - [x] system_ping + * - [x] system_info + */ +describe("mcp system", async () => { + let app: App; + let server: McpServer; + beforeAll(async () => { + app = createApp({ + config: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + await app.getMcpClient().ping(); + server = app.mcp!; + }); + + const tool = createMcpToolCaller(); + + test("system_ping", async () => { + const result = await tool(server, "system_ping", {}); + expect(result).toEqual({ pong: true }); + }); + + test("system_info", async () => { + const result = await tool(server, "system_info", {}); + expect(Object.keys(result).length).toBeGreaterThan(0); + expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]); + }); + + test("system_build", async () => { + const called = mock(() => null); + + app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true }); + + const result = await tool(server, "system_build", {}); + expect(called).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + }); + + test("system_config", async () => { + const result = await tool(server, "system_config", {}); + expect(result).toEqual(app.toJSON()); + }); +}); diff --git a/app/__test__/app/plugins/sync-config.test.ts b/app/__test__/app/plugins/sync-config.test.ts new file mode 100644 index 0000000..ba299c9 --- /dev/null +++ b/app/__test__/app/plugins/sync-config.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test"; +import { createApp } from "core/test/utils"; +import { syncConfig } from "plugins/dev/sync-config.plugin"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); + +describe("syncConfig", () => { + it("should only sync if enabled", async () => { + const called = mock(() => null); + const app = createApp(); + await app.build(); + + await syncConfig({ + write: () => { + called(); + }, + enabled: false, + includeFirstBoot: false, + })(app).onBuilt?.(); + expect(called).not.toHaveBeenCalled(); + + await syncConfig({ + write: () => { + called(); + }, + enabled: false, + includeFirstBoot: true, + })(app).onBuilt?.(); + expect(called).not.toHaveBeenCalled(); + + await syncConfig({ + write: () => { + called(); + }, + enabled: true, + includeFirstBoot: true, + })(app).onBuilt?.(); + expect(called).toHaveBeenCalledTimes(1); + }); + + it("should respect secrets", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + }); + await app.build(); + + await syncConfig({ + write: async (config) => { + called(); + expect(config.auth.jwt.secret).toBe("test"); + }, + enabled: true, + includeSecrets: true, + includeFirstBoot: true, + })(app).onBuilt?.(); + + await syncConfig({ + write: async (config) => { + called(); + // it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON() + // but it's required to get the app running + expect(config.auth.jwt.secret).toBe(""); + }, + enabled: true, + includeSecrets: false, + includeFirstBoot: true, + })(app).onBuilt?.(); + expect(called).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts index be2da38..54ff7e2 100644 --- a/app/__test__/app/repro.spec.ts +++ b/app/__test__/app/repro.spec.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { registries } from "../../src"; import { createApp } from "core/test/utils"; import * as proto from "../../src/data/prototype"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); describe("repros", async () => { /** @@ -88,7 +92,7 @@ describe("repros", async () => { fns.relation(schema.product_likes).manyToOne(schema.users); }, ); - const app = createApp({ initialConfig: { data: schema.toJSON() } }); + const app = createApp({ config: { data: schema.toJSON() } }); await app.build(); const info = (await (await app.server.request("/api/data/info/products")).json()) as any; 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/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index c0e04ff..caa5566 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,9 +1,31 @@ import { describe, expect, test } from "bun:test"; -import { Guard } from "../../../src/auth/authorize/Guard"; +import { Guard, type GuardConfig } from "auth/authorize/Guard"; +import { Permission } from "auth/authorize/Permission"; +import { Role, type RoleSchema } from "auth/authorize/Role"; +import { objectTransform, s } from "bknd/utils"; + +function createGuard( + permissionNames: string[], + roles?: Record>, + 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", { + filterable: true, + }); + const write = new Permission("write"); + test("basic", async () => { - const guard = Guard.create( + const guard = createGuard( ["read", "write"], { admin: { @@ -16,14 +38,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("something")).toThrow(); + expect(() => guard.granted(new Permission("something"), {})).toThrow(); }); test("with default", async () => { - const guard = Guard.create( + const guard = createGuard( ["read", "write"], { admin: { @@ -37,26 +59,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, }, @@ -66,12 +88,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, @@ -79,7 +101,143 @@ 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(); + }); + + 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.filters(read, { role: "member" }).filter).toEqual({ + type: "member", + }); + + // get filter for guest + expect(guard.filters(read, {}).filter).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.filters(read, {}, { entity: "posts" }).filter).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.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/http/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts new file mode 100644 index 0000000..40e6493 --- /dev/null +++ b/app/__test__/auth/authorize/http/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.skip("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/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts new file mode 100644 index 0000000..411ad20 --- /dev/null +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -0,0 +1,543 @@ +import { describe, it, expect } from "bun:test"; +import { s } from "bknd/utils"; +import { Permission } from "auth/authorize/Permission"; +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, 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", () => { + 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); + }); +}); + +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({ + condition: { a: { $eq: 1 } }, + filter: { foo: "bar" }, + effect: "filter", + }), + ]), + ]); + const guard = new Guard([p], [r], { + enabled: true, + }); + 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.filters(p, {}, { a: 1 }).filter).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({ + condition: { a: { $eq: 1 } }, + filter: { foo: "bar" }, + effect: "filter", + }), + ]), + ], + true, + ); + const guard = new Guard([p], [r], { + enabled: true, + }); + + 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, the default role is applied + // 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", () => { + 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("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 }, + }, + // default effect is allow + }), + ], + // change default effect to deny if no condition is met + "deny", + ), + ]); + 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); + }); + + 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", () => { + 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(p.name, json); + expect(r2.toJSON()).toEqual(r.toJSON()); + }); +}); diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index 3d8b981..65d4433 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events"; -import { disableConsoleLog, enableConsoleLog } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); 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/__test__/core/object/query.spec.ts b/app/__test__/core/object/query.spec.ts new file mode 100644 index 0000000..80383c6 --- /dev/null +++ b/app/__test__/core/object/query.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { + makeValidator, + exp, + Expression, + isPrimitive, + type Primitive, +} from "../../../src/core/object/query/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(); + + // 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/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index 15428bf..d8ea29d 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -194,6 +194,182 @@ 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, + null, + ); + 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 with fallback parameter + const obj9 = { user: "@user.id", config: "@config.theme" }; + const variables9 = {}; // empty context + const result9 = utils.recursivelyReplacePlaceholders( + obj9, + /^@([a-z\.]+)$/, + variables9, + null, + ); + 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, + }, + }, + }); + }); }); describe("file", async () => { @@ -248,7 +424,7 @@ describe("Core Utils", async () => { expect(utils.getContentName(request)).toBe(name); }); - test.only("detectImageDimensions", async () => { + test("detectImageDimensions", async () => { // wrong // @ts-expect-error expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); @@ -264,15 +440,44 @@ 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", () => { - test.only("formats local time", () => { + test("formats local time", () => { expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); - console.log(utils.datetimeStringUTC(new Date())); + /*console.log(utils.datetimeStringUTC(new Date())); console.log(utils.datetimeStringUTC()); console.log(new Date()); - console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); + console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */ }); }); }); diff --git a/app/__test__/data/DataController.spec.ts b/app/__test__/data/DataController.spec.ts index ca4905d..9ca90d8 100644 --- a/app/__test__/data/DataController.spec.ts +++ b/app/__test__/data/DataController.spec.ts @@ -5,7 +5,8 @@ import { parse } from "core/utils/schema"; import { DataController } from "../../src/data/api/DataController"; import { dataConfigSchema } from "../../src/data/data-schema"; -import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; +import { getDummyConnection } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult"; import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult"; import { Entity, EntityManager, type EntityData } from "data/entities"; @@ -13,7 +14,7 @@ import { TextField } from "data/fields"; import { ManyToOneRelation } from "data/relations"; const { dummyConnection, afterAllCleanup } = getDummyConnection(); -beforeAll(() => disableConsoleLog(["log", "warn"])); +beforeAll(() => disableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); const dataConfig = parse(dataConfigSchema, {}); 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/__test__/data/specs/Entity.spec.ts b/app/__test__/data/specs/Entity.spec.ts index 064db2d..220b688 100644 --- a/app/__test__/data/specs/Entity.spec.ts +++ b/app/__test__/data/specs/Entity.spec.ts @@ -47,8 +47,4 @@ describe("[data] Entity", async () => { entity.addField(field); expect(entity.getField("new_field")).toBe(field); }); - - test.only("types", async () => { - console.log(entity.toTypes()); - }); }); diff --git a/app/__test__/data/specs/JoinBuilder.spec.ts b/app/__test__/data/specs/JoinBuilder.spec.ts index 16f8d30..4ed1255 100644 --- a/app/__test__/data/specs/JoinBuilder.spec.ts +++ b/app/__test__/data/specs/JoinBuilder.spec.ts @@ -1,12 +1,15 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { Entity, EntityManager } from "data/entities"; import { ManyToOneRelation } from "data/relations"; import { TextField } from "data/fields"; import { JoinBuilder } from "data/entities/query/JoinBuilder"; import { getDummyConnection } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterAll(afterAllCleanup); +afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); describe("[data] JoinBuilder", async () => { test("missing relation", async () => { diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 45bbb28..c4014c8 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -9,13 +9,14 @@ import { } from "data/relations"; import { NumberField, TextField } from "data/fields"; import * as proto from "data/prototype"; -import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper"; +import { getDummyConnection } from "../../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { MutatorEvents } from "data/events"; const { dummyConnection, afterAllCleanup } = getDummyConnection(); afterAll(afterAllCleanup); -beforeAll(() => disableConsoleLog(["log", "warn"])); +beforeAll(() => disableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); describe("[data] Mutator (base)", async () => { diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index 35c4ec5..d9b2dc2 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import type { Kysely, Transaction } from "kysely"; import { TextField } from "data/fields"; import { em as $em, entity as $entity, text as $text } from "data/prototype"; @@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities"; import { ManyToOneRelation } from "data/relations"; import { RepositoryEvents } from "data/events"; import { getDummyConnection } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; type E = Kysely | Transaction; const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterAll(afterAllCleanup); +beforeAll(() => disableConsoleLog()); +afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); async function sleep(ms: number) { return new Promise((resolve) => { 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/__test__/data/specs/WithBuilder.spec.ts b/app/__test__/data/specs/WithBuilder.spec.ts index 31cfd96..b778b0a 100644 --- a/app/__test__/data/specs/WithBuilder.spec.ts +++ b/app/__test__/data/specs/WithBuilder.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { Entity, EntityManager } from "data/entities"; import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations"; import { TextField } from "data/fields"; @@ -6,6 +6,10 @@ import * as proto from "data/prototype"; import { WithBuilder } from "data/entities/query/WithBuilder"; import { schemaToEm } from "../../helper"; import { getDummyConnection } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); const { dummyConnection } = getDummyConnection(); diff --git a/app/__test__/data/specs/fields/FieldIndex.spec.ts b/app/__test__/data/specs/fields/FieldIndex.spec.ts index ab94214..fbcfabf 100644 --- a/app/__test__/data/specs/fields/FieldIndex.spec.ts +++ b/app/__test__/data/specs/fields/FieldIndex.spec.ts @@ -23,11 +23,4 @@ describe("FieldIndex", async () => { expect(index.name).toEqual("idx_test_name"); expect(index.unique).toEqual(false); }); - - test("it fails on non-unique", async () => { - const field = new TestField("name", { required: false }); - - expect(() => new EntityIndex(entity, [field], true)).toThrowError(); - expect(() => new EntityIndex(entity, [field])).toBeDefined(); - }); }); 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/__test__/data/specs/relations/EntityRelation.spec.ts b/app/__test__/data/specs/relations/EntityRelation.spec.ts index 489a5be..12a8325 100644 --- a/app/__test__/data/specs/relations/EntityRelation.spec.ts +++ b/app/__test__/data/specs/relations/EntityRelation.spec.ts @@ -4,8 +4,10 @@ import { type BaseRelationConfig, EntityRelation, EntityRelationAnchor, + ManyToManyRelation, RelationTypes, } from "data/relations"; +import * as proto from "data/prototype"; class TestEntityRelation extends EntityRelation { constructor(config?: BaseRelationConfig) { @@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => { const relation2 = new TestEntityRelation({ required: true }); expect(relation2.required).toBe(true); }); + + it("correctly produces the relation name", async () => { + const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations")); + expect(relation.getName()).not.toContain(","); + expect(relation.getName()).toBe("mn_apps_organizations"); + + const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), { + connectionTableMappedName: "appOrganizations", + }); + expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations"); + }); }); diff --git a/app/__test__/debug/jsonv-resolution.test.ts b/app/__test__/debug/jsonv-resolution.test.ts new file mode 100644 index 0000000..64b60e4 --- /dev/null +++ b/app/__test__/debug/jsonv-resolution.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "bun:test"; +import * as sDirect from "jsonv-ts"; +import { s as sFromBknd } from "bknd/utils"; + +describe("jsonv-ts resolution", () => { + it("should resolve to a single instance", () => { + const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect); + // If this fails, two instances are being loaded via different specifiers/paths + expect(sameNamespace).toBe(true); + }); + + it("should resolve specifiers to a single package path", async () => { + const base = await import.meta.resolve("jsonv-ts"); + const hono = await import.meta.resolve("jsonv-ts/hono"); + const mcp = await import.meta.resolve("jsonv-ts/mcp"); + expect(typeof base).toBe("string"); + expect(typeof hono).toBe("string"); + expect(typeof mcp).toBe("string"); + // They can be different files (subpath exports), but they should share the same package root + const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length); + expect(pkgRoot(base)).toBe(pkgRoot(hono)); + expect(pkgRoot(base)).toBe(pkgRoot(mcp)); + }); +}); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index 1760d32..aaf88d2 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter"; import type { em as protoEm } from "../src/data/prototype"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { slugify } from "core/utils/strings"; +import { slugify } from "bknd/utils"; import { type Connection, SqliteLocalConnection } from "data/connection"; import { EntityManager } from "data/entities/EntityManager"; @@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() { return { url: "http://127.0.0.1:8080" }; } -type ConsoleSeverity = "debug" | "log" | "warn" | "error"; -const _oldConsoles = { - debug: console.debug, - log: console.log, - warn: console.warn, - error: console.error, -}; - -export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) { - severities.forEach((severity) => { - console[severity] = () => null; - }); -} - -export function enableConsoleLog() { - Object.entries(_oldConsoles).forEach(([severity, fn]) => { - console[severity as ConsoleSeverity] = fn; - }); -} - export function compileQb(qb: SelectQueryBuilder) { const { sql, parameters } = qb.compile(); return { sql, parameters }; @@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder) { export function prettyPrintQb(qb: SelectQueryBuilder) { const { sql, parameters } = qb.compile(); - console.log("$", sqlFormat(sql), "\n[params]", parameters); + console.info("$", sqlFormat(sql), "\n[params]", parameters); } export function schemaToEm(s: ReturnType, conn?: Connection): EntityManager { diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index 298ad31..477951f 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,12 +1,9 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { App, createApp } from "../../src"; -import type { AuthResponse } from "../../src/auth"; -import { auth } from "../../src/auth/middlewares"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { App, createApp, type AuthResponse } from "../../src"; +import { auth } from "../../src/modules/middlewares"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; -import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; - -const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterEach(afterAllCleanup); +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { getDummyConnection } from "../helper"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -66,9 +63,10 @@ const configs = { }; function createAuthApp() { + const { dummyConnection } = getDummyConnection(); const app = createApp({ connection: dummyConnection, - initialConfig: { + config: { auth: configs.auth, }, }); @@ -151,8 +149,8 @@ describe("integration auth", () => { const { data: users } = await app.em.repository("users").findMany(); expect(users.length).toBe(2); - expect(users[0].email).toBe(configs.users.normal.email); - expect(users[1].email).toBe(configs.users.admin.email); + expect(users[0]?.email).toBe(configs.users.normal.email); + expect(users[1]?.email).toBe(configs.users.admin.email); }); it("should log you in with API", async () => { @@ -223,7 +221,7 @@ describe("integration auth", () => { app.server.get("/get", auth(), async (c) => { return c.json({ - user: c.get("auth").user ?? null, + user: c.get("auth")?.user ?? null, }); }); app.server.get("/wait", auth(), async (c) => { @@ -242,7 +240,7 @@ describe("integration auth", () => { { await new Promise((r) => setTimeout(r, 10)); const res = await app.server.request("/get"); - const data = await res.json(); + const data = (await res.json()) as any; expect(data.user).toBe(null); expect(await $fns.me()).toEqual({ user: null as any }); } diff --git a/app/__test__/integration/config.integration.test.ts b/app/__test__/integration/config.integration.test.ts index 52c7df2..8e8ba4e 100644 --- a/app/__test__/integration/config.integration.test.ts +++ b/app/__test__/integration/config.integration.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { createApp } from "core/test/utils"; import { Api } from "../../src/Api"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("integration config", () => { it("should create an entity", async () => { diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 7c9ae9f..bf62599 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -6,17 +6,20 @@ import { createApp } from "core/test/utils"; import { mergeObject, randomString } from "../../src/core/utils"; import type { TAppMediaConfig } from "../../src/media/media-schema"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; -import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; +import { assetsPath, assetsTmpPath } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(() => { + //disableConsoleLog(); registries.media.register("local", StorageLocalAdapter); }); +afterAll(enableConsoleLog); const path = `${assetsPath}/image.png`; async function makeApp(mediaOverride: Partial = {}) { const app = createApp({ - initialConfig: { + config: { media: mergeObject( { enabled: true, @@ -40,9 +43,6 @@ function makeName(ext: string) { return randomString(10) + "." + ext; } -beforeAll(disableConsoleLog); -afterAll(enableConsoleLog); - describe("MediaController", () => { test("accepts direct", async () => { const app = await makeApp(); @@ -94,4 +94,38 @@ describe("MediaController", () => { expect(res.status).toBe(413); expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false); }); + + test("audio files", async () => { + const app = await makeApp(); + const file = Bun.file(`${assetsPath}/test.mp3`); + const name = makeName("mp3"); + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: file, + }); + const result = (await res.json()) as any; + expect(result.data.mime_type).toStartWith("audio/mpeg"); + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); + + test("text files", async () => { + const app = await makeApp(); + const file = Bun.file(`${assetsPath}/test.txt`); + const name = makeName("txt"); + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: file, + }); + const result = (await res.json()) as any; + expect(result.data.mime_type).toStartWith("text/plain"); + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); }); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index dd13f7c..1435dd6 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -71,6 +71,8 @@ describe("media/mime-types", () => { ["application/zip", "zip"], ["text/tab-separated-values", "tsv"], ["application/zip", "zip"], + ["application/pdf", "pdf"], + ["audio/mpeg", "mp3"], ] as const; for (const [mime, ext] of tests) { @@ -88,6 +90,9 @@ describe("media/mime-types", () => { ["image.jpeg", "jpeg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"], + ["file.pdf", "pdf"], + ["file.mp3", "mp3"], + ["robots.txt", "txt"], ] as const; for (const [filename, ext] of tests) { @@ -102,4 +107,36 @@ describe("media/mime-types", () => { const [, ext] = getRandomizedFilename(file).split("."); expect(ext).toBe("jpg"); }); + + test("getRandomizedFilename with body", async () => { + // should keep "pdf" + const [, ext] = getRandomizedFilename( + new File([""], "file.pdf", { type: "application/pdf" }), + ).split("."); + expect(ext).toBe("pdf"); + + { + // no ext, should use "pdf" only for known formats + const [, ext] = getRandomizedFilename( + new File([""], "file", { type: "application/pdf" }), + ).split("."); + expect(ext).toBe("pdf"); + } + + { + // wrong ext, should keep the wrong one + const [, ext] = getRandomizedFilename( + new File([""], "file.what", { type: "application/pdf" }), + ).split("."); + expect(ext).toBe("what"); + } + + { + // txt + const [, ext] = getRandomizedFilename( + new File([""], "file.txt", { type: "text/plain" }), + ).split("."); + expect(ext).toBe("txt"); + } + }); }); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index e523fbc..89872de 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -3,11 +3,14 @@ import { createApp } from "core/test/utils"; import { AuthController } from "../../src/auth/api/AuthController"; import { em, entity, make, text } from "data/prototype"; import { AppAuth, type ModuleBuildContext } from "modules"; -import { disableConsoleLog, enableConsoleLog } from "../helper"; import { makeCtx, moduleTestSuite } from "./module-test-suite"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("AppAuth", () => { - test.only("...", () => { + test.skip("...", () => { const auth = new AppAuth({}); console.log(auth.toJSON()); console.log(auth.config); @@ -147,7 +150,7 @@ describe("AppAuth", () => { test("registers auth middleware for bknd routes only", async () => { const app = createApp({ - initialConfig: { + config: { auth: { enabled: true, jwt: { @@ -177,7 +180,7 @@ describe("AppAuth", () => { test("should allow additional user fields", async () => { const app = createApp({ - initialConfig: { + config: { auth: { entity_name: "users", enabled: true, @@ -201,7 +204,7 @@ describe("AppAuth", () => { test("ensure user field configs is always correct", async () => { const app = createApp({ - initialConfig: { + config: { auth: { enabled: true, }, diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index d09041b..fb5464a 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia"; import { moduleTestSuite } from "./module-test-suite"; describe("AppMedia", () => { - test.only("...", () => { + test.skip("...", () => { const media = new AppMedia(); console.log(media.toJSON()); }); @@ -18,7 +18,7 @@ describe("AppMedia", () => { registries.media.register("local", StorageLocalAdapter); const app = createApp({ - initialConfig: { + config: { media: { entity_name: "media", enabled: true, diff --git a/app/__test__/modules/DbModuleManager.spec.ts b/app/__test__/modules/DbModuleManager.spec.ts new file mode 100644 index 0000000..b85ebbf --- /dev/null +++ b/app/__test__/modules/DbModuleManager.spec.ts @@ -0,0 +1,76 @@ +import { it, expect, describe } from "bun:test"; +import { DbModuleManager } from "modules/db/DbModuleManager"; +import { getDummyConnection } from "../helper"; +import { TABLE_NAME } from "modules/db/migrations"; + +describe("DbModuleManager", () => { + it("should extract secrets", async () => { + const { dummyConnection } = getDummyConnection(); + const m = new DbModuleManager(dummyConnection, { + initial: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + }); + await m.build(); + expect(m.toJSON(true).auth.jwt.secret).toBe("test"); + await m.save(); + }); + + it("should work with initial secrets", async () => { + const { dummyConnection } = getDummyConnection(); + const db = dummyConnection.kysely; + const m = new DbModuleManager(dummyConnection, { + initial: { + auth: { + enabled: true, + jwt: { + secret: "", + }, + }, + }, + secrets: { + "auth.jwt.secret": "test", + }, + }); + await m.build(); + expect(m.toJSON(true).auth.jwt.secret).toBe("test"); + + const getSecrets = () => + db + .selectFrom(TABLE_NAME) + .selectAll() + .where("type", "=", "secrets") + .executeTakeFirst() + .then((r) => r?.json); + + expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" }); + + // also after rebuild + await m.build(); + await m.save(); + expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" }); + + // and ignore if already present + const m2 = new DbModuleManager(dummyConnection, { + initial: { + auth: { + enabled: true, + jwt: { + secret: "", + }, + }, + }, + secrets: { + "auth.jwt.secret": "something completely different", + }, + }); + await m2.build(); + await m2.save(); + expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" }); + }); +}); diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index 9c24de0..de5c889 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -1,14 +1,19 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { Module } from "modules/Module"; -import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager"; -import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations"; +import { getDefaultConfig } from "modules/ModuleManager"; +import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager"; + +import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations"; import { getDummyConnection } from "../helper"; import { s, stripMark } from "core/utils/schema"; import { Connection } from "data/connection/Connection"; import { entity, text } from "data/prototype"; +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + describe("ModuleManager", async () => { test("s1: no config, no build", async () => { const { dummyConnection } = getDummyConnection(); @@ -133,7 +138,7 @@ describe("ModuleManager", async () => { const db = c2.dummyConnection.kysely; const mm2 = new ModuleManager(c2.dummyConnection, { - initial: { version: version - 1, ...json }, + initial: { version: version - 1, ...json } as any, }); await mm2.syncConfigTable(); await db diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index 4bd83ab..1266746 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -1,14 +1,22 @@ -import { describe, expect, test } from "bun:test"; -import { type InitialModuleConfigs, createApp } from "../../../src"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { App, type InitialModuleConfigs, createApp } from "/"; import { type Kysely, sql } from "kysely"; import { getDummyConnection } from "../../helper"; 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 { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(enableConsoleLog); // app expects migratable config to be present in database -async function createVersionedApp(config: InitialModuleConfigs | any) { +async function createVersionedApp( + config: InitialModuleConfigs | any, + opts?: { beforeCreateApp?: (db: Kysely) => Promise }, +) { const { dummyConnection } = getDummyConnection(); if (!("version" in config)) throw new Error("config must have a version"); @@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) { }) .execute(); + if (opts?.beforeCreateApp) { + await opts.beforeCreateApp(db); + } + const app = createApp({ connection: dummyConnection, }); @@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) { return app; } +async function getRawConfig( + app: App, + opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] }, +) { + const db = app.em.connection.kysely; + return await db + .selectFrom("__bknd") + .selectAll() + .$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version)) + .$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types)) + .execute(); +} + describe("Migrations", () => { /** * updated auth strategies to have "enabled" prop @@ -78,4 +103,30 @@ describe("Migrations", () => { // @ts-expect-error expect(app.toJSON(true).server.admin).toBeUndefined(); }); + + test("migration from 9 to 10", async () => { + expect(v9.version).toBe(9); + + const app = await createVersionedApp(v9); + + expect(app.version()).toBeGreaterThan(9); + // @ts-expect-error + expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe( + "^^s3.secret_access_key^^", + ); + const [config, secrets] = (await getRawConfig(app, { + version: 10, + types: ["config", "secrets"], + })) as any; + + expect(config.json.auth.jwt.secret).toBe(""); + expect(config.json.media.adapter.config.access_key).toBe(""); + expect(config.json.media.adapter.config.secret_access_key).toBe(""); + + expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^"); + expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^"); + expect(secrets.json["media.adapter.config.secret_access_key"]).toBe( + "^^s3.secret_access_key^^", + ); + }); }); diff --git a/app/__test__/modules/migrations/samples/v9.json b/app/__test__/modules/migrations/samples/v9.json new file mode 100644 index 0000000..b6fc2a2 --- /dev/null +++ b/app/__test__/modules/migrations/samples/v9.json @@ -0,0 +1,612 @@ +{ + "version": 9, + "server": { + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "allow_credentials": true + }, + "mcp": { "enabled": false, "path": "/api/system/mcp" } + }, + "data": { + "basepath": "/api/data", + "default_primary_format": "integer", + "entities": { + "media": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "path": { "type": "text", "config": { "required": true } }, + "folder": { + "type": "boolean", + "config": { + "default_value": false, + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "mime_type": { "type": "text", "config": { "required": false } }, + "size": { "type": "number", "config": { "required": false } }, + "scope": { + "type": "text", + "config": { + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "etag": { "type": "text", "config": { "required": false } }, + "modified_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "reference": { "type": "text", "config": { "required": false } }, + "entity_id": { "type": "number", "config": { "required": false } }, + "metadata": { "type": "json", "config": { "required": false } } + }, + "config": { "sort_field": "id", "sort_dir": "asc" } + }, + "users": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "email": { "type": "text", "config": { "required": true } }, + "strategy": { + "type": "enum", + "config": { + "options": { "type": "strings", "values": ["password"] }, + "required": true, + "hidden": ["update", "form"], + "fillable": ["create"] + } + }, + "strategy_value": { + "type": "text", + "config": { + "fillable": ["create"], + "hidden": ["read", "table", "update", "form"], + "required": true + } + }, + "role": { + "type": "enum", + "config": { + "options": { "type": "strings", "values": ["admin", "guest"] }, + "required": false + } + }, + "age": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["18-24", "25-34", "35-44", "45-64", "65+"] + }, + "required": false + } + }, + "height": { "type": "number", "config": { "required": false } }, + "gender": { + "type": "enum", + "config": { + "options": { "type": "strings", "values": ["male", "female"] }, + "required": false + } + } + }, + "config": { "sort_field": "id", "sort_dir": "asc" } + }, + "avatars": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "identifier": { "type": "text", "config": { "required": false } }, + "payload": { + "type": "json", + "config": { "required": false, "hidden": ["table"] } + }, + "created_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "started_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "completed_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "input": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "avatars" + } + }, + "output": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "avatars" + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": false, + "reference": "users", + "target": "users", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + } + }, + "config": { "sort_field": "id", "sort_dir": "desc" } + }, + "tryons": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "created_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "completed_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "avatars_id": { + "type": "relation", + "config": { + "label": "Avatars", + "required": false, + "reference": "avatars", + "target": "avatars", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": false, + "reference": "users", + "target": "users", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + }, + "output": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "tryons", + "max_items": 1 + } + }, + "products_id": { + "type": "relation", + "config": { + "label": "Products", + "required": false, + "reference": "products", + "target": "products", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + }, + "payload": { + "type": "json", + "config": { "required": false, "hidden": ["table"] } + } + }, + "config": { "sort_field": "id", "sort_dir": "desc" } + }, + "products": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "enabled": { "type": "boolean", "config": { "required": false } }, + "title": { "type": "text", "config": { "required": false } }, + "url": { "type": "text", "config": { "required": false } }, + "image": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "products", + "max_items": 1 + } + }, + "created_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "sites_id": { + "type": "relation", + "config": { + "label": "Sites", + "required": false, + "reference": "sites", + "target": "sites", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + }, + "garment_type": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["auto", "tops", "bottoms", "one-pieces"] + }, + "required": false + } + } + }, + "config": { "sort_field": "id", "sort_dir": "desc" } + }, + "sites": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "origin": { + "type": "text", + "config": { + "pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$", + "required": true + } + }, + "name": { "type": "text", "config": { "required": false } }, + "active": { "type": "boolean", "config": { "required": false } }, + "logo": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "sites", + "max_items": 1 + } + }, + "instructions": { + "type": "text", + "config": { + "html_config": { + "element": "textarea", + "props": { "rows": "2" } + }, + "required": false, + "hidden": ["table"] + } + } + }, + "config": { "sort_field": "id", "sort_dir": "desc" } + }, + "sessions": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { "format": "uuid", "fillable": false, "required": false } + }, + "created_at": { + "type": "date", + "config": { "type": "datetime", "required": true } + }, + "claimed_at": { + "type": "date", + "config": { "type": "datetime", "required": false } + }, + "url": { "type": "text", "config": { "required": false } }, + "sites_id": { + "type": "relation", + "config": { + "label": "Sites", + "required": false, + "reference": "sites", + "target": "sites", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": false, + "reference": "users", + "target": "users", + "target_field": "id", + "target_field_type": "integer", + "on_delete": "set null" + } + } + }, + "config": { "sort_field": "id", "sort_dir": "desc" } + } + }, + "relations": { + "poly_avatars_media_input": { + "type": "poly", + "source": "avatars", + "target": "media", + "config": { "mappedBy": "input" } + }, + "poly_avatars_media_output": { + "type": "poly", + "source": "avatars", + "target": "media", + "config": { "mappedBy": "output" } + }, + "n1_avatars_users": { + "type": "n:1", + "source": "avatars", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + }, + "n1_tryons_avatars": { + "type": "n:1", + "source": "tryons", + "target": "avatars", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + }, + "n1_tryons_users": { + "type": "n:1", + "source": "tryons", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + }, + "poly_tryons_media_output": { + "type": "poly", + "source": "tryons", + "target": "media", + "config": { "mappedBy": "output", "targetCardinality": 1 } + }, + "poly_products_media_image": { + "type": "poly", + "source": "products", + "target": "media", + "config": { "mappedBy": "image", "targetCardinality": 1 } + }, + "n1_tryons_products": { + "type": "n:1", + "source": "tryons", + "target": "products", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + }, + "poly_sites_media_logo": { + "type": "poly", + "source": "sites", + "target": "media", + "config": { "mappedBy": "logo", "targetCardinality": 1 } + }, + "n1_sessions_sites": { + "type": "n:1", + "source": "sessions", + "target": "sites", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + }, + "n1_sessions_users": { + "type": "n:1", + "source": "sessions", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + }, + "n1_products_sites": { + "type": "n:1", + "source": "products", + "target": "sites", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": false, + "with_limit": 5 + } + } + }, + "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 + }, + "idx_unique_users_email": { + "entity": "users", + "fields": ["email"], + "unique": true + }, + "idx_users_strategy": { + "entity": "users", + "fields": ["strategy"], + "unique": false + }, + "idx_users_strategy_value": { + "entity": "users", + "fields": ["strategy_value"], + "unique": false + }, + "idx_sites_origin_active": { + "entity": "sites", + "fields": ["origin", "active"], + "unique": false + }, + "idx_sites_active": { + "entity": "sites", + "fields": ["active"], + "unique": false + }, + "idx_products_url": { + "entity": "products", + "fields": ["url"], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "^^jwt.secret^^", + "alg": "HS256", + "expires": 999999999, + "issuer": "issuer", + "fields": ["id", "email", "role"] + }, + "cookie": { + "path": "/", + "sameSite": "none", + "secure": true, + "httpOnly": true, + "expires": 604800, + "partitioned": false, + "renew": true, + "pathSuccess": "/admin", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { "hashing": "sha256" } + } + }, + "guard": { "enabled": false }, + "roles": { + "admin": { "implicit_allow": true }, + "guest": { "is_default": true } + } + }, + "media": { + "enabled": true, + "basepath": "/api/media", + "entity_name": "media", + "storage": { "body_max_size": 0 }, + "adapter": { + "type": "s3", + "config": { + "access_key": "^^s3.access_key^^", + "secret_access_key": "^^s3.secret_access_key^^", + "url": "https://1234.r2.cloudflarestorage.com/bucket-name" + } + } + }, + "flows": { "basepath": "/api/flows", "flows": {} } +} diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 99dfcf5..1f19f4e 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; import { Guard } from "auth/authorize/Guard"; -import { DebugLogger } from "core/utils/DebugLogger"; import { EventManager } from "core/events"; import { EntityManager } from "data/entities/EntityManager"; import { Module, type ModuleBuildContext } from "modules/Module"; import { getDummyConnection } from "../helper"; import { ModuleHelper } from "modules/ModuleHelper"; +import { DebugLogger, McpServer } from "bknd/utils"; export function makeCtx(overrides?: Partial): ModuleBuildContext { const { dummyConnection } = getDummyConnection(); @@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial): ModuleBuildCon guard: new Guard(), flags: Module.ctx_flags, logger: new DebugLogger(false), + mcp: new McpServer(), ...overrides, }; return { diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts index c1331f2..331d6eb 100644 --- a/app/__test__/ui/json-form.spec.ts +++ b/app/__test__/ui/json-form.spec.ts @@ -102,7 +102,9 @@ describe("json form", () => { ] satisfies [string, Exclude, boolean][]; for (const [pointer, schema, output] of examples) { - expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output); + expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe( + output, + ); } }); diff --git a/app/build.cli.ts b/app/build.cli.ts index e874813..fee5373 100644 --- a/app/build.cli.ts +++ b/app/build.cli.ts @@ -1,6 +1,29 @@ import pkg from "./package.json" with { type: "json" }; import c from "picocolors"; -import { formatNumber } from "core/utils"; +import { formatNumber } from "bknd/utils"; +import * as esbuild from "esbuild"; + +const deps = Object.keys(pkg.dependencies); +const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps]; + +if (process.env.DEBUG) { + const result = await esbuild.build({ + entryPoints: ["./src/cli/index.ts"], + outdir: "./dist/cli", + platform: "node", + minify: true, + format: "esm", + metafile: true, + bundle: true, + external, + define: { + __isDev: "0", + __version: JSON.stringify(pkg.version), + }, + }); + await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2)); + process.exit(0); +} const result = await Bun.build({ entrypoints: ["./src/cli/index.ts"], @@ -8,6 +31,7 @@ const result = await Bun.build({ outdir: "./dist/cli", env: "PUBLIC_*", minify: true, + external, define: { __isDev: "0", __version: JSON.stringify(pkg.version), diff --git a/app/build.ts b/app/build.ts index f149231..4a30da9 100644 --- a/app/build.ts +++ b/app/build.ts @@ -61,14 +61,19 @@ function delayTypes() { watcher_timeout = setTimeout(buildTypes, 1000); } +const dependencies = Object.keys(pkg.dependencies); + // collection of always-external packages const external = [ + ...dependencies, "bun:test", "node:test", "node:assert/strict", "@libsql/client", "bknd", /^bknd\/.*/, + "jsonv-ts", + /^jsonv-ts\/.*/, ] as const; /** @@ -80,14 +85,19 @@ 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, + target: "esnext", platform: "browser", format: ["esm"], splitting: false, - treeshake: true, loader: { ".svg": "dataurl", }, @@ -243,8 +253,12 @@ async function buildAdapters() { // base adapter handles tsup.build({ ...baseConfig(""), + target: "esnext", + platform: "neutral", entry: ["src/adapter/index.ts"], outDir: "dist/adapter", + // only way to keep @vite-ignore comments + minify: false, }), // specific adatpers @@ -256,7 +270,20 @@ async function buildAdapters() { ), tsup.build(baseConfig("astro")), tsup.build(baseConfig("aws")), - tsup.build(baseConfig("cloudflare")), + tsup.build( + baseConfig("cloudflare", { + external: ["wrangler", "node:process"], + }), + ), + tsup.build( + baseConfig("cloudflare/proxy", { + target: "esnext", + entry: ["src/adapter/cloudflare/proxy.ts"], + outDir: "dist/adapter/cloudflare", + metafile: false, + external: [/bknd/, "wrangler", "node:process"], + }), + ), tsup.build({ ...baseConfig("vite"), diff --git a/app/bunfig.toml b/app/bunfig.toml index 6f4fe9a..c39b588 100644 --- a/app/bunfig.toml +++ b/app/bunfig.toml @@ -2,4 +2,5 @@ #registry = "http://localhost:4873" [test] -coverageSkipTestFiles = true \ No newline at end of file +coverageSkipTestFiles = true +console.depth = 10 \ No newline at end of file diff --git a/app/e2e/adapters.ts b/app/e2e/adapters.ts index 9ca8154..0dd009b 100644 --- a/app/e2e/adapters.ts +++ b/app/e2e/adapters.ts @@ -17,7 +17,7 @@ async function run( }); // Read from stdout - const reader = proc.stdout.getReader(); + const reader = (proc.stdout as ReadableStream).getReader(); const decoder = new TextDecoder(); // Function to read chunks @@ -30,7 +30,7 @@ async function run( const text = decoder.decode(value); if (!resolveCalled) { - console.log(c.dim(text.replace(/\n$/, ""))); + console.info(c.dim(text.replace(/\n$/, ""))); } onChunk( text, @@ -189,21 +189,21 @@ const adapters = { async function testAdapter(name: keyof typeof adapters) { const config = adapters[name]; - console.log("adapter", c.cyan(name)); + console.info("adapter", c.cyan(name)); await config.clean(); const { proc, data } = await config.start(); - console.log("proc:", proc.pid, "data:", c.cyan(data)); + console.info("proc:", proc.pid, "data:", c.cyan(data)); //proc.kill();process.exit(0); const add_env = "env" in config && config.env ? config.env : ""; await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`; - console.log("DONE!"); + console.info("DONE!"); while (!proc.killed) { proc.kill("SIGINT"); await Bun.sleep(250); - console.log("Waiting for process to exit..."); + console.info("Waiting for process to exit..."); } } diff --git a/app/internal/docs.build-assets.ts b/app/internal/docs.build-assets.ts new file mode 100644 index 0000000..2bedbee --- /dev/null +++ b/app/internal/docs.build-assets.ts @@ -0,0 +1,39 @@ +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: "/mcp2", + }, + }, + auth: { + enabled: true, + }, + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + }, + }); + await app.build(); + await app.getMcpClient().ping(); + + const { tools, resources } = app.mcp!.toJSON(); + await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2)); + + console.info("MCP documentation generated."); +} + +void generate(); diff --git a/app/package.json b/app/package.json index f4d518e..aea48bc 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.16.0", + "version": "0.19.0", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -13,9 +13,9 @@ "bugs": { "url": "https://github.com/bknd-io/bknd/issues" }, - "packageManager": "bun@1.2.19", + "packageManager": "bun@1.2.22", "engines": { - "node": ">=22" + "node": ">=22.13" }, "scripts": { "dev": "BKND_CLI_LOG_LEVEL=debug vite", @@ -30,7 +30,7 @@ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "updater": "bun x npm-check-updates -ui", "cli": "LOCAL=1 bun src/cli/index.ts", - "prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./", + "prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./", "postpublish": "rm -f README.md", "test": "ALL_TESTS=1 bun test --bail", "test:all": "bun run test && bun run test:node", @@ -40,10 +40,11 @@ "test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:vitest:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "test:e2e:adapters": "bun run e2e/adapters.ts", - "test:e2e:ui": "playwright test --ui", - "test:e2e:debug": "playwright test --debug", - "test:e2e:report": "playwright show-report" + "test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts", + "test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui", + "test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug", + "test:e2e:report": "VITE_DB_URL=:memory: playwright show-report", + "docs:build-assets": "bun internal/docs.build-assets.ts" }, "license": "FSL-1.1-MIT", "dependencies": { @@ -65,20 +66,23 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "kysely": "^0.27.6", + "jsonv-ts": "0.9.1", + "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "radix-ui": "^1.1.3", + "picocolors": "^1.1.1", "swr": "^2.3.3" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", - "@cloudflare/vitest-pool-workers": "^0.8.38", + "@clack/prompts": "^0.11.0", + "@cloudflare/vitest-pool-workers": "^0.9.3", "@cloudflare/workers-types": "^4.20250606.0", "@dagrejs/dagre": "^1.1.4", - "@hono/vite-dev-server": "^0.19.1", + "@hono/vite-dev-server": "^0.21.0", "@hookform/resolvers": "^4.1.3", "@libsql/client": "^0.15.9", "@mantine/modals": "^7.17.1", @@ -101,13 +105,11 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.3.2", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", "open": "^10.1.0", "openapi-types": "^12.1.3", - "picocolors": "^1.1.1", "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", @@ -129,7 +131,9 @@ "vite-plugin-circular-dependency": "^0.5.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9", - "wouter": "^3.6.0" + "wouter": "^3.6.0", + "wrangler": "^4.37.1", + "miniflare": "^4.20250913.0" }, "optionalDependencies": { "@hono/node-server": "^1.14.3" @@ -177,6 +181,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": { @@ -196,6 +205,11 @@ "import": "./dist/adapter/cloudflare/index.js", "require": "./dist/adapter/cloudflare/index.js" }, + "./adapter/cloudflare/proxy": { + "types": "./dist/types/adapter/cloudflare/proxy.d.ts", + "import": "./dist/adapter/cloudflare/proxy.js", + "require": "./dist/adapter/cloudflare/proxy.js" + }, "./adapter": { "types": "./dist/types/adapter/index.d.ts", "import": "./dist/adapter/index.js" diff --git a/app/src/Api.ts b/app/src/Api.ts index cce9156..fd4394a 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi"; import { decode } from "hono/jwt"; import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { BaseModuleApiOptions } from "modules"; export type TApiUser = SafeUser; @@ -40,10 +40,11 @@ export type ApiOptions = { data?: SubApiOptions; auth?: SubApiOptions; media?: SubApiOptions; + credentials?: RequestCredentials; } & ( | { token?: string; - user?: TApiUser; + user?: TApiUser | null; } | { request: Request; @@ -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/App.ts b/app/src/App.ts index 832ed70..633b9fa 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,21 +1,22 @@ import type { CreateUserPayload } from "auth/AppAuth"; -import { $console } from "core/utils"; +import { $console, McpClient } from "bknd/utils"; import { Event } from "core/events"; import type { em as prototypeEm } from "data/prototype"; import { Connection } from "data/connection/Connection"; import type { Hono } from "hono"; import { - ModuleManager, type InitialModuleConfigs, - type ModuleBuildContext, type ModuleConfigs, - type ModuleManagerOptions, type Modules, + ModuleManager, + type ModuleBuildContext, + type ModuleManagerOptions, } from "modules/ModuleManager"; +import { DbModuleManager } from "modules/db/DbModuleManager"; import * as SystemPermissions from "modules/permissions"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; -import type { MaybePromise } from "core/types"; +import type { MaybePromise, PartialRec } from "core/types"; import type { ServerEnv } from "modules/Controller"; import type { IEmailDriver, ICacheDriver } from "core/drivers"; @@ -23,13 +24,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers"; import { Api, type ApiOptions } from "Api"; export type AppPluginConfig = { + /** + * The name of the plugin. + */ name: string; + /** + * The schema of the plugin. + */ schema?: () => MaybePromise | void>; + /** + * Called before the app is built. + */ beforeBuild?: () => MaybePromise; + /** + * Called after the app is built. + */ onBuilt?: () => MaybePromise; + /** + * Called when the server is initialized. + */ onServerInit?: (server: Hono) => MaybePromise; - onFirstBoot?: () => MaybePromise; + /** + * Called when the app is booted. + */ onBoot?: () => MaybePromise; + /** + * Called when the app is first booted. + */ + onFirstBoot?: () => MaybePromise; }; export type AppPlugin = (app: App) => AppPluginConfig; @@ -72,20 +94,23 @@ export type AppOptions = { email?: IEmailDriver; cache?: ICacheDriver; }; + mode?: "db" | "code"; + readonly?: boolean; }; export type CreateAppConfig = { - /** - * bla - */ connection?: Connection | { url: string }; - initialConfig?: InitialModuleConfigs; + config?: PartialRec; options?: AppOptions; }; -export type AppConfig = InitialModuleConfigs; +export type AppConfig = { version: number } & ModuleConfigs; export type LocalApiOptions = Request | ApiOptions; -export class App { +export class App< + C extends Connection = Connection, + Config extends PartialRec = PartialRec, + Options extends AppOptions = AppOptions, +> { static readonly Events = AppEvents; modules: ModuleManager; @@ -96,11 +121,12 @@ export class App(module: Module) { - return this.modules.mutateConfigSafe(module); - } - get server() { return this.modules.server; } @@ -204,7 +239,14 @@ export class App(module: Module, config: ModuleConfigs[Module]) { // if the EventManager was disabled, we assume we shouldn't // respond to events, such as "onUpdated". @@ -330,6 +386,7 @@ export class App Promise; - makeHandler?: ( - config?: Config, - args?: Args, - opts?: RuntimeOptions | FrameworkOptions, - ) => (request: Request) => Promise; + makeApp: (config: Config, args?: Args) => Promise; + makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise; label?: string; overrides?: { dbUrl?: string; }; }, ) { - const { test, expect, mock } = testRunner; - const id = crypto.randomUUID(); + const { test, expect, mock, beforeAll, afterAll } = testRunner; + beforeAll(() => disableConsoleLog()); + afterAll(() => enableConsoleLog()); test(`creates ${label}`, async () => { const beforeBuild = mock(async () => null) as any; @@ -39,7 +33,7 @@ export function adapterTestSuite< const config = { app: (env) => ({ connection: { url: env.url }, - initialConfig: { + config: { server: { cors: { origin: env.origin } }, }, }), @@ -53,11 +47,10 @@ export function adapterTestSuite< url: overrides.dbUrl ?? ":memory:", origin: "localhost", } as any, - { id }, ); expect(app).toBeDefined(); expect(app.toJSON().server.cors.origin).toEqual("localhost"); - expect(beforeBuild).toHaveBeenCalledTimes(1); + expect(beforeBuild).toHaveBeenCalledTimes(2); expect(onBuilt).toHaveBeenCalledTimes(1); }); @@ -68,7 +61,7 @@ export function adapterTestSuite< return { res, data }; }; - test("responds with the same app id", async () => { + /* test.skip("responds with the same app id", async () => { const fetcher = makeHandler(undefined, undefined, { id }); const { res, data } = await getConfig(fetcher); @@ -77,14 +70,14 @@ export function adapterTestSuite< expect(data.server.cors.origin).toEqual("localhost"); }); - test("creates fresh & responds to api config", async () => { + test.skip("creates fresh & responds to api config", async () => { // set the same id, but force recreate - const fetcher = makeHandler(undefined, undefined, { id, force: true }); + const fetcher = makeHandler(undefined, undefined, { id }); const { res, data } = await getConfig(fetcher); expect(res.ok).toBe(true); expect(res.status).toBe(200); expect(data.server.cors.origin).toEqual("*"); - }); + }); */ } } diff --git a/app/src/adapter/astro/astro.adapter.spec.ts b/app/src/adapter/astro/astro.adapter.spec.ts index 3f3d1e8..54f3eb2 100644 --- a/app/src/adapter/astro/astro.adapter.spec.ts +++ b/app/src/adapter/astro/astro.adapter.spec.ts @@ -10,6 +10,6 @@ afterAll(enableConsoleLog); describe("astro adapter", () => { adapterTestSuite(bunTestRunner, { makeApp: astro.getApp, - makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }), + makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }), }); }); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index a684f73..92a8604 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,4 +1,4 @@ -import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter"; +import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; type AstroEnv = NodeJS.ProcessEnv; type TAstro = { @@ -8,18 +8,16 @@ export type AstroBkndConfig = FrameworkBkndConfig; export async function getApp( config: AstroBkndConfig = {}, - args: Env = {} as Env, - opts: FrameworkOptions = {}, + args: Env = import.meta.env as Env, ) { - return await createFrameworkApp(config, args ?? import.meta.env, opts); + return await createFrameworkApp(config, args); } export function serve( config: AstroBkndConfig = {}, - args: Env = {} as Env, - opts?: FrameworkOptions, + args: Env = import.meta.env as Env, ) { return async (fnArgs: TAstro) => { - return (await getApp(config, args, opts)).fetch(fnArgs.request); + return (await getApp(config, args)).fetch(fnArgs.request); }; } diff --git a/app/src/adapter/aws/aws-lambda.adapter.ts b/app/src/adapter/aws/aws-lambda.adapter.ts index ad19047..8c43b3d 100644 --- a/app/src/adapter/aws/aws-lambda.adapter.ts +++ b/app/src/adapter/aws/aws-lambda.adapter.ts @@ -1,7 +1,7 @@ import type { App } from "bknd"; import { handle } from "hono/aws-lambda"; import { serveStatic } from "@hono/node-server/serve-static"; -import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; type AwsLambdaEnv = object; export type AwsLambdaBkndConfig = @@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig = export async function createApp( { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig = {}, args: Env = {} as Env, - opts?: RuntimeOptions, ): Promise { let additional: Partial = { adminOptions, @@ -57,17 +56,15 @@ export async function createApp( ...additional, }, args ?? process.env, - opts, ); } export function serve( config: AwsLambdaBkndConfig = {}, args: Env = {} as Env, - opts?: RuntimeOptions, ) { return async (event) => { - const app = await createApp(config, args, opts); + const app = await createApp(config, args); return await handle(app.server)(event); }; } diff --git a/app/src/adapter/aws/aws.adapter.spec.ts b/app/src/adapter/aws/aws.adapter.spec.ts index e6873d8..e3007e4 100644 --- a/app/src/adapter/aws/aws.adapter.spec.ts +++ b/app/src/adapter/aws/aws.adapter.spec.ts @@ -11,8 +11,8 @@ describe("aws adapter", () => { adapterTestSuite(bunTestRunner, { makeApp: awsLambda.createApp, // @todo: add a request to lambda event translator? - makeHandler: (c, a, o) => async (request: Request) => { - const app = await awsLambda.createApp(c, a, o); + makeHandler: (c, a) => async (request: Request) => { + const app = await awsLambda.createApp(c, a); return app.fetch(request); }, }); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index c3d271b..44e7ccf 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,7 +1,7 @@ /// import path from "node:path"; -import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import { registerLocalMediaAdapter } from "."; import { config, type App } from "bknd"; import type { ServeOptions } from "bun"; @@ -11,32 +11,33 @@ type BunEnv = Bun.Env; export type BunBkndConfig = RuntimeBkndConfig & Omit; export async function createApp( - { distPath, ...config }: BunBkndConfig = {}, - args: Env = {} as Env, - opts?: RuntimeOptions, + { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, + args: Env = Bun.env as Env, ) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); registerLocalMediaAdapter(); return await createRuntimeApp( { - serveStatic: serveStatic({ root }), + serveStatic: + _serveStatic ?? + serveStatic({ + root, + }), ...config, }, - args ?? (process.env as Env), - opts, + args, ); } export function createHandler( config: BunBkndConfig = {}, - args: Env = {} as Env, - opts?: RuntimeOptions, + 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), opts); + app = await createApp(config, args); } return app.fetch(req); }; @@ -46,17 +47,17 @@ export function serve( { distPath, connection, - initialConfig, + config: _config, options, port = config.server.default_port, onBuilt, buildConfig, adminOptions, serveStatic, + beforeBuild, ...serveOptions }: BunBkndConfig = {}, - args: Env = {} as Env, - opts?: RuntimeOptions, + args: Env = Bun.env as Env, ) { Bun.serve({ ...serveOptions, @@ -64,16 +65,16 @@ export function serve( fetch: createHandler( { connection, - initialConfig, + config: _config, options, onBuilt, buildConfig, adminOptions, distPath, serveStatic, + beforeBuild, }, args, - opts, ), }); diff --git a/app/src/adapter/bun/connection/BunSqliteConnection.spec.ts b/app/src/adapter/bun/connection/BunSqliteConnection.spec.ts index 2b242a1..b18fe82 100644 --- a/app/src/adapter/bun/connection/BunSqliteConnection.spec.ts +++ b/app/src/adapter/bun/connection/BunSqliteConnection.spec.ts @@ -1,8 +1,9 @@ import { connectionTestSuite } from "data/connection/connection-test-suite"; import { bunSqlite } from "./BunSqliteConnection"; import { bunTestRunner } from "adapter/bun/test"; -import { describe } from "bun:test"; +import { describe, test, mock, expect } from "bun:test"; import { Database } from "bun:sqlite"; +import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection"; describe("BunSqliteConnection", () => { connectionTestSuite(bunTestRunner, { @@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => { }), rawDialectDetails: [], }); + + test("onCreateConnection", async () => { + const called = mock(() => null); + + const conn = bunSqlite({ + onCreateConnection: (db) => { + expect(db).toBeInstanceOf(Database); + called(); + }, + }); + await conn.ping(); + + expect(conn).toBeInstanceOf(GenericSqliteConnection); + expect(conn.db).toBeInstanceOf(Database); + expect(called).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/src/adapter/bun/connection/BunSqliteConnection.ts b/app/src/adapter/bun/connection/BunSqliteConnection.ts index 08444c5..39fff00 100644 --- a/app/src/adapter/bun/connection/BunSqliteConnection.ts +++ b/app/src/adapter/bun/connection/BunSqliteConnection.ts @@ -1,40 +1,53 @@ import { Database } from "bun:sqlite"; -import { genericSqlite, type GenericSqliteConnection } from "bknd"; +import { + genericSqlite, + type GenericSqliteConnection, + type GenericSqliteConnectionConfig, +} from "bknd"; +import { omitKeys } from "bknd/utils"; export type BunSqliteConnection = GenericSqliteConnection; -export type BunSqliteConnectionConfig = { - database: Database; -}; +export type BunSqliteConnectionConfig = Omit< + GenericSqliteConnectionConfig, + "name" | "supports" +> & + ({ database?: Database; url?: never } | { url?: string; database?: never }); -export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) { - let db: Database; +export function bunSqlite(config?: BunSqliteConnectionConfig) { + let db: Database | undefined; if (config) { - if ("database" in config) { + if ("database" in config && config.database) { db = config.database; - } else { + } else if (config.url) { db = new Database(config.url); } - } else { + } + + if (!db) { db = new Database(":memory:"); } - return genericSqlite("bun-sqlite", db, (utils) => { - //const fn = cache ? "query" : "prepare"; - const getStmt = (sql: string) => db.prepare(sql); + return genericSqlite( + "bun-sqlite", + db, + (utils) => { + const getStmt = (sql: string) => db.prepare(sql); - return { - db, - query: utils.buildQueryFn({ - all: (sql, parameters) => getStmt(sql).all(...(parameters || [])), - run: (sql, parameters) => { - const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || [])); - return { - insertId: utils.parseBigInt(lastInsertRowid), - numAffectedRows: utils.parseBigInt(changes), - }; - }, - }), - close: () => db.close(), - }; - }); + return { + db, + query: utils.buildQueryFn({ + all: (sql, parameters) => getStmt(sql).all(...(parameters || [])), + run: (sql, parameters) => { + const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || [])); + return { + insertId: utils.parseBigInt(lastInsertRowid), + numAffectedRows: utils.parseBigInt(changes), + }; + }, + }), + close: () => db.close(), + }; + }, + omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]), + ); } 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/bun/test.ts b/app/src/adapter/bun/test.ts index 4d453d7..b185776 100644 --- a/app/src/adapter/bun/test.ts +++ b/app/src/adapter/bun/test.ts @@ -1,4 +1,4 @@ -import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test"; +import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test"; export const bunTestRunner = { describe, @@ -8,4 +8,5 @@ export const bunTestRunner = { beforeEach, afterEach, afterAll, + beforeAll, }; diff --git a/app/src/adapter/cloudflare/bindings.ts b/app/src/adapter/cloudflare/bindings.ts index 0b68524..891081e 100644 --- a/app/src/adapter/cloudflare/bindings.ts +++ b/app/src/adapter/cloudflare/bindings.ts @@ -1,3 +1,5 @@ +import { inspect } from "node:util"; + export type BindingTypeMap = { D1Database: D1Database; KVNamespace: KVNamespace; @@ -13,8 +15,9 @@ export function getBindings(env: any, type: T): Bindin for (const key in env) { try { if ( - env[key] && - ((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`) + (env[key] as any).constructor.name === type || + String(env[key]) === `[object ${type}]` || + inspect(env[key]).includes(type) ) { bindings.push({ key, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 5cdde1a..6cb0f90 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -1,10 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { makeApp } from "./modes/fresh"; -import { makeConfig, type CfMakeConfigArgs } from "./config"; +import { makeConfig, type CloudflareContext } from "./config"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { adapterTestSuite } from "adapter/adapter-test-suite"; import { bunTestRunner } from "adapter/bun/test"; -import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter"; +import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -18,42 +17,42 @@ describe("cf adapter", () => { }); it("makes config", async () => { - const staticConfig = makeConfig( + const staticConfig = await makeConfig( { connection: { url: DB_URL }, - initialConfig: { data: { basepath: DB_URL } }, + config: { data: { basepath: DB_URL } }, }, $ctx({ DB_URL }), ); - expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); + expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.connection).toBeDefined(); - const dynamicConfig = makeConfig( + const dynamicConfig = await makeConfig( { app: (env) => ({ - initialConfig: { data: { basepath: env.DB_URL } }, + config: { data: { basepath: env.DB_URL } }, connection: { url: env.DB_URL }, }), }, $ctx({ DB_URL }), ); - expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); + expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } }); expect(dynamicConfig.connection).toBeDefined(); }); - adapterTestSuite>(bunTestRunner, { - makeApp: async (c, a, o) => { - return await makeApp(c, { env: a } as any, o); + adapterTestSuite>(bunTestRunner, { + makeApp: async (c, a) => { + return await createApp(c, { env: a } as any); }, - makeHandler: (c, a, o) => { + makeHandler: (c, a) => { + console.log("args", a); return async (request: any) => { - const app = await makeApp( + const app = await createApp( // needs a fallback, otherwise tries to launch D1 c ?? { connection: { url: DB_URL }, }, - a!, - o, + a as any, ); return app.fetch(request); }; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 427f8e4..e263756 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -3,11 +3,10 @@ import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import { getFresh } from "./modes/fresh"; -import { getCached } from "./modes/cached"; -import { getDurable } from "./modes/durable"; -import type { App } from "bknd"; -import { $console } from "core/utils"; +import type { MaybePromise } from "bknd"; +import { $console } from "bknd/utils"; +import { createRuntimeApp } from "bknd/adapter"; +import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; declare global { namespace Cloudflare { @@ -17,12 +16,10 @@ declare global { export type CloudflareEnv = Cloudflare.Env; export type CloudflareBkndConfig = RuntimeBkndConfig & { - mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (args: Env) => { + bindings?: (args: Env) => MaybePromise<{ kv?: KVNamespace; - dobj?: DurableObjectNamespace; db?: D1Database; - }; + }>; d1?: { session?: boolean; transport?: "header" | "cookie"; @@ -36,11 +33,27 @@ export type CloudflareBkndConfig = RuntimeBkndConfig & registerMedia?: boolean | ((env: Env) => void); }; -export type Context = { - request: Request; - env: Env; - ctx: ExecutionContext; -}; +export async function createApp( + config: CloudflareBkndConfig = {}, + ctx: Partial> = {}, +) { + const appConfig = await makeConfig(config, ctx); + return await createRuntimeApp( + { + ...appConfig, + onBuilt: async (app) => { + if (ctx.ctx) { + registerAsyncsExecutionContext(app, ctx?.ctx); + } + await appConfig.onBuilt?.(app); + }, + }, + ctx?.env, + ); +} + +// compatiblity +export const getFresh = createApp; export function serve( config: CloudflareBkndConfig = {}, @@ -79,25 +92,8 @@ export function serve( } } - const context = { request, env, ctx } as Context; - const mode = config.mode ?? "warm"; - - let app: App; - switch (mode) { - case "fresh": - app = await getFresh(config, context, { force: true }); - break; - case "warm": - app = await getFresh(config, context); - break; - case "cache": - app = await getCached(config, context); - break; - case "durable": - return await getDurable(config, context); - default: - throw new Error(`Unknown mode ${mode}`); - } + const context = { request, env, ctx } as CloudflareContext; + const app = await createApp(config, context); return app.fetch(request, env, ctx); }, diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index da5af07..86a7722 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -8,8 +8,8 @@ import { getBinding } from "./bindings"; import { d1Sqlite } from "./connection/D1Connection"; import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; -import type { Context, ExecutionContext } from "hono"; -import { $console } from "core/utils"; +import type { Context as HonoContext, ExecutionContext } from "hono"; +import { $console } from "bknd/utils"; import { setCookie } from "hono/cookie"; export const constants = { @@ -22,10 +22,10 @@ export const constants = { }, }; -export type CfMakeConfigArgs = { +export type CloudflareContext = { env: Env; - ctx?: ExecutionContext; - request?: Request; + ctx: ExecutionContext; + request: Request; }; function getCookieValue(cookies: string | null, name: string) { @@ -67,7 +67,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig) { return undefined; }, - set: (c: Context, d1?: D1DatabaseSession) => { + set: (c: HonoContext, d1?: D1DatabaseSession) => { if (!d1 || !config.d1?.session) return; const session = d1.getBookmark(); @@ -89,9 +89,9 @@ export function d1SessionHelper(config: CloudflareBkndConfig) { } let media_registered: boolean = false; -export function makeConfig( +export async function makeConfig( config: CloudflareBkndConfig, - args?: CfMakeConfigArgs, + args?: Partial>, ) { if (!media_registered && config.registerMedia !== false) { if (typeof config.registerMedia === "function") { @@ -102,7 +102,7 @@ export function makeConfig( media_registered = true; } - const appConfig = makeAdapterConfig(config, args?.env); + const appConfig = await makeAdapterConfig(config, args?.env); // if connection instance is given, don't do anything // other than checking if D1 session is defined @@ -115,12 +115,12 @@ export function makeConfig( } // if connection is given, try to open with unified sqlite adapter } else if (appConfig.connection) { - appConfig.connection = sqlite(appConfig.connection); + appConfig.connection = sqlite(appConfig.connection) as any; // if connection is not given, but env is set // try to make D1 from bindings } else if (args?.env) { - const bindings = config.bindings?.(args?.env); + const bindings = await config.bindings?.(args?.env); const sessionHelper = d1SessionHelper(config); const sessionId = sessionHelper.get(args.request); let session: D1DatabaseSession | undefined; diff --git a/app/src/adapter/cloudflare/connection/DoConnection.ts b/app/src/adapter/cloudflare/connection/DoConnection.ts index 91ae5ec..5a13b91 100644 --- a/app/src/adapter/cloudflare/connection/DoConnection.ts +++ b/app/src/adapter/cloudflare/connection/DoConnection.ts @@ -3,16 +3,16 @@ import { genericSqlite, type GenericSqliteConnection } from "bknd"; import type { QueryResult } from "kysely"; -export type D1SqliteConnection = GenericSqliteConnection; +export type DoSqliteConnection = GenericSqliteConnection; export type DurableObjecSql = DurableObjectState["storage"]["sql"]; -export type D1ConnectionConfig = +export type DoConnectionConfig = | DurableObjectState | { sql: DB; }; -export function doSqlite(config: D1ConnectionConfig) { +export function doSqlite(config: DoConnectionConfig) { const db = "sql" in config ? config.sql : config.storage.sql; return genericSqlite( @@ -21,7 +21,7 @@ export function doSqlite(config: D1ConnectionConfig< (utils) => { // must be async to work with the miniflare mock const getStmt = async (sql: string, parameters?: any[] | readonly any[]) => - await db.exec(sql, ...(parameters || [])); + db.exec(sql, ...(parameters || [])); const mapResult = ( cursor: SqlStorageCursor>, diff --git a/app/src/adapter/cloudflare/connection/DoConnection.vitest.ts b/app/src/adapter/cloudflare/connection/DoConnection.vitest.ts index 695046e..cb209e8 100644 --- a/app/src/adapter/cloudflare/connection/DoConnection.vitest.ts +++ b/app/src/adapter/cloudflare/connection/DoConnection.vitest.ts @@ -1,11 +1,12 @@ /// -import { describe, test, expect } from "vitest"; +import { describe, beforeAll, afterAll } from "vitest"; import { viTestRunner } from "adapter/node/vitest"; import { connectionTestSuite } from "data/connection/connection-test-suite"; import { Miniflare } from "miniflare"; import { doSqlite } from "./DoConnection"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; const script = ` import { DurableObject } from "cloudflare:workers"; @@ -40,6 +41,9 @@ export default { } `; +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); + describe("doSqlite", async () => { connectionTestSuite(viTestRunner, { makeConnection: async () => { diff --git a/app/src/adapter/cloudflare/drivers/cache.vitest.ts b/app/src/adapter/cloudflare/drivers/cache.vitest.ts index d9a856d..65c6608 100644 --- a/app/src/adapter/cloudflare/drivers/cache.vitest.ts +++ b/app/src/adapter/cloudflare/drivers/cache.vitest.ts @@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache"; import { viTestRunner } from "adapter/node/vitest"; import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite"; import { Miniflare } from "miniflare"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); describe("cacheWorkersKV", async () => { beforeAll(() => { diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index bc4e294..abc0719 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,10 +1,14 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection"; -export * from "./cloudflare-workers.adapter"; -export { makeApp, getFresh } from "./modes/fresh"; -export { getCached } from "./modes/cached"; -export { DurableBkndApp, getDurable } from "./modes/durable"; +export { + getFresh, + createApp, + serve, + type CloudflareEnv, + type CloudflareBkndConfig, +} from "./cloudflare-workers.adapter"; export { d1Sqlite, type D1ConnectionConfig }; +export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection"; export { getBinding, getBindings, @@ -12,9 +16,10 @@ export { type GetBindingType, type BindingMap, } from "./bindings"; -export { constants } from "./config"; +export { constants, makeConfig, type CloudflareContext } from "./config"; export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter"; export { registries } from "bknd"; +export { devFsVitePlugin, devFsWrite } from "./vite"; // for compatibility with old code export function d1( diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts deleted file mode 100644 index fc1d3c4..0000000 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { App } from "bknd"; -import { createRuntimeApp } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { makeConfig, registerAsyncsExecutionContext, constants } from "../config"; - -export async function getCached( - config: CloudflareBkndConfig, - args: Context, -) { - const { env, ctx } = args; - const { kv } = config.bindings?.(env)!; - if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); - const key = config.key ?? "app"; - - const cachedConfig = await kv.get(key); - const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined; - - async function saveConfig(__config: any) { - ctx.waitUntil(kv!.put(key, JSON.stringify(__config))); - } - - const app = await createRuntimeApp( - { - ...makeConfig(config, args), - initialConfig, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, ctx); - app.module.server.client.get(constants.cache_endpoint, async (c) => { - await kv.delete(key); - return c.json({ message: "Cache cleared" }); - }); - await config.onBuilt?.(app); - }, - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppConfigUpdatedEvent, - async ({ params: { app } }) => { - saveConfig(app.toJSON(true)); - }, - "sync", - ); - await config.beforeBuild?.(app); - }, - }, - args, - ); - - if (!cachedConfig) { - saveConfig(app.toJSON(true)); - } - - return app; -} diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts deleted file mode 100644 index 4812b0c..0000000 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { DurableObject } from "cloudflare:workers"; -import type { App, CreateAppConfig } from "bknd"; -import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { constants, registerAsyncsExecutionContext } from "../config"; -import { $console } from "core/utils"; - -export async function getDurable( - config: CloudflareBkndConfig, - ctx: Context, -) { - const { dobj } = config.bindings?.(ctx.env)!; - if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); - const key = config.key ?? "app"; - - if ([config.onBuilt, config.beforeBuild].some((x) => x)) { - $console.warn("onBuilt and beforeBuild are not supported with DurableObject mode"); - } - - const start = performance.now(); - - const id = dobj.idFromName(key); - const stub = dobj.get(id) as unknown as DurableBkndApp; - - const create_config = makeConfig(config, ctx.env); - - const res = await stub.fire(ctx.request, { - config: create_config, - keepAliveSeconds: config.keepAliveSeconds, - }); - - const headers = new Headers(res.headers); - headers.set("X-TTDO", String(performance.now() - start)); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); -} - -export class DurableBkndApp extends DurableObject { - protected id = Math.random().toString(36).slice(2); - protected app?: App; - protected interval?: any; - - async fire( - request: Request, - options: { - config: CreateAppConfig; - html?: string; - keepAliveSeconds?: number; - setAdminHtml?: boolean; - }, - ) { - let buildtime = 0; - if (!this.app) { - const start = performance.now(); - const config = options.config; - - // change protocol to websocket if libsql - if ( - config?.connection && - "type" in config.connection && - config.connection.type === "libsql" - ) { - //config.connection.config.protocol = "wss"; - } - - this.app = await createRuntimeApp({ - ...config, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, this.ctx); - app.modules.server.get(constants.do_endpoint, async (c) => { - // @ts-ignore - const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; - return c.json({ - id: this.id, - keepAliveSeconds: options?.keepAliveSeconds ?? 0, - colo: context.colo, - }); - }); - - await this.onBuilt(app); - }, - adminOptions: { html: options.html }, - beforeBuild: async (app) => { - await this.beforeBuild(app); - }, - }); - - buildtime = performance.now() - start; - } - - if (options?.keepAliveSeconds) { - this.keepAlive(options.keepAliveSeconds); - } - - const res = await this.app!.fetch(request); - const headers = new Headers(res.headers); - headers.set("X-BuildTime", buildtime.toString()); - headers.set("X-DO-ID", this.id); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); - } - - async onBuilt(app: App) {} - - async beforeBuild(app: App) {} - - protected keepAlive(seconds: number) { - if (this.interval) { - clearInterval(this.interval); - } - - let i = 0; - this.interval = setInterval(() => { - i += 1; - if (i === seconds) { - console.log("cleared"); - clearInterval(this.interval); - - // ping every 30 seconds - } else if (i % 30 === 0) { - console.log("ping"); - this.app?.modules.ctx().connection.ping(); - } - }, 1000); - } -} diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts deleted file mode 100644 index 7fb37e3..0000000 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config"; - -export async function makeApp( - config: CloudflareBkndConfig, - args?: CfMakeConfigArgs, - opts?: RuntimeOptions, -) { - return await createRuntimeApp(makeConfig(config, args), args?.env, opts); -} - -export async function getFresh( - config: CloudflareBkndConfig, - ctx: Context, - opts: RuntimeOptions = {}, -) { - return await makeApp( - { - ...config, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, ctx.ctx); - await config.onBuilt?.(app); - }, - }, - ctx, - opts, - ); -} diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts new file mode 100644 index 0000000..9efd5c4 --- /dev/null +++ b/app/src/adapter/cloudflare/proxy.ts @@ -0,0 +1,101 @@ +import { + d1Sqlite, + getBinding, + registerMedia, + type CloudflareBkndConfig, + type CloudflareEnv, +} from "bknd/adapter/cloudflare"; +import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler"; +import process from "node:process"; +import { $console } from "bknd/utils"; + +export type WithPlatformProxyOptions = { + /** + * By default, proxy is used if the PROXY environment variable is set to 1. + * You can override/force this by setting this option. + */ + useProxy?: boolean; + proxyOptions?: GetPlatformProxyOptions; +}; + +async function getPlatformProxy(opts?: GetPlatformProxyOptions) { + try { + const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then( + (pkg) => pkg.default, + ); + $console.log("Using wrangler version", version); + const { getPlatformProxy } = await import("wrangler"); + return getPlatformProxy(opts); + } catch (e) { + $console.error("Failed to import wrangler", String(e)); + const resolved = import.meta.resolve("wrangler"); + $console.log("Wrangler resolved to", resolved); + const file = resolved?.split("/").pop(); + if (file?.endsWith(".json")) { + $console.error( + "You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml", + ); + } + } + + process.exit(1); +} + +export function withPlatformProxy( + config: CloudflareBkndConfig = {}, + opts?: WithPlatformProxyOptions, +) { + const use_proxy = + typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1"; + let proxy: PlatformProxy | undefined; + + $console.log("Using cloudflare platform proxy"); + + async function getEnv(env?: Env): Promise { + if (use_proxy) { + if (!proxy) { + proxy = await getPlatformProxy(opts?.proxyOptions); + process.on("exit", () => { + proxy?.dispose(); + }); + } + return proxy.env as unknown as Env; + } + return env || ({} as Env); + } + + return { + ...config, + beforeBuild: async (app, registries) => { + if (!use_proxy) return; + const env = await getEnv(); + registerMedia(env, registries as any); + await config?.beforeBuild?.(app, registries); + }, + bindings: async (env) => { + return (await config?.bindings?.(await getEnv(env))) || {}; + }, + // @ts-ignore + app: async (_env) => { + const env = await getEnv(_env); + const binding = use_proxy ? getBinding(env, "D1Database") : undefined; + + if (config?.app === undefined && use_proxy && binding) { + return { + connection: d1Sqlite({ + binding: binding.value, + }), + }; + } else if (typeof config?.app === "function") { + const appConfig = await config?.app(env); + if (binding) { + appConfig.connection = d1Sqlite({ + binding: binding.value, + }) as any; + } + return appConfig; + } + return config?.app || {}; + }, + } satisfies CloudflareBkndConfig; +} diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index a1edf58..756e562 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,4 +1,4 @@ -import { registries, isDebug, guessMimeType } from "bknd"; +import { registries as $registries, isDebug, guessMimeType } from "bknd"; import { getBindings } from "../bindings"; import { s } from "bknd/utils"; import { StorageAdapter, type FileBody } from "bknd"; @@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) { ); } -export function registerMedia(env: Record) { +export function registerMedia( + env: Record, + registries: typeof $registries = $registries, +) { const r2_bindings = getBindings(env, "R2Bucket"); registries.media.register( @@ -46,6 +49,8 @@ export function registerMedia(env: Record) { * @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(); } @@ -172,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter { } protected getKey(key: string) { + if (this.keyPrefix.length > 0) { + return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/"); + } return key; } diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts index 437ec9b..6ecd8df 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.vitest.ts @@ -3,8 +3,12 @@ import { Miniflare } from "miniflare"; import { StorageR2Adapter } from "./StorageR2Adapter"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import path from "node:path"; -import { describe, afterAll, test, expect } from "vitest"; +import { describe, afterAll, test, expect, beforeAll } from "vitest"; import { viTestRunner } from "adapter/node/vitest"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); let mf: Miniflare | undefined; describe("StorageR2Adapter", async () => { @@ -24,7 +28,8 @@ describe("StorageR2Adapter", async () => { const buffer = readFileSync(path.join(basePath, "image.png")); const file = new File([buffer], "image.png", { type: "image/png" }); - await adapterTestSuite(viTestRunner, adapter, file); + // miniflare doesn't support range requests + await adapterTestSuite(viTestRunner, adapter, file, { testRange: false }); }); afterAll(async () => { diff --git a/app/src/adapter/cloudflare/vite.ts b/app/src/adapter/cloudflare/vite.ts new file mode 100644 index 0000000..1b7640b --- /dev/null +++ b/app/src/adapter/cloudflare/vite.ts @@ -0,0 +1,272 @@ +import type { Plugin } from "vite"; +import { writeFile as nodeWriteFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +/** + * Vite plugin that provides Node.js filesystem access during development + * by injecting a polyfill into the SSR environment + */ +export function devFsVitePlugin({ + verbose = false, + configFile = "bknd.config.ts", +}: { + verbose?: boolean; + configFile?: string; +} = {}): any { + let isDev = false; + let projectRoot = ""; + + return { + name: "dev-fs-plugin", + enforce: "pre", + configResolved(config) { + isDev = config.command === "serve"; + projectRoot = config.root; + }, + configureServer(server) { + if (!isDev) { + verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping"); + return; + } + + // Track active chunked requests + const activeRequests = new Map< + string, + { + totalChunks: number; + filename: string; + chunks: string[]; + receivedChunks: number; + } + >(); + + // Intercept stdout to watch for our write requests + const originalStdoutWrite = process.stdout.write; + process.stdout.write = function (chunk: any, encoding?: any, callback?: any) { + const output = chunk.toString(); + + // Skip our own debug output + if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) { + // @ts-ignore + // biome-ignore lint/style/noArguments: + return originalStdoutWrite.apply(process.stdout, arguments); + } + + // Track if we process any protocol messages (to suppress output) + let processedProtocolMessage = false; + + // Process all start markers in this output + if (output.includes("{{DEV_FS_START}}")) { + const startMatches = [ + ...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g), + ]; + for (const startMatch of startMatches) { + const requestId = startMatch[1]; + const totalChunks = Number.parseInt(startMatch[2]); + const filename = startMatch[3]; + + activeRequests.set(requestId, { + totalChunks, + filename, + chunks: new Array(totalChunks), + receivedChunks: 0, + }); + + verbose && + console.debug( + `[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`, + ); + } + processedProtocolMessage = true; + } + + // Process all chunk data in this output + if (output.includes("{{DEV_FS_CHUNK}}")) { + const chunkMatches = [ + ...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g), + ]; + for (const chunkMatch of chunkMatches) { + const requestId = chunkMatch[1]; + const chunkIndex = Number.parseInt(chunkMatch[2]); + const chunkData = chunkMatch[3]; + + const request = activeRequests.get(requestId); + if (request) { + request.chunks[chunkIndex] = chunkData; + request.receivedChunks++; + verbose && + console.debug( + `[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`, + ); + + // Validate base64 chunk + if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) { + verbose && + console.warn( + `[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`, + ); + } + } + } + processedProtocolMessage = true; + } + + // Process all end markers in this output + if (output.includes("{{DEV_FS_END}}")) { + const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)]; + for (const endMatch of endMatches) { + const requestId = endMatch[1]; + const request = activeRequests.get(requestId); + + if (request && request.receivedChunks === request.totalChunks) { + try { + // Reconstruct the base64 string + const fullBase64 = request.chunks.join(""); + verbose && + console.debug( + `[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`, + ); + + // Decode and parse + const decodedJson = atob(fullBase64); + const writeRequest = JSON.parse(decodedJson); + + if (writeRequest.type === "DEV_FS_WRITE_REQUEST") { + verbose && + console.debug( + `[dev-fs-plugin] Processing write request for ${writeRequest.filename}`, + ); + + // Process the write request + (async () => { + try { + const fullPath = resolve(projectRoot, writeRequest.filename); + verbose && + console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`); + await nodeWriteFile(fullPath, writeRequest.data); + verbose && + console.debug("[dev-fs-plugin] File written successfully!"); + } catch (error) { + console.error("[dev-fs-plugin] Error writing file:", error); + } + })(); + + // Clean up + activeRequests.delete(requestId); + return true; + } + } catch (error) { + console.error( + "[dev-fs-plugin] Error processing chunked request:", + String(error), + ); + activeRequests.delete(requestId); + } + } else if (request) { + verbose && + console.debug( + `[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`, + ); + } + } + processedProtocolMessage = true; + } + + // If we processed any protocol messages, suppress output + if (processedProtocolMessage) { + return callback ? callback() : true; + } + + // @ts-ignore + // biome-ignore lint: + return originalStdoutWrite.apply(process.stdout, arguments); + }; + + // Restore stdout when server closes + server.httpServer?.on("close", () => { + process.stdout.write = originalStdoutWrite; + }); + }, + // @ts-ignore + transform(code, id, options) { + // Only transform in SSR mode during development + //if (!isDev || !options?.ssr) return; + if (!isDev) { + return; + } + + // Check if this is the bknd config file + if (id.includes(configFile)) { + if (verbose) { + console.debug("[dev-fs-plugin] Transforming", configFile); + } + + // Inject our filesystem polyfill at the top of the file + const polyfill = ` +// Dev-fs polyfill injected by vite-plugin-dev-fs +if (typeof globalThis !== 'undefined') { + globalThis.__devFsPolyfill = { + writeFile: async (filename, data) => { + ${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""} + + // Use console logging as a communication channel + // The main process will watch for this specific log pattern + const writeRequest = { + type: 'DEV_FS_WRITE_REQUEST', + filename: filename, + data: data, + timestamp: Date.now() + }; + + // Output as a specially formatted console message with end delimiter + // Base64 encode the JSON to avoid any control character issues + const jsonString = JSON.stringify(writeRequest); + const encodedJson = btoa(jsonString); + + // Split into reasonable chunks that balance performance vs reliability + const chunkSize = 2000; // 2KB chunks - safe for most environments + const chunks = []; + for (let i = 0; i < encodedJson.length; i += chunkSize) { + chunks.push(encodedJson.slice(i, i + chunkSize)); + } + + const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + + // Send start marker (use stdout.write to avoid console display) + process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n'); + + // Send each chunk + chunks.forEach((chunk, index) => { + process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n'); + }); + + // Send end marker + process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n'); + + return Promise.resolve(); + } + }; +}`; + return polyfill + code; + } else { + verbose && console.debug("[dev-fs-plugin] Not transforming", id); + } + }, + } satisfies Plugin; +} + +// Write function that uses the dev-fs polyfill injected by our Vite plugin +export async function devFsWrite(filename: string, data: string): Promise { + try { + // Check if the dev-fs polyfill is available (injected by our Vite plugin) + if (typeof globalThis !== "undefined" && (globalThis as any).__devFsPolyfill) { + return (globalThis as any).__devFsPolyfill.writeFile(filename, data); + } + + // Fallback to Node.js fs for other environments (Node.js, Bun) + const { writeFile } = await import("node:fs/promises"); + return writeFile(filename, data); + } catch (error) { + console.error("[dev-fs-write] Error writing file:", error); + } +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 65c749b..79f4c97 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,25 +1,31 @@ -import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd"; +import { + config as $config, + App, + type CreateAppConfig, + Connection, + 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?: CreateAppConfig | ((args: Args) => CreateAppConfig); - onBuilt?: (app: App) => Promise; - beforeBuild?: (app: App) => Promise; - 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; -export type CreateAdapterAppOptions = { - force?: boolean; - id?: string; -}; -export type FrameworkOptions = CreateAdapterAppOptions; -export type RuntimeOptions = CreateAdapterAppOptions; - export type RuntimeBkndConfig = BkndConfig & { distPath?: string; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; @@ -30,10 +36,10 @@ export type DefaultArgs = { [key: string]: any; }; -export function makeConfig( +export async function makeConfig( config: BkndConfig, args?: Args, -): CreateAppConfig { +): Promise, "app">> { let additionalConfig: CreateAppConfig = {}; const { app, ...rest } = config; if (app) { @@ -41,7 +47,7 @@ export function makeConfig( if (!args) { throw new Error("args is required when config.app is a function"); } - additionalConfig = app(args); + additionalConfig = await app(args); } else { additionalConfig = app; } @@ -50,55 +56,50 @@ export function makeConfig( return { ...rest, ...additionalConfig }; } -// a map that contains all apps by id -const apps = new Map(); export async function createAdapterApp( config: Config = {} as Config, args?: Args, - opts?: CreateAdapterAppOptions, -): Promise { - const id = opts?.id ?? "app"; - let app = apps.get(id); - if (!app || opts?.force) { - const appConfig = makeConfig(config, args); - if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) { - let connection: Connection | undefined; - if (Connection.isConnection(config.connection)) { - connection = config.connection; - } else { - const sqlite = (await import("bknd/adapter/sqlite")).sqlite; - const conf = appConfig.connection ?? { url: ":memory:" }; - connection = sqlite(conf); - $console.info(`Using ${connection!.name} connection`, conf.url); - } - appConfig.connection = connection; - } +): Promise<{ app: App; config: BkndConfig }> { + await config.beforeBuild?.(undefined, $registries); - app = App.create(appConfig); - apps.set(id, app); + const appConfig = await makeConfig(config, args); + if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) { + let connection: Connection | undefined; + if (Connection.isConnection(config.connection)) { + connection = config.connection; + } else { + const sqlite = (await import("bknd/adapter/sqlite")).sqlite; + const conf = appConfig.connection ?? { url: "file:data.db" }; + connection = sqlite(conf) as any; + $console.info(`Using ${connection!.name} connection`, conf.url); + } + appConfig.connection = connection; } - return app; + + return { + app: App.create(appConfig), + config: appConfig, + }; } export async function createFrameworkApp( config: FrameworkBkndConfig = {}, args?: Args, - opts?: FrameworkOptions, ): Promise { - const app = await createAdapterApp(config, args, opts); + 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); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -108,9 +109,8 @@ export async function createFrameworkApp( export async function createRuntimeApp( { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, args?: Args, - opts?: RuntimeOptions, ): Promise { - const app = await createAdapterApp(config, args, opts); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { app.emgr.onEvent( @@ -123,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); } @@ -131,7 +131,7 @@ export async function createRuntimeApp( "sync", ); - await config.beforeBuild?.(app); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -154,22 +154,33 @@ 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")).default as Manifest); + opts?.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`, { - assert: { type: "text" }, + const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`; + const content = await import(/* @vite-ignore */ url, { + with: { type: "text" }, }).then((m) => m.default); if (content) { @@ -181,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/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index bce7009..eed1c35 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,4 +1,4 @@ -import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter"; +import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter"; import { isNode } from "bknd/utils"; import type { NextApiRequest } from "next"; @@ -9,10 +9,9 @@ export type NextjsBkndConfig = FrameworkBkndConfig & { export async function getApp( config: NextjsBkndConfig, - args: Env = {} as Env, - opts?: FrameworkOptions, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? (process.env as Env), opts); + return await createFrameworkApp(config, args); } function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { @@ -40,11 +39,10 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ export function serve( { cleanRequest, ...config }: NextjsBkndConfig = {}, - args: Env = {} as Env, - opts?: FrameworkOptions, + args: Env = process.env as Env, ) { return async (req: Request) => { - const app = await getApp(config, args, opts); + const app = await getApp(config, args); const request = getCleanRequest(req, cleanRequest); return app.fetch(request); }; diff --git a/app/src/adapter/node/connection/NodeSqliteConnection.ts b/app/src/adapter/node/connection/NodeSqliteConnection.ts index e215aad..915ab3d 100644 --- a/app/src/adapter/node/connection/NodeSqliteConnection.ts +++ b/app/src/adapter/node/connection/NodeSqliteConnection.ts @@ -1,19 +1,29 @@ -import { genericSqlite } from "bknd"; +import { + genericSqlite, + type GenericSqliteConnection, + type GenericSqliteConnectionConfig, +} from "bknd"; import { DatabaseSync } from "node:sqlite"; +import { omitKeys } from "bknd/utils"; -export type NodeSqliteConnectionConfig = { - database: DatabaseSync; -}; +export type NodeSqliteConnection = GenericSqliteConnection; +export type NodeSqliteConnectionConfig = Omit< + GenericSqliteConnectionConfig, + "name" | "supports" +> & + ({ database?: DatabaseSync; url?: never } | { url?: string; database?: never }); -export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) { - let db: DatabaseSync; +export function nodeSqlite(config?: NodeSqliteConnectionConfig) { + let db: DatabaseSync | undefined; if (config) { - if ("database" in config) { + if ("database" in config && config.database) { db = config.database; - } else { + } else if (config.url) { db = new DatabaseSync(config.url); } - } else { + } + + if (!db) { db = new DatabaseSync(":memory:"); } @@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string } "node-sqlite", db, (utils) => { - const getStmt = (sql: string) => { - const stmt = db.prepare(sql); - //stmt.setReadBigInts(true); - return stmt; - }; + const getStmt = (sql: string) => db.prepare(sql); return { db, @@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string } }; }, { + ...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]), supports: { batching: false, }, diff --git a/app/src/adapter/node/connection/NodeSqliteConnection.vi-test.ts b/app/src/adapter/node/connection/NodeSqliteConnection.vi-test.ts index 2cb9149..bbf85f5 100644 --- a/app/src/adapter/node/connection/NodeSqliteConnection.vi-test.ts +++ b/app/src/adapter/node/connection/NodeSqliteConnection.vi-test.ts @@ -1,8 +1,13 @@ import { nodeSqlite } from "./NodeSqliteConnection"; import { DatabaseSync } from "node:sqlite"; import { connectionTestSuite } from "data/connection/connection-test-suite"; -import { describe } from "vitest"; +import { describe, beforeAll, afterAll, test, expect, vi } from "vitest"; import { viTestRunner } from "../vitest"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection"; + +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); describe("NodeSqliteConnection", () => { connectionTestSuite(viTestRunner, { @@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => { }), rawDialectDetails: [], }); + + test("onCreateConnection", async () => { + const called = vi.fn(() => null); + + const conn = nodeSqlite({ + onCreateConnection: (db) => { + expect(db).toBeInstanceOf(DatabaseSync); + called(); + }, + }); + await conn.ping(); + + expect(conn).toBeInstanceOf(GenericSqliteConnection); + expect(conn.db).toBeInstanceOf(DatabaseSync); + expect(called).toHaveBeenCalledOnce(); + }); }); 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 5a2c058..83feba8 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { serve as honoServe } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { registerLocalMediaAdapter } from "adapter/node/storage"; -import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import { config as $config, type App } from "bknd"; import { $console } from "bknd/utils"; @@ -17,8 +17,7 @@ export type NodeBkndConfig = RuntimeBkndConfig & { export async function createApp( { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, - opts?: RuntimeOptions, + args: Env = process.env as Env, ) { const root = path.relative( process.cwd(), @@ -34,21 +33,18 @@ export async function createApp( serveStatic: serveStatic({ root }), ...config, }, - // @ts-ignore - args ?? { env: process.env }, - opts, + args, ); } export function createHandler( config: NodeBkndConfig = {}, - args: Env = {} as Env, - opts?: RuntimeOptions, + 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), opts); + app = await createApp(config, args); } return app.fetch(req); }; @@ -56,14 +52,13 @@ export function createHandler( export function serve( { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, - opts?: RuntimeOptions, + args: Env = process.env as Env, ) { honoServe( { port, hostname, - fetch: createHandler(config, args, opts), + fetch: createHandler(config, args), }, (connInfo) => { $console.log(`Server is running on http://localhost:${connInfo.port}`); diff --git a/app/src/adapter/node/node.adapter.vi-test.ts b/app/src/adapter/node/node.adapter.vi-test.ts index 31cdb31..11bb0f7 100644 --- a/app/src/adapter/node/node.adapter.vi-test.ts +++ b/app/src/adapter/node/node.adapter.vi-test.ts @@ -2,10 +2,6 @@ import { describe, beforeAll, afterAll } from "vitest"; import * as node from "./node.adapter"; import { adapterTestSuite } from "adapter/adapter-test-suite"; import { viTestRunner } from "adapter/node/vitest"; -import { disableConsoleLog, enableConsoleLog } from "core/utils"; - -beforeAll(() => disableConsoleLog()); -afterAll(enableConsoleLog); describe("node adapter", () => { adapterTestSuite(viTestRunner, { diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts index fa3e336..46db8fd 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter { } } + private parseRangeHeader( + rangeHeader: string, + fileSize: number, + ): { start: number; end: number } | null { + // Parse "bytes=start-end" format + const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); + if (!match) return null; + + const [, startStr, endStr] = match; + let start = startStr ? Number.parseInt(startStr, 10) : 0; + let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1; + + // Handle suffix-byte-range-spec (e.g., "bytes=-500") + if (!startStr && endStr) { + start = Math.max(0, fileSize - Number.parseInt(endStr, 10)); + end = fileSize - 1; + } + + // Validate range + if (start < 0 || end >= fileSize || start > end) { + return null; + } + + return { start, end }; + } + async getObject(key: string, headers: Headers): Promise { try { - const content = await readFile(`${this.config.path}/${key}`); + const filePath = `${this.config.path}/${key}`; + const stats = await stat(filePath); + const fileSize = stats.size; const mimeType = guessMimeType(key); - return new Response(content, { - status: 200, - headers: { - "Content-Type": mimeType || "application/octet-stream", - "Content-Length": content.length.toString(), - }, + const responseHeaders = new Headers({ + "Accept-Ranges": "bytes", + "Content-Type": mimeType || "application/octet-stream", }); + + const rangeHeader = headers.get("range"); + + if (rangeHeader) { + const range = this.parseRangeHeader(rangeHeader, fileSize); + + if (!range) { + // Invalid range - return 416 Range Not Satisfiable + responseHeaders.set("Content-Range", `bytes */${fileSize}`); + return new Response("", { + status: 416, + headers: responseHeaders, + }); + } + + const { start, end } = range; + const content = await readFile(filePath, { encoding: null }); + const chunk = content.slice(start, end + 1); + + responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`); + responseHeaders.set("Content-Length", chunk.length.toString()); + + return new Response(chunk, { + status: 206, // Partial Content + headers: responseHeaders, + }); + } else { + // Normal request - return entire file + const content = await readFile(filePath); + responseHeaders.set("Content-Length", content.length.toString()); + + return new Response(content, { + status: 200, + headers: responseHeaders, + }); + } } catch (error) { // Handle file reading errors return new Response("", { status: 404 }); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.vitest.ts b/app/src/adapter/node/storage/StorageLocalAdapter.vitest.ts index 3d13bd5..8b3acf1 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.vitest.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.vitest.ts @@ -1,9 +1,13 @@ -import { describe } from "vitest"; +import { describe, beforeAll, afterAll } from "vitest"; import { viTestRunner } from "adapter/node/vitest"; import { StorageLocalAdapter } from "adapter/node"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); describe("StorageLocalAdapter (node)", async () => { const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets"); diff --git a/app/src/adapter/node/test.ts b/app/src/adapter/node/test.ts index 3c78f25..bd1d8a5 100644 --- a/app/src/adapter/node/test.ts +++ b/app/src/adapter/node/test.ts @@ -1,5 +1,5 @@ import nodeAssert from "node:assert/strict"; -import { test, describe, beforeEach, afterEach } from "node:test"; +import { test, describe, beforeEach, afterEach, after, before } from "node:test"; import type { Matcher, Test, TestFn, TestRunner } from "core/test"; // Track mock function calls @@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = { }), beforeEach: beforeEach, afterEach: afterEach, - afterAll: () => {}, + afterAll: after, + beforeAll: before, }; diff --git a/app/src/adapter/node/vitest.ts b/app/src/adapter/node/vitest.ts index 8f6988e..ad9afcf 100644 --- a/app/src/adapter/node/vitest.ts +++ b/app/src/adapter/node/vitest.ts @@ -1,5 +1,5 @@ import type { TestFn, TestRunner, Test } from "core/test"; -import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; +import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest"; function vitestTest(label: string, fn: TestFn, options?: any) { return test(label, fn as any); @@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = { beforeEach: beforeEach, afterEach: afterEach, afterAll: afterAll, + beforeAll: beforeAll, }; diff --git a/app/src/adapter/react-router/react-router.adapter.spec.ts b/app/src/adapter/react-router/react-router.adapter.spec.ts index 25ef895..bb525c7 100644 --- a/app/src/adapter/react-router/react-router.adapter.spec.ts +++ b/app/src/adapter/react-router/react-router.adapter.spec.ts @@ -10,6 +10,6 @@ afterAll(enableConsoleLog); describe("react-router adapter", () => { adapterTestSuite(bunTestRunner, { makeApp: rr.getApp, - makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }), + makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }), }); }); diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index 4474509..f624bde 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -1,5 +1,4 @@ import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import type { FrameworkOptions } from "adapter"; type ReactRouterEnv = NodeJS.ProcessEnv; type ReactRouterFunctionArgs = { @@ -9,18 +8,16 @@ export type ReactRouterBkndConfig = FrameworkBkndConfig( config: ReactRouterBkndConfig, - args: Env = {} as Env, - opts?: FrameworkOptions, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? process.env, opts); + return await createFrameworkApp(config, args); } export function serve( config: ReactRouterBkndConfig = {}, - args: Env = {} as Env, - opts?: FrameworkOptions, + args: Env = process.env as Env, ) { return async (fnArgs: ReactRouterFunctionArgs) => { - return (await getApp(config, args, opts)).fetch(fnArgs.request); + return (await getApp(config, args)).fetch(fnArgs.request); }; } diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index c69bc1e..a4cb346 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,7 +1,7 @@ import { serveStatic } from "@hono/node-server/serve-static"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import type { App } from "bknd"; -import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { devServerConfig } from "./dev-server-config"; import type { MiddlewareHandler } from "hono"; @@ -30,7 +30,6 @@ ${addBkndContext ? "" : ""} async function createApp( config: ViteBkndConfig = {}, env: ViteEnv = {} as ViteEnv, - opts: FrameworkOptions = {}, ): Promise { registerLocalMediaAdapter(); return await createRuntimeApp( @@ -47,18 +46,13 @@ async function createApp( ], }, env, - opts, ); } -export function serve( - config: ViteBkndConfig = {}, - args?: ViteEnv, - opts?: FrameworkOptions, -) { +export function serve(config: ViteBkndConfig = {}, args?: ViteEnv) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { - const app = await createApp(config, env, opts); + const app = await createApp(config, env); return app.fetch(request, env, ctx); }, }; diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 8ee5423..4b23919 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,8 +1,8 @@ -import type { DB } from "bknd"; +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 "core/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"; @@ -14,6 +14,7 @@ import { usersFields } from "./auth-entities"; import { Authenticator } from "./authenticate/Authenticator"; import { Role } from "./authorize/Role"; +export type UsersFields = typeof AppAuth.usersFields; export type UserFieldSchema = FieldSchema; declare module "bknd" { interface Users extends AppEntity, UserFieldSchema {} @@ -60,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 ?? {}); @@ -87,6 +88,7 @@ export class AppAuth extends Module { super.setBuilt(); this._controller = new AuthController(this); + this._controller.registerMcp(); this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.guard.registerPermissions(AuthPermissions); } @@ -111,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!; @@ -176,16 +191,44 @@ export class AppAuth extends Module { return created; } + async changePassword(userId: PrimaryFieldType, newPassword: string) { + const users_entity = this.config.entity_name as "users"; + const { data: user } = await this.em.repository(users_entity).findId(userId); + if (!user) { + throw new Error("User not found"); + } else if (user.strategy !== "password") { + throw new Error("User is not using password strategy"); + } + + const togglePw = (visible: boolean) => { + const field = this.em.entity(users_entity).field("strategy_value")!; + + field.config.hidden = !visible; + field.config.fillable = visible; + }; + + const pw = this.authenticator.strategy("password" as const) as PasswordStrategy; + togglePw(true); + await this.em.mutator(users_entity).updateOne(user.id, { + strategy_value: await pw.hash(newPassword), + }); + togglePw(false); + + return true; + } + override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; } const strategies = this.authenticator.getStrategies(); + const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()])); return { ...this.config, ...this.authenticator.toJSON(secrets), + roles, strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), ...strategy.toJSON(secrets), diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts index 128de6c..d5679b6 100644 --- a/app/src/auth/AppUserPool.ts +++ b/app/src/auth/AppUserPool.ts @@ -1,6 +1,6 @@ import { AppAuth } from "auth/AppAuth"; import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { pick } from "lodash-es"; import { InvalidConditionsException, 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/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b039635..99f1000 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,11 +1,20 @@ -import type { SafeUser } from "bknd"; +import type { DB, SafeUser } from "bknd"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AppAuth } from "auth/AppAuth"; import * as AuthPermissions from "auth/auth-permissions"; import * as DataPermissions from "data/permissions"; import type { Hono } from "hono"; import { Controller, type ServerEnv } from "modules/Controller"; -import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils"; +import { + describeRoute, + jsc, + s, + parse, + InvalidSchemaError, + transformObject, + mcpTool, +} from "bknd/utils"; +import type { PasswordStrategy } from "auth/authenticate/strategies"; export type AuthActionResponse = { success: boolean; @@ -51,7 +60,10 @@ export class AuthController extends Controller { if (create) { hono.post( "/create", - permission([AuthPermissions.createUser, DataPermissions.entityCreate]), + permission(AuthPermissions.createUser, {}), + permission(DataPermissions.entityCreate, { + context: (c) => ({ entity: this.auth.config.entity_name }), + }), describeRoute({ summary: "Create a new user", tags: ["auth"], @@ -118,6 +130,9 @@ export class AuthController extends Controller { summary: "Get the current user", tags: ["auth"], }), + mcpTool("auth_me", { + noErrorCodes: [403], + }), auth(), async (c) => { const claims = c.get("auth")?.user; @@ -159,6 +174,7 @@ export class AuthController extends Controller { summary: "Get the available authentication strategies", tags: ["auth"], }), + mcpTool("auth_strategies"), jsc("query", s.object({ include_disabled: s.boolean().optional() })), async (c) => { const { include_disabled } = c.req.valid("query"); @@ -188,4 +204,116 @@ export class AuthController extends Controller { return hono; } + + override registerMcp(): void { + const { mcp } = this.auth.ctx; + const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]); + + const getUser = async (params: { id?: string | number; email?: string }) => { + let user: DB["users"] | undefined = undefined; + if (params.id) { + const { data } = await this.userRepo.findId(params.id); + user = data; + } else if (params.email) { + const { data } = await this.userRepo.findOne({ email: params.email }); + user = data; + } + if (!user) { + throw new Error("User not found"); + } + return user; + }; + + const roles = Object.keys(this.auth.config.roles ?? {}); + mcp.tool( + "auth_user_create", + { + description: "Create a new user", + inputSchema: s.object({ + email: s.string({ format: "email" }), + password: s.string({ minLength: 8 }), + role: s + .string({ + enum: roles.length > 0 ? roles : undefined, + }) + .optional(), + }), + }, + async (params, c) => { + await c.context.ctx().helper.granted(c, AuthPermissions.createUser); + + return c.json(await this.auth.createUser(params)); + }, + ); + + mcp.tool( + "auth_user_token", + { + description: "Get a user token", + inputSchema: s.object({ + id: idType.optional(), + email: s.string({ format: "email" }).optional(), + }), + }, + async (params, 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) }); + }, + ); + + mcp.tool( + "auth_user_password_change", + { + description: "Change a user's password", + inputSchema: s.object({ + id: idType.optional(), + email: s.string({ format: "email" }).optional(), + password: s.string({ minLength: 8 }), + }), + }, + async (params, c) => { + await c.context.ctx().helper.granted(c, AuthPermissions.changePassword); + + const user = await getUser(params); + if (!(await this.auth.changePassword(user.id, params.password))) { + throw new Error("Failed to change password"); + } + return c.json({ changed: true }); + }, + ); + + mcp.tool( + "auth_user_password_test", + { + description: "Test a user's password", + inputSchema: s.object({ + email: s.string({ format: "email" }), + password: s.string({ minLength: 8 }), + }), + }, + async (params, 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); + + const res = await controller.request( + new Request("https://localhost/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: params.email, + password: params.password, + }), + }), + ); + + return c.json({ valid: res.ok }); + }, + ); + } } diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts index ed57c50..dce59f5 100644 --- a/app/src/auth/auth-permissions.ts +++ b/app/src/auth/auth-permissions.ts @@ -1,4 +1,7 @@ -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"); +export const testPassword = new Permission("auth.user.password.test"); +export const changePassword = new Permission("auth.user.password.change"); +export const createToken = new Permission("auth.user.token.create"); diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index aedce2d..e479ea1 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,6 +1,8 @@ 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 = { password: { @@ -39,13 +41,11 @@ export type AppAuthCustomOAuthStrategy = s.Static { - const user = pick(_user, this.config.jwt.fields); + const user = pickKeys(_user, this.config.jwt.fields as any); const payload: JWTPayload = { ...user, @@ -257,7 +254,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), @@ -280,7 +277,9 @@ export class Authenticator< } return payload as any; - } catch (e) {} + } catch (e) { + $console.debug("Authenticator jwt verify error", String(e)); + } return; } @@ -290,6 +289,7 @@ export class Authenticator< return { ...cookieConfig, + domain: cookieConfig.domain ?? undefined, expires: new Date(Date.now() + expires * 1000), }; } @@ -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); @@ -354,7 +379,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) { @@ -378,13 +406,29 @@ export class Authenticator< } // @todo: don't extract user from token, but from the database or cache - async resolveAuthFromRequest(c: Context): Promise { + async resolveAuthFromRequest(c: Context | Request | Headers): Promise { + let headers: Headers; + let is_context = false; + if (c instanceof Headers) { + headers = c; + } else if (c instanceof Request) { + headers = c.headers; + } else { + is_context = true; + try { + headers = c.req.raw.headers; + } catch (e) { + throw new Exception("Request/Headers/Context is required to resolve auth", 400); + } + } + let token: string | undefined; - if (c.req.raw.headers.has("Authorization")) { - const bearerHeader = String(c.req.header("Authorization")); + if (headers.has("Authorization")) { + const bearerHeader = String(headers.get("Authorization")); token = bearerHeader.replace("Bearer ", ""); } else { - token = await this.getAuthCookie(c); + const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context); + token = await this.getAuthCookie(context); } if (token) { diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 1ee6d36..0c6066b 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,11 +1,10 @@ import type { User } from "bknd"; import type { Authenticator } from "auth/authenticate/Authenticator"; import { InvalidCredentialsException } from "auth/errors"; -import { hash, $console } from "core/utils"; +import { hash, $console, s, parse, jsc, describeRoute } from "bknd/utils"; import { Hono } from "hono"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { AuthStrategy } from "./Strategy"; -import { s, parse, jsc } from "bknd/utils"; const schema = s .object({ @@ -85,51 +84,67 @@ export class PasswordStrategy extends AuthStrategy { }); const payloadSchema = this.getPayloadSchema(); - hono.post("/login", jsc("query", redirectQuerySchema), async (c) => { - try { - const body = parse(payloadSchema, await authenticator.getBody(c), { - onError: (errors) => { - $console.error("Invalid login payload", [...errors]); - throw new InvalidCredentialsException(); - }, - }); - const { redirect } = c.req.valid("query"); - - return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { - redirect, - }); - } catch (e) { - return authenticator.respondWithError(c, e as any); - } - }); - - hono.post("/register", jsc("query", redirectQuerySchema), async (c) => { - try { - const { redirect } = c.req.valid("query"); - const { password, email, ...body } = parse( - payloadSchema, - await authenticator.getBody(c), - { + hono.post( + "/login", + describeRoute({ + summary: "Login with email and password", + tags: ["auth"], + }), + jsc("query", redirectQuerySchema), + async (c) => { + try { + const body = parse(payloadSchema, await authenticator.getBody(c), { onError: (errors) => { - $console.error("Invalid register payload", [...errors]); - new InvalidCredentialsException(); + $console.error("Invalid login payload", [...errors]); + throw new InvalidCredentialsException(); }, - }, - ); + }); + const { redirect } = c.req.valid("query"); - const profile = { - ...body, - email, - strategy_value: await this.hash(password), - }; + return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { + redirect, + }); + } catch (e) { + return authenticator.respondWithError(c, e as any); + } + }, + ); - return await authenticator.resolveRegister(c, this, profile, async () => void 0, { - redirect, - }); - } catch (e) { - return authenticator.respondWithError(c, e as any); - } - }); + hono.post( + "/register", + describeRoute({ + summary: "Register a new user with email and password", + tags: ["auth"], + }), + jsc("query", redirectQuerySchema), + async (c) => { + try { + const { redirect } = c.req.valid("query"); + const { password, email, ...body } = parse( + payloadSchema, + await authenticator.getBody(c), + { + onError: (errors) => { + $console.error("Invalid register payload", [...errors]); + new InvalidCredentialsException(); + }, + }, + ); + + const profile = { + ...body, + email, + strategy_value: await this.hash(password), + }; + + return await authenticator.resolveRegister(c, this, profile, async () => void 0, { + redirect, + }); + } catch (e) { + return authenticator.respondWithError(c, e as any); + } + }, + ); return hono; } diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 09d36fb..a8f91e3 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,9 +1,12 @@ import { Exception } from "core/errors"; -import { $console, objectTransform } from "core/utils"; -import { Permission } from "core/security/Permission"; +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"; -import { Role } from "./Role"; +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; @@ -12,41 +15,43 @@ export type GuardUserContext = { export type GuardConfig = { enabled?: boolean; + 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; } - 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); } @@ -73,7 +78,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`); } @@ -82,9 +87,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) { @@ -117,56 +126,216 @@ 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`); - } - - const role = this.getUserRole(user); - - if (!role) { - $console.debug("guard: user has no role, denying"); - return false; - } else if (role.implicit_allow === true) { - $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); - return true; - } - - 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: Permission | string, c?: GuardContext): boolean { + private collect(permission: Permission, c: GuardContext | undefined, context: any) { const user = c && "get" in c ? c.get("auth")?.user : c; - return this.hasPermission(permission as any, user); + 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, + }; } - throwUnlessGranted(permission: Permission | string, c: GuardContext) { - if (!this.granted(permission, c)) { - throw new Exception( - `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, - 403, + 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: _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, + context: ctx, + }); + if (!exists) { + throw new GuardPermissionsException( + permission, + undefined, + `Permission ${permission.name} does not exist`, ); } + + if (!role) { + throw new GuardPermissionsException(permission, undefined, "User has no role"); + } + + if (!rolePermission) { + if (role.implicit_allow === true) { + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); + return; + } + + throw new GuardPermissionsException( + permission, + undefined, + `Role "${role.name}" does not have required permission`, + ); + } + + 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) { + $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; + + // if allow, set allow but continue checking + } else if (policy.content.effect === "allow") { + allowed = true; + } + } else { + $console.debug("guard: policy does not meet condition"); + } + } + + if (!allowed) { + throw new GuardPermissionsException(permission, undefined, "Policy condition unmet"); + } + } + + $console.debug("guard allowing", { + permission: permission.name, + role: role.name, + }); + } + + filters

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

, + ); + filters

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

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

, + ) { + if (!permission.isFilterable()) { + throw new GuardPermissionsException(permission, undefined, "Permission is not filterable"); + } + + const { + ctx: _ctx, + exists, + role, + user, + rolePermission, + } = this.collect(permission, c, context); + + // validate context + let ctx = Object.assign( + { + user, + }, + _ctx, + ); + + if (permission.context) { + 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); + if (meets) { + policies.push(policy); + filters.push(policy.getReplacedFilter(ctx)); + } + } + } + } + + const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined; + return { + filters, + filter, + policies, + merge: (givenFilter: object | undefined) => { + return mergeFilters(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; + }, + }; } } + +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/auth/authorize/Permission.ts b/app/src/auth/authorize/Permission.ts new file mode 100644 index 0000000..cfd5963 --- /dev/null +++ b/app/src/auth/authorize/Permission.ts @@ -0,0 +1,77 @@ +import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils"; + +export const permissionOptionsSchema = s + .strictObject({ + description: s.string(), + filterable: s.boolean(), + }) + .partial(); + +export type TPermission = { + name: string; + description?: string; + filterable?: boolean; + context?: any; +}; + +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) { + // @todo: allow additional properties + if (!this.context) return ctx; + 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..06357f1 --- /dev/null +++ b/app/src/auth/authorize/Policy.ts @@ -0,0 +1,52 @@ +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>, + // @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>, + }) + .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, 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) { + 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); + } + + 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..7506fc7 100644 --- a/app/src/auth/authorize/Role.ts +++ b/app/src/auth/authorize/Role.ts @@ -1,10 +1,39 @@ -import { Permission } from "core/security/Permission"; +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; + +export const roleSchema = s.strictObject({ + // @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(), +}); +export type RoleSchema = s.Static; export class RolePermission { constructor( - public permission: Permission, - public config?: any, + 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, + }; + } } export class Role { @@ -15,31 +44,23 @@ 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(name: string, 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, p.effect); + }) ?? []; + return new Role(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 { + 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 53% rename from app/src/auth/middlewares.ts rename to app/src/auth/middlewares/auth.middleware.ts index 702023b..eeebe45 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares/auth.middleware.ts @@ -1,4 +1,3 @@ -import type { Permission } from "core/security/Permission"; import { $console, patternMatch } from "bknd/utils"; import type { Context } from "hono"; import { createMiddleware } from "hono/factory"; @@ -49,7 +48,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; @@ -67,48 +66,3 @@ export const auth = (options?: { authCtx.resolved = false; authCtx.user = undefined; }); - -export const permission = ( - permission: Permission | Permission[], - options?: { - onGranted?: (c: Context) => Promise; - onDenied?: (c: Context) => Promise; - }, -) => - // @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 permissions = Array.isArray(permission) ? permission : [permission]; - - if (options?.onGranted || options?.onDenied) { - let returned: undefined | void | Response; - if (permissions.every((p) => guard.granted(p, c))) { - returned = await options?.onGranted?.(c); - } else { - returned = await options?.onDenied?.(c); - } - if (returned instanceof Response) { - return returned; - } - } else { - permissions.some((p) => guard.throwUnlessGranted(p, c)); - } - } - - 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..c6e53f4 --- /dev/null +++ b/app/src/auth/middlewares/permission.middleware.ts @@ -0,0 +1,94 @@ +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"; +import { GuardPermissionsException } from "auth/authorize/Guard"; + +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), GuardPermissionsException)) { + 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/cli/commands/config.ts b/app/src/cli/commands/config.ts index 81e6cb7..ad9f428 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -1,15 +1,45 @@ import { getDefaultConfig } from "modules/ModuleManager"; import type { CliCommand } from "../types"; +import { makeAppFromEnv } from "cli/commands/run"; +import { writeFile } from "node:fs/promises"; +import c from "picocolors"; +import { withConfigOptions } from "cli/utils/options"; +import { $console } from "bknd/utils"; export const config: CliCommand = (program) => { - program - .command("config") - .description("get default config") + withConfigOptions(program.command("config")) + .description("get app config") .option("--pretty", "pretty print") - .action((options) => { - const config = getDefaultConfig(); + .option("--default", "use default config") + .option("--secrets", "include secrets in output") + .option("--out ", "output file") + .action(async (options) => { + let config: any = {}; - // biome-ignore lint/suspicious/noConsoleLog: - console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); + if (options.default) { + config = getDefaultConfig(); + } else { + const app = await makeAppFromEnv(options); + const manager = app.modules; + + if (options.secrets) { + $console.warn("Including secrets in output"); + config = manager.toJSON(true); + } else { + config = manager.extractSecrets().configs; + } + } + + config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config); + + console.info(""); + if (options.out) { + await writeFile(options.out, config); + console.info(`Config written to ${c.cyan(options.out)}`); + } else { + console.info(JSON.parse(config)); + } + + process.exit(0); }); }; diff --git a/app/src/cli/commands/copy-assets.ts b/app/src/cli/commands/copy-assets.ts index d9d1d12..0d9b27d 100644 --- a/app/src/cli/commands/copy-assets.ts +++ b/app/src/cli/commands/copy-assets.ts @@ -34,4 +34,5 @@ async function action(options: { out?: string; clean?: boolean }) { // biome-ignore lint/suspicious/noConsoleLog: console.log(c.green(`Assets copied to: ${c.bold(out)}`)); + process.exit(0); } diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index 217b07d..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: { @@ -20,6 +21,7 @@ const config = { node: "Node.js", bun: "Bun", cloudflare: "Cloudflare", + deno: "Deno", aws: "AWS Lambda", }, framework: { @@ -259,17 +261,19 @@ 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 = options.yes ?? (await $p.confirm({ 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/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts index 0bbab03..1cac856 100644 --- a/app/src/cli/commands/create/templates/cloudflare.ts +++ b/app/src/cli/commands/create/templates/cloudflare.ts @@ -1,7 +1,6 @@ import * as $p from "@clack/prompts"; -import { overrideJson, overridePackageJson } from "cli/commands/create/npm"; -import { typewriter, wait } from "cli/utils/cli"; -import { uuid } from "core/utils"; +import { overrideJson } from "cli/commands/create/npm"; +import { typewriter } from "cli/utils/cli"; import c from "picocolors"; import type { Template, TemplateSetupCtx } from "."; import { exec } from "cli/utils/sys"; 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/app/src/cli/commands/debug.ts b/app/src/cli/commands/debug.ts index a9a1e6f..412b485 100644 --- a/app/src/cli/commands/debug.ts +++ b/app/src/cli/commands/debug.ts @@ -40,7 +40,9 @@ const subjects = { async function action(subject: string) { if (subject in subjects) { await subjects[subject](); + process.exit(0); } else { console.error("Invalid subject: ", subject); + process.exit(1); } } diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index 9f63382..87b6fcf 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -6,3 +6,6 @@ export { user } from "./user"; export { create } from "./create"; export { copyAssets } from "./copy-assets"; export { types } from "./types"; +export { mcp } from "./mcp/mcp"; +export { sync } from "./sync"; +export { secrets } from "./secrets"; diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts new file mode 100644 index 0000000..0ec0564 --- /dev/null +++ b/app/src/cli/commands/mcp/mcp.ts @@ -0,0 +1,82 @@ +import type { CliCommand } from "cli/types"; +import { makeAppFromEnv } from "../run"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { $console, stdioTransport } from "bknd/utils"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; + +export const mcp: CliCommand = (program) => + withConfigOptions(program.command("mcp")) + .description("mcp server stdio transport") + .option( + "--token ", + "token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable", + ) + .option("--verbose", "verbose output") + .option("--log-level ", "log level") + .option("--force", "force enable mcp") + .action(action); + +async function action( + options: WithConfigOptions<{ + verbose?: boolean; + token?: string; + logLevel?: string; + force?: boolean; + }>, +) { + const verbose = !!options.verbose; + const __oldConsole = { ...console }; + + // disable console + if (!verbose) { + $console.disable(); + Object.entries(console).forEach(([key]) => { + console[key] = () => null; + }); + } + + const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, + server: "node", + }); + + if (!app.modules.get("server").config.mcp.enabled && !options.force) { + $console.enable(); + Object.assign(console, __oldConsole); + console.error("MCP is not enabled in the config, use --force to enable it"); + process.exit(1); + } + + const token = options.token || process.env.BEARER_TOKEN; + const server = getSystemMcp(app); + + if (verbose) { + console.info( + `\n⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`, + ); + console.info( + `📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`, + ); + console.info("\nMCP server is running on STDIO transport"); + } + + if (options.logLevel) { + server.setLogLevel(options.logLevel as any); + } + + const stdout = process.stdout; + const stdin = process.stdin; + const stderr = process.stderr; + + { + using transport = stdioTransport(server, { + stdin, + stdout, + stderr, + raw: new Request("https://localhost", { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }), + }); + } +} diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index bc3379b..ed2e1aa 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; @@ -9,18 +9,28 @@ export const PLATFORMS = ["node", "bun"] as const; export type Platform = (typeof PLATFORMS)[number]; export async function serveStatic(server: Platform): Promise { + const onNotFound = (path: string) => { + $console.debug("Couldn't resolve static file at", path); + }; + switch (server) { case "node": { const m = await import("@hono/node-server/serve-static"); + const root = getRelativeDistPath() + "/static"; + $console.debug("Serving static files from", root); return m.serveStatic({ // somehow different for node - root: getRelativeDistPath() + "/static", + root, + onNotFound, }); } case "bun": { const m = await import("hono/bun"); + const root = path.resolve(getRelativeDistPath(), "static"); + $console.debug("Serving static files from", root); return m.serveStatic({ - root: path.resolve(getRelativeDistPath(), "static"), + root, + onNotFound, }); } } @@ -66,6 +76,9 @@ export async function getConfigPath(filePath?: string) { const config_path = path.resolve(process.cwd(), filePath); if (await fileExists(config_path)) { return config_path; + } else { + $console.error(`Config file could not be resolved: ${config_path}`); + process.exit(1); } } diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 24c14b5..dbae9f9 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -2,9 +2,8 @@ import type { Config } from "@libsql/client/node"; import { StorageLocalAdapter } from "adapter/node/storage"; import type { CliBkndConfig, CliCommand } from "cli/types"; import { Option } from "commander"; -import { config, type App, type CreateAppConfig } from "bknd"; +import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd"; import dotenv from "dotenv"; -import { registries } from "modules/registries"; import c from "picocolors"; import path from "node:path"; import { @@ -17,6 +16,7 @@ import { } from "./platform"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { colorizeConsole, isBun } from "bknd/utils"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; const env_files = [".env", ".dev.vars"]; dotenv.config({ @@ -25,8 +25,7 @@ dotenv.config({ const is_bun = isBun(); export const run: CliCommand = (program) => { - program - .command("run") + withConfigOptions(program.command("run")) .description("run an instance") .addOption( new Option("-p, --port ", "port to run on") @@ -41,12 +40,6 @@ export const run: CliCommand = (program) => { "db-token", ]), ) - .addOption(new Option("-c, --config ", "config file")) - .addOption( - new Option("--db-url ", "database url, can be any valid sqlite url").conflicts( - "config", - ), - ) .addOption( new Option("--server ", "server type") .choices(PLATFORMS) @@ -66,7 +59,7 @@ type MakeAppConfig = { connection?: CreateAppConfig["connection"]; server?: { platform?: Platform }; setAdminHtml?: boolean; - onBuilt?: (app: App) => Promise; + onBuilt?: (app: App) => MaybePromise; }; async function makeApp(config: MakeAppConfig) { @@ -77,21 +70,21 @@ async function makeApp(config: MakeAppConfig) { } export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { - const config = makeConfig(_config, process.env); + const config = await makeConfig(_config, process.env); return makeApp({ ...config, server: { platform }, }); } -type RunOptions = { +type RunOptions = WithConfigOptions<{ port: number; memory?: boolean; config?: string; dbUrl?: string; server: Platform; open?: boolean; -}; +}>; export async function makeAppFromEnv(options: Partial = {}) { const configFilePath = await getConfigPath(options.config); @@ -117,7 +110,10 @@ export async function makeAppFromEnv(options: Partial = {}) { // try to use an in-memory connection } else if (options.memory) { console.info("Using", c.cyan("in-memory"), "connection"); - app = await makeApp({ server: { platform: options.server } }); + app = await makeApp({ + server: { platform: options.server }, + connection: { url: ":memory:" }, + }); // finally try to use env variables } else { diff --git a/app/src/cli/commands/schema.ts b/app/src/cli/commands/schema.ts index 5dceee9..f9fd510 100644 --- a/app/src/cli/commands/schema.ts +++ b/app/src/cli/commands/schema.ts @@ -8,7 +8,7 @@ export const schema: CliCommand = (program) => { .option("--pretty", "pretty print") .action((options) => { const schema = getDefaultSchema(); - // biome-ignore lint/suspicious/noConsoleLog: - console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); + console.info(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); + process.exit(0); }); }; diff --git a/app/src/cli/commands/secrets.ts b/app/src/cli/commands/secrets.ts new file mode 100644 index 0000000..ceae59f --- /dev/null +++ b/app/src/cli/commands/secrets.ts @@ -0,0 +1,59 @@ +import type { CliCommand } from "../types"; +import { makeAppFromEnv } from "cli/commands/run"; +import { writeFile } from "node:fs/promises"; +import c from "picocolors"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; +import { transformObject } from "bknd/utils"; +import { Option } from "commander"; + +export const secrets: CliCommand = (program) => { + withConfigOptions(program.command("secrets")) + .description("get app secrets") + .option("--template", "template output without the actual secrets") + .addOption( + new Option("--format ", "format output").choices(["json", "env"]).default("json"), + ) + .option("--out ", "output file") + .action( + async ( + options: WithConfigOptions<{ template: string; format: "json" | "env"; out: string }>, + ) => { + const app = await makeAppFromEnv(options); + const manager = app.modules; + + let secrets = manager.extractSecrets().secrets; + if (options.template) { + secrets = transformObject(secrets, () => ""); + } + + console.info(""); + if (options.out) { + if (options.format === "env") { + await writeFile( + options.out, + Object.entries(secrets) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ); + } else { + await writeFile(options.out, JSON.stringify(secrets, null, 2)); + } + console.info(`Secrets written to ${c.cyan(options.out)}`); + } else { + if (options.format === "env") { + console.info( + c.cyan( + Object.entries(secrets) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ), + ); + } else { + console.info(secrets); + } + } + console.info(""); + process.exit(0); + }, + ); +}; diff --git a/app/src/cli/commands/sync.ts b/app/src/cli/commands/sync.ts new file mode 100644 index 0000000..8b8c5c4 --- /dev/null +++ b/app/src/cli/commands/sync.ts @@ -0,0 +1,66 @@ +import type { CliCommand } from "../types"; +import { makeAppFromEnv } from "cli/commands/run"; +import { writeFile } from "node:fs/promises"; +import c from "picocolors"; +import { withConfigOptions } from "cli/utils/options"; + +export const sync: CliCommand = (program) => { + withConfigOptions(program.command("sync")) + .description("sync database") + .option("--force", "perform database syncing operations") + .option("--seed", "perform seeding operations") + .option("--drop", "include destructive DDL operations") + .option("--out ", "output file") + .option("--sql", "use sql output") + .action(async (options) => { + const app = await makeAppFromEnv(options); + const schema = app.em.schema(); + console.info(c.dim("Checking database state...")); + const stmts = await schema.sync({ drop: options.drop }); + + console.info(""); + if (stmts.length === 0) { + console.info(c.yellow("No changes to sync")); + process.exit(0); + } + // @todo: currently assuming parameters aren't used + const sql = stmts.map((d) => d.sql).join(";\n") + ";"; + + if (options.force) { + console.info(c.dim("Executing:") + "\n" + c.cyan(sql)); + await schema.sync({ force: true, drop: options.drop }); + + console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); + console.info(`${c.green("Database synced")}`); + + if (options.seed) { + console.info(c.dim("\nExecuting seed...")); + const seed = app.options?.seed; + if (seed) { + await app.options?.seed?.({ + ...app.modules.ctx(), + app: app, + }); + console.info(c.green("Seed executed")); + } else { + console.info(c.yellow("No seed function provided")); + } + } + } else { + if (options.out) { + const output = options.sql ? sql : JSON.stringify(stmts, null, 2); + await writeFile(options.out, output); + console.info(`SQL written to ${c.cyan(options.out)}`); + } else { + console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql)); + + console.info( + c.yellow( + "\nNo statements have been executed. Use --force to perform database syncing operations", + ), + ); + } + } + process.exit(0); + }); +}; diff --git a/app/src/cli/commands/types/types.ts b/app/src/cli/commands/types/types.ts index 3d53618..ebadf35 100644 --- a/app/src/cli/commands/types/types.ts +++ b/app/src/cli/commands/types/types.ts @@ -4,34 +4,37 @@ import { makeAppFromEnv } from "cli/commands/run"; import { EntityTypescript } from "data/entities/EntityTypescript"; import { writeFile } from "cli/utils/sys"; import c from "picocolors"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; export const types: CliCommand = (program) => { - program - .command("types") + withConfigOptions(program.command("types")) .description("generate types") .addOption(new Option("-o, --outfile ", "output file").default("bknd-types.d.ts")) - .addOption(new Option("--no-write", "do not write to file").default(true)) + .addOption(new Option("--dump", "dump types to console instead of writing to file")) .action(action); }; async function action({ outfile, - write, -}: { + dump, + ...options +}: WithConfigOptions<{ outfile: string; - write: boolean; -}) { + dump: boolean; +}>) { const app = await makeAppFromEnv({ server: "node", + ...options, }); - await app.build(); const et = new EntityTypescript(app.em); - if (write) { + if (dump) { + console.info(et.toString()); + } else { await writeFile(outfile, et.toString()); console.info(`\nTypes written to ${c.cyan(outfile)}`); - } else { - console.info(et.toString()); } + + process.exit(0); } diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 4f4db7c..726748b 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -3,19 +3,19 @@ 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"; import { makeAppFromEnv } from "cli/commands/run"; import type { CliCommand } from "cli/types"; import { Argument } from "commander"; -import { $console } from "core/utils"; +import { $console, isBun } from "bknd/utils"; import c from "picocolors"; -import { isBun } from "core/utils"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; export const user: CliCommand = (program) => { - program - .command("user") + withConfigOptions(program.command("user")) .description("create/update users, or generate a token (auth)") .addArgument( new Argument("", "action to perform").choices(["create", "update", "token"]), @@ -23,11 +23,18 @@ export const user: CliCommand = (program) => { .action(action); }; -async function action(action: "create" | "update" | "token", options: any) { +async function action(action: "create" | "update" | "token", options: WithConfigOptions) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); + if (!app.module.auth.enabled) { + $log.error("Auth is not enabled"); + process.exit(1); + } + switch (action) { case "create": await create(app, options); @@ -42,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: any) { } 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"); @@ -75,19 +103,19 @@ 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); } catch (e) { $log.error("Error creating user"); $console.error(e); + process.exit(1); } } async function update(app: App, options: any) { const config = app.module.auth.toJSON(true); - const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name as "users"; - const em = app.modules.ctx().em; const email = (await $text({ message: "Which user? Enter email", @@ -100,7 +128,10 @@ async function update(app: App, options: any) { })) as string; if ($isCancel(email)) process.exit(1); - const { data: user } = await em.repository(users_entity).findOne({ email }); + const { data: user } = await app.modules + .ctx() + .em.repository(config.entity_name as "users") + .findOne({ email }); if (!user) { $log.error("User not found"); process.exit(1); @@ -118,26 +149,12 @@ async function update(app: App, options: any) { }); if ($isCancel(password)) process.exit(1); - try { - function togglePw(visible: boolean) { - const field = em.entity(users_entity).field("strategy_value")!; - - field.config.hidden = !visible; - field.config.fillable = visible; - } - togglePw(true); - await app.modules - .ctx() - .em.mutator(users_entity) - .updateOne(user.id, { - strategy_value: await strategy.hash(password as string), - }); - togglePw(false); - + if (await app.module.auth.changePassword(user.id, password)) { $log.success(`Updated user: ${c.cyan(user.email)}`); - } catch (e) { + process.exit(0); + } else { $log.error("Error updating user"); - $console.error(e); + process.exit(1); } } @@ -173,4 +190,5 @@ async function token(app: App, options: any) { console.log( `\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`, ); + process.exit(0); } diff --git a/app/src/cli/index.ts b/app/src/cli/index.ts index 352cdd4..3385713 100644 --- a/app/src/cli/index.ts +++ b/app/src/cli/index.ts @@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys"; import { capture, flush, init } from "cli/utils/telemetry"; const program = new Command(); -export async function main() { +async function main() { await init(); capture("start"); diff --git a/app/src/cli/utils/options.ts b/app/src/cli/utils/options.ts new file mode 100644 index 0000000..26a9abb --- /dev/null +++ b/app/src/cli/utils/options.ts @@ -0,0 +1,16 @@ +import { type Command, Option } from "commander"; + +export function withConfigOptions(program: Command) { + return program + .addOption(new Option("-c, --config ", "config file")) + .addOption( + new Option("--db-url ", "database url, can be any valid sqlite url").conflicts( + "config", + ), + ); +} + +export type WithConfigOptions = { + config?: string; + dbUrl?: string; +} & CustomOptions; diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 56ae32e..e1fd340 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -1,4 +1,4 @@ -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { execSync, exec as nodeExec } from "node:child_process"; import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import path from "node:path"; @@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") { return JSON.parse(pkg).version ?? "preview"; } } catch (e) { - console.error("Failed to resolve version"); + //console.error("Failed to resolve version"); } return "unknown"; diff --git a/app/src/core/config.ts b/app/src/core/config.ts index 99a9013..581bea1 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -11,10 +11,12 @@ export interface AppEntity { export interface DB { // make sure to make unknown as "any" - [key: string]: { + /* [key: string]: { id: PrimaryFieldType; [key: string]: any; - }; + }; */ + // @todo: that's not good, but required for admin options + [key: string]: any; } export const config = { diff --git a/app/src/core/drivers/email/mailchannels.ts b/app/src/core/drivers/email/mailchannels.ts index 7478ef5..540355a 100644 --- a/app/src/core/drivers/email/mailchannels.ts +++ b/app/src/core/drivers/email/mailchannels.ts @@ -1,4 +1,4 @@ -import { mergeObject, type RecursivePartial } from "core/utils"; +import { mergeObject, type RecursivePartial } from "bknd/utils"; import type { IEmailDriver } from "./index"; export type MailchannelsEmailOptions = { diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 78db931..1ac8bc4 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,6 +1,6 @@ import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export type RegisterListenerConfig = | ListenerMode @@ -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/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index c22b811..cf40bcf 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -27,7 +27,7 @@ export class SchemaObject { ) { this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any); this._value = deepFreeze( - parse(_schema, structuredClone(initial ?? {}), { + parse(_schema, initial ?? {}, { withDefaults: true, //withExtendedDefaults: true, forceParse: this.isForceParse(), @@ -177,7 +177,6 @@ export class SchemaObject { this.throwIfRestricted(partial); - // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index e90921d..110cce6 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, isPlainObject } from "bknd/utils"; export type Primitive = PrimaryFieldType | string | number | boolean; export function isPrimitive(value: any): value is Primitive { @@ -25,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); } @@ -50,7 +55,7 @@ function getExpression( } type LiteralExpressionCondition = { - [key: string]: Primitive | ExpressionCondition; + [key: string]: undefined | Primitive | ExpressionCondition; }; const OperandOr = "$or" as const; @@ -67,8 +72,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 = {}; @@ -83,13 +89,21 @@ 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)) { + // skip undefined values + if (value === undefined) { + continue; + } + // 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 @@ -98,7 +112,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), @@ -112,9 +126,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)}"`, + ); } } @@ -149,15 +167,19 @@ 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); } // 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 : 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 +187,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])); @@ -189,6 +211,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) => diff --git a/app/src/core/security/Permission.ts b/app/src/core/security/Permission.ts deleted file mode 100644 index 86cf46b..0000000 --- a/app/src/core/security/Permission.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class Permission { - constructor(public name: Name) { - this.name = name; - } - - toJSON() { - return { - name: this.name, - }; - } -} diff --git a/app/src/core/test/index.ts b/app/src/core/test/index.ts index 4e9bfef..0afecb2 100644 --- a/app/src/core/test/index.ts +++ b/app/src/core/test/index.ts @@ -31,6 +31,7 @@ export type TestRunner = { beforeEach: (fn: () => MaybePromise) => void; afterEach: (fn: () => MaybePromise) => void; afterAll: (fn: () => MaybePromise) => void; + beforeAll: (fn: () => MaybePromise) => void; }; export async function retry( diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index d4cefa9..c7971e2 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -1,12 +1,37 @@ -import { createApp as createAppInternal, type CreateAppConfig } from "App"; -import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection"; -import { Connection } from "data/connection/Connection"; +import { Connection, createApp as createAppInternal, type CreateAppConfig } from "bknd"; +import { bunSqlite } from "bknd/adapter/bun"; +import type { McpServer } from "bknd/utils"; -export { App } from "App"; +export { App } from "bknd"; export function createApp({ connection, ...config }: CreateAppConfig = {}) { return createAppInternal({ ...config, - connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any), + connection: Connection.isConnection(connection) + ? connection + : (bunSqlite(connection as any) as any), }); } + +export function createMcpToolCaller() { + return async (server: McpServer, name: string, args: any, raw?: any) => { + const res = await server.handle( + { + jsonrpc: "2.0", + method: "tools/call", + params: { + name, + arguments: args, + }, + }, + raw, + ); + + if ((res.result as any)?.isError) { + console.dir(res.result, { depth: null }); + throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error"); + } + + return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null"); + }; +} diff --git a/app/src/core/types.ts b/app/src/core/types.ts index 1751766..c0550db 100644 --- a/app/src/core/types.ts +++ b/app/src/core/types.ts @@ -4,3 +4,9 @@ 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/core/utils/console.ts b/app/src/core/utils/console.ts index b07fa2c..4932ada 100644 --- a/app/src/core/utils/console.ts +++ b/app/src/core/utils/console.ts @@ -76,6 +76,7 @@ declare global { | { level: TConsoleSeverity; id?: string; + enabled?: boolean; } | undefined; } @@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity; // biome-ignore lint/suspicious/noAssignInExpressions: const config = (globalThis.__consoleConfig ??= { level: defaultLevel, + enabled: true, //id: crypto.randomUUID(), // for debugging }); @@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, { switch (prop) { case "original": return console; + case "disable": + return () => { + config.enabled = false; + }; + case "enable": + return () => { + config.enabled = true; + }; case "setLevel": return (l: TConsoleSeverity) => { config.level = l; @@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, { }; } + if (!config.enabled) { + return () => null; + } + const current = keys.indexOf(config.level); const requested = keys.indexOf(prop as string); @@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, { } & { setLevel: (l: TConsoleSeverity) => void; resetLevel: () => void; + disable: () => void; + enable: () => void; }; export function colorizeConsole(con: typeof console) { diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index ea5eb2b..a2093c0 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -1,7 +1,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; -import { randomString } from "core/utils/strings"; +import { randomString } from "./strings"; import type { Context } from "hono"; -import { invariant } from "core/utils/runtime"; +import { invariant } from "./runtime"; import { $console } from "./console"; export function getContentName(request: Request): string | undefined; @@ -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/core/utils/index.ts b/app/src/core/utils/index.ts index 36928c5..163a148 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -13,18 +13,5 @@ export * from "./uuid"; export * from "./test"; export * from "./runtime"; export * from "./numbers"; -export { - s, - stripMark, - mark, - stringIdentifier, - SecretSchema, - secret, - parse, - jsc, - describeRoute, - schemaToSpec, - openAPISpecs, - type ParseOptions, - InvalidSchemaError, -} from "./schema"; +export * from "./schema"; +export { DebugLogger } from "./DebugLogger"; diff --git a/app/src/core/utils/numbers.ts b/app/src/core/utils/numbers.ts index e9b458b..8e9c038 100644 --- a/app/src/core/utils/numbers.ts +++ b/app/src/core/utils/numbers.ts @@ -14,10 +14,10 @@ export function ensureInt(value?: string | number | null | undefined): number { export const formatNumber = { fileSize: (bytes: number, decimals = 2): string => { - if (bytes === 0) return "0 Bytes"; + if (bytes === 0) return "0 B"; const k = 1024; const dm = decimals < 0 ? 0 : decimals; - const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i]; }, diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 2bf1e60..33c6a43 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -26,6 +26,20 @@ export function omitKeys( return result; } +export function pickKeys( + obj: T, + keys_: readonly K[], +): Pick> { + const keys = new Set(keys_); + const result = {} as Pick>; + for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { + if (keys.has(key as K)) { + (result as any)[key] = value; + } + } + return result; +} + export function safelyParseObjectValues(obj: T): T { return Object.entries(obj).reduce((acc, [key, value]) => { try { @@ -189,6 +203,30 @@ export function objectDepth(object: object): number { return level; } +export function limitObjectDepth(obj: T, maxDepth: number): T { + function _limit(current: any, depth: number): any { + if (isPlainObject(current)) { + if (depth > maxDepth) { + return undefined; + } + const result: any = {}; + for (const key in current) { + if (Object.prototype.hasOwnProperty.call(current, key)) { + result[key] = _limit(current[key], depth + 1); + } + } + return result; + } + if (Array.isArray(current)) { + // Arrays themselves are not limited, but their object elements are + return current.map((item) => _limit(item, depth)); + } + // Primitives are always returned, regardless of depth + return current; + } + return _limit(obj, 1); +} + export function objectCleanEmpty(obj: Obj): Obj { if (!obj) return obj; return Object.entries(obj).reduce((acc, [key, value]) => { @@ -334,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; @@ -358,6 +396,38 @@ export function getPath( } } +export function setPath(object: object, _path: string | (string | number)[], value: any) { + let path = _path; + // Optional string-path support. + // You can remove this `if` block if you don't need it. + if (typeof path === "string") { + const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"'; + path = path + .split(/[.\[\]]+/) + .filter((x) => x) + .map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x)) + .map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x)); + } + + if (path.length === 0) { + throw new Error("The path must have at least one entry in it"); + } + + const [head, ...tail] = path as any; + + if (tail.length === 0) { + object[head] = value; + return object; + } + + if (!(head in object)) { + object[head] = typeof tail[0] === "number" ? [] : {}; + } + + setPath(object[head], tail, value); + return object; +} + export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string { const nl = indent ? "\n" : ""; const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : ""); @@ -435,3 +505,50 @@ export function deepFreeze(object: T): T { return Object.freeze(object); } + +export function convertNumberedObjectToArray(obj: object): any[] | object { + if (Object.keys(obj).every((key) => Number.isInteger(Number(key)))) { + return Object.values(obj); + } + return obj; +} + +export function recursivelyReplacePlaceholders( + obj: any, + pattern: RegExp, + variables: Record, + fallback?: any, +) { + 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, 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, null); + // convert to string for partial replacements + return value !== null + ? String(value) + : fallback !== undefined + ? String(fallback) + : match; + }); + } + } + if (Array.isArray(obj)) { + 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, fallback); + return acc; + }, {} as object); + } + return obj; +} diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 0772abd..5b943ff 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -61,3 +61,19 @@ export function invariant(condition: boolean | any, message: string) { throw new Error(message); } } + +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; + } +} diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 0382700..ff8190c 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -1,14 +1,58 @@ +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"; -export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono"; +export { describeRoute, schemaToSpec, openAPISpecs, info } from "jsonv-ts/hono"; +export { + mcp, + McpServer, + Resource, + Tool, + mcpTool, + mcpResource, + getMcpServer, + stdioTransport, + McpClient, + logLevels as mcpLogLevels, + type McpClientConfig, + type ToolAnnotation, + type ToolHandlerCtx, +} from "jsonv-ts/mcp"; export { secret, SecretSchema } from "./secret"; export { s }; -export const stripMark = (o: O): O => o; -export const mark = (o: O): O => o; +const symbol = Symbol("bknd-validation-mark"); + +export function stripMark(obj: O) { + const newObj = structuredClone(obj); + mark(newObj, false); + return newObj as O; +} + +export function mark(obj: any, validated = true) { + try { + if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) { + if (validated) { + obj[symbol] = true; + } else { + delete obj[symbol]; + } + for (const key in obj) { + if (typeof obj[key] === "object" && obj[key] !== null) { + mark(obj[key], validated); + } + } + } + } catch (e) {} +} + +export function isMarked(obj: any) { + if (typeof obj !== "object" || obj === null) return false; + return obj[symbol] === true; +} export const stringIdentifier = s.string({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$", @@ -16,7 +60,10 @@ 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, public value: unknown, @@ -24,7 +71,8 @@ export class InvalidSchemaError extends Error { ) { super( `Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` + - `Error: ${JSON.stringify(errors[0], null, 2)}`, + `Error: ${JSON.stringify(errors[0], null, 2)}\n\n` + + `Schema: ${JSON.stringify(schema.toJSON(), null, 2)}`, ); } @@ -59,6 +107,10 @@ export function parse : s.Static { + if (!opts?.forceParse && !opts?.coerce && isMarked(v)) { + return v as any; + } + const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema; let value = opts?.coerce !== false diff --git a/app/src/core/utils/schema/secret.ts b/app/src/core/utils/schema/secret.ts index 7eae592..6fcdf14 100644 --- a/app/src/core/utils/schema/secret.ts +++ b/app/src/core/utils/schema/secret.ts @@ -1,6 +1,7 @@ -import { StringSchema, type IStringOptions } from "jsonv-ts"; +import type { s } from "bknd/utils"; +import { StringSchema } from "jsonv-ts"; -export class SecretSchema extends StringSchema {} +export class SecretSchema extends StringSchema {} -export const secret = (o?: O): SecretSchema & O => +export const secret = (o?: O): SecretSchema & O => new SecretSchema(o) as any; diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index 44d38d9..5107c4b 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -6,6 +6,8 @@ const _oldConsoles = { warn: console.warn, error: console.error, }; +let _oldStderr: any; +let _oldStdout: any; export async function withDisabledConsole( fn: () => Promise, @@ -36,10 +38,17 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn" severities.forEach((severity) => { console[severity] = () => null; }); + // Disable stderr + _oldStderr = process.stderr.write; + _oldStdout = process.stdout.write; + process.stderr.write = () => true; + process.stdout.write = () => true; $console?.setLevel("critical"); } export function enableConsoleLog() { + process.stderr.write = _oldStderr; + process.stdout.write = _oldStdout; Object.entries(_oldConsoles).forEach(([severity, fn]) => { console[severity as ConsoleSeverity] = fn; }); diff --git a/app/src/core/utils/uuid.ts b/app/src/core/utils/uuid.ts index e3112e2..99511af 100644 --- a/app/src/core/utils/uuid.ts +++ b/app/src/core/utils/uuid.ts @@ -1,10 +1,16 @@ -import { v4, v7 } from "uuid"; +import { v4, v7, validate, version as uuidVersion } from "uuid"; // generates v4 export function uuid(): string { - return v4(); + return v4(); } +// generates v7 export function uuidv7(): string { - return v7(); + return v7(); +} + +// validate uuid +export function uuidValidate(uuid: string, version: 4 | 7): boolean { + return validate(uuid) && uuidVersion(uuid) === version; } diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 0b4e464..fbe7514 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,5 +1,4 @@ -import { transformObject } from "core/utils"; - +import { transformObject } from "bknd/utils"; import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; import { type AppDataConfig, dataConfigSchema } from "./data-schema"; @@ -49,10 +48,9 @@ export class AppData extends Module { this.ctx.em.addIndex(index); } - this.ctx.server.route( - this.basepath, - new DataController(this.ctx, this.config).getController(), - ); + const dataController = new DataController(this.ctx, this.config); + dataController.registerMcp(); + this.ctx.server.route(this.basepath, dataController.getController()); this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.setBuilt(); diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index b4deb5d..de88812 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -42,6 +42,9 @@ export class DataApi extends ModuleApi { ) { type Data = E extends keyof DB ? Selectable : EntityData; type T = RepositoryResultJSON; + + // @todo: if none found, still returns meta... + return this.readMany(entity, { ...query, limit: 1, diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index b468a08..082ae0c 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,12 +1,21 @@ -import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; -import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils"; +import { + jsc, + s, + describeRoute, + schemaToSpec, + omitKeys, + pickKeys, + mcpTool, + convertNumberedObjectToArray, +} from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; 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( @@ -34,17 +43,9 @@ 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); - // @todo: sample implementation how to augment handler with additional info - function handler(name: string, h: HH): any { - const func = h; - // @ts-ignore - func.description = name; - return func; - } - // info hono.get( "/", @@ -52,16 +53,19 @@ export class DataController extends Controller { summary: "Retrieve data configuration", tags: ["data"], }), - handler("data info", (c) => { - // sample implementation - return c.json(this.em.toJSON()); - }), + (c) => c.json(this.em.toJSON()), ); // sync endpoint hono.get( "/sync", - permission(DataPermissions.databaseSync), + permission(DataPermissions.databaseSync, {}), + mcpTool("data_sync", { + // @todo: should be removed if readonly + annotations: { + destructiveHint: true, + }, + }), describeRoute({ summary: "Sync database schema", tags: ["data"], @@ -77,9 +81,7 @@ export class DataController extends Controller { ), async (c) => { const { force, drop } = c.req.valid("query"); - //console.log("force", force); const tables = await this.em.schema().introspect(); - //console.log("tables", tables); const changes = await this.em.schema().sync({ force, drop, @@ -94,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"], @@ -120,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"], @@ -152,6 +158,22 @@ export class DataController extends Controller { }, ); + hono.get( + "/types", + permission(SystemPermissions.schemaRead, { + context: (c) => ({ module: "data" }), + }), + 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()); @@ -160,11 +182,14 @@ 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"], }), + mcpTool("data_entity_info"), jsc("param", s.object({ entity: entitiesEnum })), async (c) => { const { entity } = c.req.param(); @@ -201,7 +226,9 @@ export class DataController extends Controller { const entitiesEnum = this.getEntitiesEnum(this.em); // @todo: make dynamic based on entity - const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string }); + const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], { + coerce: (v) => v as number | string, + }); /** * Function endpoints @@ -209,11 +236,14 @@ 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"], }), + mcpTool("data_entity_fn_count"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { @@ -231,11 +261,14 @@ 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"], }), + mcpTool("data_entity_fn_exists"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { @@ -268,6 +301,9 @@ export class DataController extends Controller { (p) => pick.includes(p.name), ) as any), ]; + const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => { + return s.object(pickKeys(saveRepoQuery.properties, pick as any)); + }; hono.get( "/:entity", @@ -276,16 +312,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 }); }, @@ -299,7 +345,16 @@ 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 }), + query: saveRepoQuerySchema(["offset", "sort", "select"]), + }, + noErrorCodes: [404], + }), jsc( "param", s.object({ @@ -310,11 +365,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 }); }, @@ -328,7 +391,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({ @@ -345,9 +410,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 }); }, @@ -374,7 +450,15 @@ 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 }), + json: fnQuery, + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery, { skipOpenAPI: true }), async (c) => { @@ -383,7 +467,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 }); }, @@ -399,7 +489,10 @@ 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({}))])), async (c) => { @@ -407,7 +500,19 @@ export class DataController extends Controller { if (!this.entityExists(entity)) { return this.notFound(c); } - const body = (await c.req.json()) as EntityData | EntityData[]; + + const _body = (await c.req.json()) as EntityData | EntityData[]; + // @todo: check on jsonv-ts how to handle this better + // temporary fix for numbered object to array + // this happens when the MCP tool uses the allOf function + // 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); @@ -426,7 +531,18 @@ 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 }), + json: s.object({ + update: s.object({}), + where: s.object({}), + }), + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc( "json", @@ -444,7 +560,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); }, @@ -457,7 +576,10 @@ 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({})), async (c) => { @@ -466,6 +588,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); @@ -479,13 +612,28 @@ 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) => { const { entity, id } = c.req.valid("param"); 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); @@ -499,7 +647,15 @@ 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 }), + json: s.object({}), + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { @@ -508,7 +664,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); }, @@ -516,4 +675,35 @@ export class DataController extends Controller { return hono; } + + override registerMcp() { + this.ctx.mcp + .resource( + "data_entities", + "bknd://data/entities", + (c) => c.json(c.context.ctx().em.toJSON().entities), + { + title: "Entities", + description: "Retrieve all entities", + }, + ) + .resource( + "data_relations", + "bknd://data/relations", + (c) => c.json(c.context.ctx().em.toJSON().relations), + { + title: "Relations", + description: "Retrieve all relations", + }, + ) + .resource( + "data_indices", + "bknd://data/indices", + (c) => c.json(c.context.ctx().em.toJSON().indices), + { + title: "Indices", + description: "Retrieve all indices", + }, + ); + } } diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index 7db9227..1cc8b52 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -230,3 +230,15 @@ export function customIntrospector>( }, }; } + +export class DummyConnection extends Connection { + override name = "dummy"; + + constructor() { + super(undefined as any); + } + + override getFieldSchema(): SchemaResponse { + throw new Error("Method not implemented."); + } +} diff --git a/app/src/data/connection/connection-test-suite.ts b/app/src/data/connection/connection-test-suite.ts index af1eeba..270ccb0 100644 --- a/app/src/data/connection/connection-test-suite.ts +++ b/app/src/data/connection/connection-test-suite.ts @@ -1,9 +1,10 @@ import type { TestRunner } from "core/test"; import { Connection, type FieldSpec } from "./Connection"; -import { getPath } from "core/utils"; +import { getPath } from "bknd/utils"; import * as proto from "data/prototype"; import { createApp } from "App"; import type { MaybePromise } from "core/types"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; // @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc. // @todo: add toDriver/fromDriver tests on all types and fields @@ -21,7 +22,9 @@ export function connectionTestSuite( rawDialectDetails: string[]; }, ) { - const { test, expect, describe, beforeEach, afterEach, afterAll } = testRunner; + const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner; + beforeAll(() => disableConsoleLog()); + afterAll(() => enableConsoleLog()); describe("base", () => { let ctx: Awaited>; @@ -247,7 +250,7 @@ export function connectionTestSuite( const app = createApp({ connection: ctx.connection, - initialConfig: { + config: { data: schema.toJSON(), }, }); @@ -333,7 +336,7 @@ export function connectionTestSuite( const app = createApp({ connection: ctx.connection, - initialConfig: { + config: { data: schema.toJSON(), }, }); diff --git a/app/src/data/connection/index.ts b/app/src/data/connection/index.ts index a55d135..969611a 100644 --- a/app/src/data/connection/index.ts +++ b/app/src/data/connection/index.ts @@ -9,6 +9,7 @@ export { type ConnQueryResults, customIntrospector, } from "./Connection"; +export { DummyConnection } from "./DummyConnection"; // sqlite export { SqliteConnection } from "./sqlite/SqliteConnection"; diff --git a/app/src/data/connection/sqlite/GenericSqliteConnection.ts b/app/src/data/connection/sqlite/GenericSqliteConnection.ts index 98a584b..0ec5212 100644 --- a/app/src/data/connection/sqlite/GenericSqliteConnection.ts +++ b/app/src/data/connection/sqlite/GenericSqliteConnection.ts @@ -1,7 +1,6 @@ import type { KyselyPlugin, QueryResult } from "kysely"; import { type IGenericSqlite, - type OnCreateConnection, type Promisable, parseBigInt, buildQueryFn, @@ -9,6 +8,7 @@ import { } from "kysely-generic-sqlite"; import { SqliteConnection } from "./SqliteConnection"; import type { ConnQuery, ConnQueryResults, Features } from "../Connection"; +import type { MaybePromise } from "bknd"; export type { IGenericSqlite }; export type TStatement = { sql: string; parameters?: any[] | readonly any[] }; @@ -16,11 +16,11 @@ export interface IGenericCustomSqlite extends IGenericSqlite { batch?: (stmts: TStatement[]) => Promisable[]>; } -export type GenericSqliteConnectionConfig = { +export type GenericSqliteConnectionConfig = { name?: string; additionalPlugins?: KyselyPlugin[]; excludeTables?: string[]; - onCreateConnection?: OnCreateConnection; + onCreateConnection?: (db: Database) => MaybePromise; supports?: Partial; }; @@ -35,7 +35,12 @@ export class GenericSqliteConnection extends SqliteConnection ) { super({ dialect: GenericSqliteDialect, - dialectArgs: [executor, config?.onCreateConnection], + dialectArgs: [ + executor, + config?.onCreateConnection && typeof config.onCreateConnection === "function" + ? (c: any) => config.onCreateConnection?.(c.db.db as any) + : undefined, + ], additionalPlugins: config?.additionalPlugins, excludeTables: config?.excludeTables, }); @@ -61,7 +66,6 @@ export class GenericSqliteConnection extends SqliteConnection override async executeQueries(...qbs: O): Promise> { const executor = await this.getExecutor(); if (!executor.batch) { - //$console.debug("Batching is not supported by this database"); return super.executeQueries(...qbs); } diff --git a/app/src/data/connection/sqlite/SqliteIntrospector.ts b/app/src/data/connection/sqlite/SqliteIntrospector.ts index 70c3ff6..8ff2688 100644 --- a/app/src/data/connection/sqlite/SqliteIntrospector.ts +++ b/app/src/data/connection/sqlite/SqliteIntrospector.ts @@ -55,7 +55,8 @@ export class SqliteIntrospector extends BaseIntrospector { )) FROM pragma_index_info(i.name) ii) )) FROM pragma_index_list(m.name) i LEFT JOIN sqlite_master im ON im.name = i.name - AND im.type = 'index' + AND im.type = 'index' + WHERE i.name not like 'sqlite_%' ) AS indices FROM sqlite_master m WHERE m.type IN ('table', 'view') diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 7b5c0d8..f416da6 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -1,16 +1,18 @@ -import { objectTransform } from "core/utils"; +import { objectTransform } from "bknd/utils"; import { MediaField, mediaFieldConfigSchema } from "../media/MediaField"; import { FieldClassMap } from "data/fields"; import { RelationClassMap, RelationFieldClassMap } from "data/relations"; import { entityConfigSchema, entityTypes } from "data/entities"; -import { primaryFieldTypes } from "./fields"; +import { primaryFieldTypes, baseFieldConfigSchema } from "./fields"; import { s } from "bknd/utils"; +import { $object, $record } from "modules/mcp"; export const FIELDS = { ...FieldClassMap, ...RelationFieldClassMap, media: { schema: mediaFieldConfigSchema, field: MediaField }, }; +export const FIELD_TYPES = Object.keys(FIELDS); export type FieldType = keyof typeof FIELDS; export const RELATIONS = RelationClassMap; @@ -28,17 +30,30 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => { ); }); export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject)); -export const entityFields = s.record(fieldsSchema); +export const entityFields = s.record(fieldsSchema, { default: {} }); export type TAppDataField = s.Static; export type TAppDataEntityFields = s.Static; export const entitiesSchema = s.strictObject({ name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI) - type: s.string({ enum: entityTypes, default: "regular" }), - config: entityConfigSchema, - fields: entityFields, + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + fields: entityFields.optional(), }); export type TAppDataEntity = s.Static; +export const simpleEntitiesSchema = s.strictObject({ + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + fields: s + .record( + s.object({ + type: s.anyOf([s.string({ enum: FIELD_TYPES }), s.string()]), + config: baseFieldConfigSchema.optional(), + }), + { default: {} }, + ) + .optional(), +}); export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => { return s.strictObject( @@ -61,12 +76,27 @@ export const indicesSchema = s.strictObject({ unique: s.boolean({ default: false }).optional(), }); -export const dataConfigSchema = s.strictObject({ +export const dataConfigSchema = $object("config_data", { basepath: s.string({ default: "/api/data" }).optional(), default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(), - entities: s.record(entitiesSchema, { default: {} }).optional(), - relations: s.record(s.anyOf(relationsSchema), { default: {} }).optional(), - indices: s.record(indicesSchema, { default: {} }).optional(), -}); + entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(), + relations: $record( + "config_data_relations", + s.anyOf(relationsSchema), + { + default: {}, + }, + s.strictObject({ + type: s.string({ enum: Object.keys(RelationClassMap) }), + source: s.string(), + target: s.string(), + config: s.object({}).optional(), + }), + ).optional(), + indices: $record("config_data_indices", indicesSchema, { + default: {}, + mcp: { update: false }, + }).optional(), +}).strict(); export type AppDataConfig = s.Static; diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 10612b5..fcbe092 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -10,14 +10,17 @@ import { // @todo: entity must be migrated to typebox export const entityConfigSchema = s - .strictObject({ - name: s.string(), - name_singular: s.string(), - description: s.string(), - sort_field: s.string({ default: config.data.default_primary_field }), - sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), - primary_format: s.string({ enum: primaryFieldTypes }), - }) + .strictObject( + { + name: s.string(), + name_singular: s.string(), + description: s.string(), + sort_field: s.string({ default: config.data.default_primary_field }), + sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), + primary_format: s.string({ enum: primaryFieldTypes }), + }, + { default: {} }, + ) .partial(); export type EntityConfig = s.Static; diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 544c8ad..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,15 +248,14 @@ export class EntityManager { } schema() { - if (!this._schema) { - this._schema = new SchemaManager(this); - } - - return this._schema; + return new SchemaManager(this); } // @todo: centralize and add tests hydrate(entity_name: string, _data: EntityData[]) { + if (!Array.isArray(_data) || _data.length === 0) { + return []; + } const entity = this.entity(entity_name); const data: EntityData[] = []; diff --git a/app/src/data/entities/EntityTypescript.spec.ts b/app/src/data/entities/EntityTypescript.spec.ts new file mode 100644 index 0000000..7241659 --- /dev/null +++ b/app/src/data/entities/EntityTypescript.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test"; +import { EntityTypescript } from "./EntityTypescript"; +import * as proto from "../prototype"; +import { DummyConnection } from "../connection/Connection"; + +describe("EntityTypescript", () => { + it("should generate correct typescript for system entities", () => { + const schema = proto.em( + { + test: proto.entity("test", { + name: proto.text(), + }), + users: proto.systemEntity("users", { + name: proto.text(), + }), + }, + ({ relation }, { test, users }) => { + relation(test).manyToOne(users); + }, + ); + const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection())); + expect(et.toString()).toContain('users?: DB["users"];'); + }); + + it("should generate correct typescript for system entities with uuid primary field", () => { + const schema = proto.em( + { + test: proto.entity( + "test", + { + name: proto.text(), + }, + { + primary_format: "uuid", + }, + ), + users: proto.systemEntity( + "users", + { + name: proto.text(), + }, + { + primary_format: "uuid", + }, + ), + }, + ({ relation }, { test, users }) => { + relation(test).manyToOne(users); + }, + ); + const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection())); + expect(et.toString()).toContain("users_id?: string;"); + }); +}); diff --git a/app/src/data/entities/EntityTypescript.ts b/app/src/data/entities/EntityTypescript.ts index 85255b9..36d571b 100644 --- a/app/src/data/entities/EntityTypescript.ts +++ b/app/src/data/entities/EntityTypescript.ts @@ -1,6 +1,6 @@ import type { Entity, EntityManager, TEntityType } from "data/entities"; import type { EntityRelation } from "data/relations"; -import { autoFormatString } from "core/utils"; +import { autoFormatString } from "bknd/utils"; import { usersFields } from "auth/auth-entities"; import { mediaFields } from "media/media-entities"; @@ -40,7 +40,7 @@ const systemEntities = { export class EntityTypescript { constructor( - protected em: EntityManager, + protected em: EntityManager, protected _options: EntityTypescriptOptions = {}, ) {} @@ -50,7 +50,7 @@ export class EntityTypescript { indentWidth: 2, indentChar: " ", entityCommentMultiline: true, - fieldCommentMultiline: false, + fieldCommentMultiline: true, }; } @@ -82,7 +82,7 @@ export class EntityTypescript { } typeName(name: string) { - return autoFormatString(name); + return autoFormatString(name).replace(/ /g, ""); } fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) { diff --git a/app/src/data/entities/Result.ts b/app/src/data/entities/Result.ts index 0570aa6..b637e1a 100644 --- a/app/src/data/entities/Result.ts +++ b/app/src/data/entities/Result.ts @@ -1,5 +1,5 @@ import { isDebug } from "core/env"; -import { pick } from "core/utils"; +import { pick } from "bknd/utils"; import type { Connection } from "data/connection"; import type { Compilable, @@ -72,12 +72,12 @@ export class Result { return this.first().parameters; } - get data() { + get data(): T { if (this.options.single) { return this.first().data?.[0]; } - return this.first().data ?? []; + return this.first().data ?? ([] as T); } async execute(qb: Compilable | Compilable[]) { diff --git a/app/src/data/entities/index.ts b/app/src/data/entities/index.ts index 5beca37..59b16b9 100644 --- a/app/src/data/entities/index.ts +++ b/app/src/data/entities/index.ts @@ -4,5 +4,6 @@ export * from "./mutation/Mutator"; export * from "./query/Repository"; export * from "./query/WhereBuilder"; export * from "./query/WithBuilder"; +export * from "./Result"; export * from "./query/RepositoryResult"; export * from "./mutation/MutatorResult"; diff --git a/app/src/data/entities/mutation/MutatorResult.ts b/app/src/data/entities/mutation/MutatorResult.ts index 551bd61..e9e876e 100644 --- a/app/src/data/entities/mutation/MutatorResult.ts +++ b/app/src/data/entities/mutation/MutatorResult.ts @@ -1,4 +1,4 @@ -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { Entity, EntityData } from "../Entity"; import type { EntityManager } from "../EntityManager"; import { Result, type ResultJSON, type ResultOptions } from "../Result"; diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 5f85d80..3d8f432 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -1,5 +1,5 @@ -import type { DB as DefaultDB, PrimaryFieldType } from "bknd"; -import { $console } from "core/utils"; +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"; import { InvalidSearchParamsException } from "../../errors"; @@ -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/entities/query/RepositoryResult.ts b/app/src/data/entities/query/RepositoryResult.ts index 7631f8f..85dc2eb 100644 --- a/app/src/data/entities/query/RepositoryResult.ts +++ b/app/src/data/entities/query/RepositoryResult.ts @@ -2,7 +2,7 @@ import type { Entity, EntityData } from "../Entity"; import type { EntityManager } from "../EntityManager"; import { Result, type ResultJSON, type ResultOptions } from "../Result"; import type { Compilable, SelectQueryBuilder } from "kysely"; -import { $console, ensureInt } from "core/utils"; +import { $console, ensureInt } from "bknd/utils"; export type RepositoryResultOptions = ResultOptions & { silent?: boolean; diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 3f6dde3..5e9fd6a 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -1,4 +1,4 @@ -import { isObject } from "core/utils"; +import { isObject } from "bknd/utils"; import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { RepoQuery } from "data/server/query"; diff --git a/app/src/data/fields/BooleanField.ts b/app/src/data/fields/BooleanField.ts index 1655a89..860dbe4 100644 --- a/app/src/data/fields/BooleanField.ts +++ b/app/src/data/fields/BooleanField.ts @@ -1,8 +1,7 @@ -import { omitKeys } from "core/utils"; +import { omitKeys, s } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; -import { s } from "bknd/utils"; export const booleanFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/DateField.ts b/app/src/data/fields/DateField.ts index 0624986..20d152e 100644 --- a/app/src/data/fields/DateField.ts +++ b/app/src/data/fields/DateField.ts @@ -1,9 +1,7 @@ -import { dayjs } from "core/utils"; +import { dayjs, $console, s } from "bknd/utils"; import type { EntityManager } from "../entities"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; -import { $console } from "core/utils"; import type { TFieldTSType } from "data/entities/EntityTypescript"; -import { s } from "bknd/utils"; export const dateFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/EnumField.ts b/app/src/data/fields/EnumField.ts index 306674c..5b2e10f 100644 --- a/app/src/data/fields/EnumField.ts +++ b/app/src/data/fields/EnumField.ts @@ -1,4 +1,4 @@ -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field"; diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index 711767f..8ed4802 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -1,4 +1,4 @@ -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; @@ -64,20 +64,27 @@ export class JsonField f.name) - .join(", ")}`, - ); - } - } - if (!name) { this.name = [ unique ? "idx_unique" : "idx", diff --git a/app/src/data/permissions/index.ts b/app/src/data/permissions/index.ts index 3db75ed..f832716 100644 --- a/app/src/data/permissions/index.ts +++ b/app/src/data/permissions/index.ts @@ -1,9 +1,51 @@ -import { Permission } from "core/security/Permission"; +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/data/prototype/index.ts b/app/src/data/prototype/index.ts index 4f25aeb..43df6a1 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -39,6 +39,9 @@ import { type PolymorphicRelationConfig, } from "data/relations"; +import type { MediaFields } from "media/AppMedia"; +import type { UsersFields } from "auth/AppAuth"; + type Options = { entity: { name: string; fields: Record> }; field_name: string; @@ -199,6 +202,18 @@ export function entity< return new Entity(name, _fields, config, type); } +type SystemEntities = { + users: UsersFields; + media: MediaFields; +}; + +export function systemEntity< + E extends keyof SystemEntities, + Fields extends Record>, +>(name: E, fields: Fields, config?: EntityConfig) { + return entity(name, fields as any, config, "system"); +} + export function relation(local: Local) { return { manyToOne: (foreign: Foreign, config?: ManyToOneRelationConfig) => { diff --git a/app/src/data/relations/ManyToManyRelation.ts b/app/src/data/relations/ManyToManyRelation.ts index 99b5740..e7a1233 100644 --- a/app/src/data/relations/ManyToManyRelation.ts +++ b/app/src/data/relations/ManyToManyRelation.ts @@ -180,8 +180,15 @@ export class ManyToManyRelation extends EntityRelation; @@ -70,7 +71,7 @@ export class PolymorphicRelation extends EntityRelation { + override getReferenceQuery(entity: Entity, id: PrimaryFieldType): Partial { const info = this.queryInfo(entity); return { @@ -101,8 +102,8 @@ export class PolymorphicRelation extends EntityRelation) { diff --git a/app/src/data/relations/RelationField.ts b/app/src/data/relations/RelationField.ts index 3a2ab07..f6e4c0d 100644 --- a/app/src/data/relations/RelationField.ts +++ b/app/src/data/relations/RelationField.ts @@ -84,9 +84,10 @@ export class RelationField extends Field { } override toType(): TFieldTSType { + const type = this.config.target_field_type === "integer" ? "number" : "string"; return { ...super.toType(), - type: "number", + type, }; } } diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index ab2d15f..8d6135a 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -65,6 +65,7 @@ export class SchemaManager { if (SchemaManager.EXCLUDE_TABLES.includes(table.name)) { continue; } + if (!table.name) continue; cleanTables.push({ ...table, @@ -247,20 +248,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 +265,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 +275,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 +284,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 +301,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)}`); } } diff --git a/app/src/data/schema/constructor.ts b/app/src/data/schema/constructor.ts index 7742812..98cc2fa 100644 --- a/app/src/data/schema/constructor.ts +++ b/app/src/data/schema/constructor.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import { Entity } from "data/entities"; import type { Field } from "data/fields"; import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index 3992599..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, { @@ -26,9 +28,6 @@ describe("server/query", () => { expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] }); expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] }); expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] }); - - expect(() => parse({ select: "not allowed" })).toThrow(); - expect(() => parse({ select: "id," })).toThrow(); }); test("join", () => { @@ -189,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 f8ba0c0..9a01e2a 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -1,13 +1,11 @@ -import { s } from "bknd/utils"; +import { s, isObject, $console } from "bknd/utils"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"; -import { isObject, $console } from "core/utils"; -import type { anyOf, CoercionOptions, Schema } from "jsonv-ts"; // ------- // helpers const stringIdentifier = s.string({ // allow "id", "id,title" – but not "id," or "not allowed" - pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$", + //pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$", }); const stringArray = s.anyOf( [ @@ -25,7 +23,7 @@ const stringArray = s.anyOf( if (v.includes(",")) { return v.split(","); } - return [v]; + return [v].filter(Boolean); } return []; }, @@ -80,12 +78,12 @@ const where = s.anyOf([s.string(), s.object({})], { }, ], coerce: (value: unknown) => { + if (value === undefined || value === null || value === "") return {}; + const q = typeof value === "string" ? JSON.parse(value) : value; return WhereBuilder.convert(q); }, }); -//type WhereSchemaIn = s.Static; -//type WhereSchema = s.StaticCoerced; // ------ // with @@ -97,9 +95,9 @@ export type RepoWithSchema = Record< } >; -const withSchema = (self: Schema): Schema<{}, Type, Type> => +const withSchema = (self: s.Schema): s.Schema<{}, Type, Type> => s.anyOf([stringIdentifier, s.array(stringIdentifier), self], { - coerce: function (this: typeof anyOf, _value: unknown, opts: CoercionOptions = {}) { + coerce: function (this: typeof s.anyOf, _value: unknown, opts: s.CoercionOptions = {}) { let value: any = _value; if (typeof value === "string") { @@ -128,7 +126,7 @@ const withSchema = (self: Schema): 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; -}; */ diff --git a/app/src/flows/flows-schema.ts b/app/src/flows/flows-schema.ts index f430c6c..5d897c8 100644 --- a/app/src/flows/flows-schema.ts +++ b/app/src/flows/flows-schema.ts @@ -1,6 +1,5 @@ -import { transformObject } from "core/utils"; +import { transformObject, s } from "bknd/utils"; import { TaskMap, TriggerMap } from "flows"; -import { s } from "bknd/utils"; export const TASKS = { ...TaskMap, diff --git a/app/src/flows/flows/Execution.ts b/app/src/flows/flows/Execution.ts index 41d2166..7c8ef86 100644 --- a/app/src/flows/flows/Execution.ts +++ b/app/src/flows/flows/Execution.ts @@ -2,7 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events"; import type { EmitsEvents } from "core/events"; import type { Task, TaskResult } from "../tasks/Task"; import type { Flow } from "./Flow"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export type TaskLog = TaskResult & { task: Task; diff --git a/app/src/flows/flows/Flow.ts b/app/src/flows/flows/Flow.ts index cf6a00b..2a1821a 100644 --- a/app/src/flows/flows/Flow.ts +++ b/app/src/flows/flows/Flow.ts @@ -1,4 +1,4 @@ -import { $console, transformObject } from "core/utils"; +import { $console, transformObject } from "bknd/utils"; import { type TaskMapType, TriggerMap } from "../index"; import type { Task } from "../tasks/Task"; import { Condition, TaskConnection } from "../tasks/TaskConnection"; diff --git a/app/src/flows/flows/executors/RuntimeExecutor.ts b/app/src/flows/flows/executors/RuntimeExecutor.ts index 55bf890..65888c7 100644 --- a/app/src/flows/flows/executors/RuntimeExecutor.ts +++ b/app/src/flows/flows/executors/RuntimeExecutor.ts @@ -1,5 +1,5 @@ import type { Task } from "../../tasks/Task"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export class RuntimeExecutor { async run( diff --git a/app/src/flows/flows/triggers/EventTrigger.ts b/app/src/flows/flows/triggers/EventTrigger.ts index f17fd69..d1e5b82 100644 --- a/app/src/flows/flows/triggers/EventTrigger.ts +++ b/app/src/flows/flows/triggers/EventTrigger.ts @@ -1,8 +1,7 @@ import type { EventManager } from "core/events"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; -import { $console } from "core/utils"; -import { s } from "bknd/utils"; +import { $console, s } from "bknd/utils"; export class EventTrigger extends Trigger { override type = "event"; diff --git a/app/src/flows/tasks/TaskConnection.ts b/app/src/flows/tasks/TaskConnection.ts index 186eb28..d44b3f9 100644 --- a/app/src/flows/tasks/TaskConnection.ts +++ b/app/src/flows/tasks/TaskConnection.ts @@ -1,4 +1,4 @@ -import { objectCleanEmpty, uuid } from "core/utils"; +import { objectCleanEmpty, uuid } from "bknd/utils"; import { get } from "lodash-es"; import type { Task, TaskResult } from "./Task"; diff --git a/app/src/flows/tasks/presets/LogTask.ts b/app/src/flows/tasks/presets/LogTask.ts index 63b9677..05fc9f9 100644 --- a/app/src/flows/tasks/presets/LogTask.ts +++ b/app/src/flows/tasks/presets/LogTask.ts @@ -1,6 +1,5 @@ import { Task } from "../Task"; -import { $console } from "core/utils"; -import { s } from "bknd/utils"; +import { $console, s } from "bknd/utils"; export class LogTask extends Task { type = "log"; diff --git a/app/src/index.ts b/app/src/index.ts index 3a7b4d1..e30af8a 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -29,24 +29,28 @@ export { type InitialModuleConfigs, ModuleManagerEvents, } from "./modules/ModuleManager"; +export type * from "modules/ModuleApi"; export type { ServerEnv } from "modules/Controller"; export type { BkndConfig } from "bknd/adapter"; export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; +export { getSystemMcp } from "modules/mcp/system-mcp"; /** * Core */ +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"; -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"; export type { + EventListener, ListenerMode, ListenerHandler, } from "core/events/EventListener"; @@ -113,6 +117,7 @@ export { genericSqlite, genericSqliteUtils, type GenericSqliteConnection, + type GenericSqliteConnectionConfig, } from "data/connection/sqlite/GenericSqliteConnection"; export { EntityTypescript, @@ -126,10 +131,12 @@ export type { EntityRelation } from "data/relations"; export type * from "data/entities/Entity"; export type { EntityManager } from "data/entities/EntityManager"; export type { SchemaManager } from "data/schema/SchemaManager"; +export type * from "data/entities"; export { BaseIntrospector, Connection, customIntrospector, + DummyConnection, type FieldSpec, type IndexSpec, type DbFunctions, @@ -154,6 +161,7 @@ export { medium, make, entity, + systemEntity, relation, index, em, diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index a699d25..ff2cadd 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,5 +1,5 @@ import type { AppEntity, FileUploadedEventData, StorageAdapter } from "bknd"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { Storage } from "media/storage/Storage"; import { Module } from "modules/Module"; @@ -8,7 +8,9 @@ import { MediaController } from "./api/MediaController"; import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema"; import { mediaFields } from "./media-entities"; import * as MediaPermissions from "media/media-permissions"; +import * as DatabaseEvents from "data/events"; +export type MediaFields = typeof AppMedia.mediaFields; export type MediaFieldSchema = FieldSchema; declare module "bknd" { interface Media extends AppEntity, MediaFieldSchema {} @@ -20,6 +22,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) { @@ -138,6 +143,30 @@ export class AppMedia extends Module> { }, { mode: "sync", id: "delete-data-media" }, ); + + emgr.onEvent( + DatabaseEvents.MutatorDeleteAfter, + async (e) => { + const { entity, data } = e.params; + const fields = entity.fields.filter((f) => f.type === "media"); + if (fields.length > 0) { + const references = fields.map((f) => `${entity.name}.${f.name}`); + $console.log("App:storage:file cleaning up", { + reference: { $in: references }, + entity_id: String(data.id), + }); + const { data: deleted } = await em.mutator(media).deleteWhere({ + reference: { $in: references }, + entity_id: String(data.id), + }); + for (const file of deleted) { + await this.storage.deleteFile(file.path); + } + $console.log("App:storage:file cleaned up files:", deleted.length); + } + }, + { mode: "async", id: "delete-data-media-after" }, + ); } override getOverwritePaths() { diff --git a/app/src/media/MediaField.ts b/app/src/media/MediaField.ts index 5f005bc..6ff8cd3 100644 --- a/app/src/media/MediaField.ts +++ b/app/src/media/MediaField.ts @@ -43,6 +43,10 @@ export class MediaField< return this.config.max_items; } + getAllowedMimeTypes(): string[] | undefined { + return this.config.mime_types; + } + getMinItems(): number | undefined { return this.config.min_items; } diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 70cf746..d925d4d 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -1,11 +1,14 @@ import type { FileListObject } from "media/storage/Storage"; import { type BaseModuleApiOptions, + type FetchPromise, + type ResponseObject, ModuleApi, type PrimaryFieldType, type TInput, } from "modules/ModuleApi"; import type { ApiFetcher } from "Api"; +import type { DB, FileUploadedEventData } from "bknd"; export type MediaApiOptions = BaseModuleApiOptions & { upload_fetcher: ApiFetcher; @@ -67,14 +70,14 @@ export class MediaApi extends ModuleApi { return new Headers(); } - protected uploadFile( - body: File | Blob | ReadableStream, + protected uploadFile( + body: File | Blob | ReadableStream | Buffer, opts?: { filename?: string; path?: TInput; _init?: Omit; }, - ) { + ): FetchPromise> { const headers = { "Content-Type": "application/octet-stream", ...(opts?._init?.headers || {}), @@ -106,11 +109,11 @@ export class MediaApi extends ModuleApi { throw new Error("Invalid filename"); } - return this.post(opts?.path ?? ["upload", name], body, init); + return this.post(opts?.path ?? ["upload", name], body, init); } - async upload( - item: Request | Response | string | File | Blob | ReadableStream, + async upload( + item: Request | Response | string | File | Blob | ReadableStream | Buffer, opts: { filename?: string; _init?: Omit; @@ -124,12 +127,12 @@ export class MediaApi extends ModuleApi { if (!res.ok || !res.body) { throw new Error("Failed to fetch file"); } - return this.uploadFile(res.body, opts); + return this.uploadFile(res.body, opts); } else if (item instanceof Response) { if (!item.body) { throw new Error("Invalid response"); } - return this.uploadFile(item.body, { + return this.uploadFile(item.body, { ...(opts ?? {}), _init: { ...(opts._init ?? {}), @@ -141,19 +144,19 @@ export class MediaApi extends ModuleApi { }); } - return this.uploadFile(item, opts); + return this.uploadFile(item, opts); } async uploadToEntity( entity: string, id: PrimaryFieldType, field: string, - item: Request | Response | string | File | ReadableStream, + item: Request | Response | string | File | ReadableStream | Buffer, opts?: { _init?: Omit; fetcher?: typeof fetch; }, - ) { + ): Promise> { return this.upload(item, { ...opts, path: ["entity", entity, id, field], diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 5be44fd..0523b6a 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) { @@ -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( @@ -146,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"); @@ -181,16 +184,17 @@ export class MediaController extends Controller { "param", s.object({ entity: entitiesEnum, - id: s.number(), + id: s.anyOf([s.number(), s.string()]), field: s.string(), }), ), jsc("query", s.object({ overwrite: s.boolean().optional() })), - permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), + permission(DataPermissions.entityCreate, { + context: (c) => ({ entity: c.req.param("entity") }), + }), + permission(MediaPermissions.uploadFile, {}), async (c) => { - const entity_name = c.req.param("entity"); - const field_name = c.req.param("field"); - const entity_id = Number.parseInt(c.req.param("id")); + const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param"); // check if entity exists const entity = this.media.em.entity(entity_name); diff --git a/app/src/media/media-entities.ts b/app/src/media/media-entities.ts index a074b18..20bbf8b 100644 --- a/app/src/media/media-entities.ts +++ b/app/src/media/media-entities.ts @@ -9,6 +9,6 @@ export const mediaFields = { etag: text(), modified_at: datetime(), reference: text(), - entity_id: number(), + entity_id: text(), metadata: json(), }; 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/media/media-schema.ts b/app/src/media/media-schema.ts index a287d0a..eaa2b8d 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,6 +1,7 @@ import { MediaAdapters } from "media/media-registry"; import { registries } from "modules/registries"; import { s, objectTransform } from "bknd/utils"; +import { $object, $record, $schema } from "modules/mcp"; export const ADAPTERS = { ...MediaAdapters, @@ -22,7 +23,8 @@ export function buildMediaSchema() { ); }); - return s.strictObject( + return $object( + "config_media", { enabled: s.boolean({ default: false }), basepath: s.string({ default: "/api/media" }), @@ -37,12 +39,16 @@ export function buildMediaSchema() { }, { default: {} }, ), - adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(), + // @todo: currently cannot be updated partially using mcp + adapter: $schema( + "config_media_adapter", + s.anyOf(Object.values(adapterSchemaObject)), + ).optional(), }, { default: {}, }, - ); + ).strict(); } export const mediaConfigSchema = buildMediaSchema(); diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index e364daa..1d11b1d 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,5 +1,5 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { $console, isFile, detectImageDimensions } from "core/utils"; +import { $console, isFile, detectImageDimensions } from "bknd/utils"; import { isMimeType } from "media/storage/mime-types-tiny"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; @@ -71,22 +71,29 @@ export class Storage implements EmitsEvents { let info: FileUploadPayload = { name, - meta: { - size: 0, - type: "application/octet-stream", - }, + meta: isFile(file) + ? { + size: file.size, + type: file.type, + } + : { + size: 0, + type: "application/octet-stream", + }, etag: typeof result === "string" ? result : "", }; + // normally only etag is returned if (typeof result === "object") { info = result; - } else if (isFile(file)) { - info.meta.size = file.size; - info.meta.type = file.type; } // try to get better meta info - if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) { + if ( + !info.meta.type || + ["application/octet-stream", "application/json"].includes(info.meta.type) || + !info.meta.size + ) { const meta = await this.#adapter.getObjectMeta(name); if (!meta) { throw new Error("Failed to get object meta"); @@ -103,7 +110,7 @@ export class Storage implements EmitsEvents { ...dim, }; } catch (e) { - $console.warn("Failed to get image dimensions", e); + $console.warn("Failed to get image dimensions", e, file); } } diff --git a/app/src/media/storage/adapters/adapter-test-suite.ts b/app/src/media/storage/adapters/adapter-test-suite.ts index 1a92d34..a6d1917 100644 --- a/app/src/media/storage/adapters/adapter-test-suite.ts +++ b/app/src/media/storage/adapters/adapter-test-suite.ts @@ -11,12 +11,14 @@ export async function adapterTestSuite( retries?: number; retryTimeout?: number; skipExistsAfterDelete?: boolean; + testRange?: boolean; }, ) { const { test, expect } = testRunner; const options = { retries: opts?.retries ?? 1, retryTimeout: opts?.retryTimeout ?? 1000, + testRange: opts?.testRange ?? true, }; let objects = 0; @@ -53,9 +55,34 @@ export async function adapterTestSuite( await test("gets an object", async () => { const res = await adapter.getObject(filename, new Headers()); expect(res.ok).toBe(true); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); // @todo: check the content }); + if (options.testRange) { + await test("handles range request - partial content", async () => { + const headers = new Headers({ Range: "bytes=0-99" }); + const res = await adapter.getObject(filename, headers); + expect(res.status).toBe(206); // Partial Content + expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); + expect(res.headers.get("Accept-Ranges")).toBe("bytes"); + }); + + await test("handles range request - suffix range", async () => { + const headers = new Headers({ Range: "bytes=-100" }); + const res = await adapter.getObject(filename, headers); + expect(res.status).toBe(206); // Partial Content + expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); + }); + + await test("handles invalid range request", async () => { + const headers = new Headers({ Range: "bytes=invalid" }); + const res = await adapter.getObject(filename, headers); + expect(res.status).toBe(416); // Range Not Satisfiable + expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); + }); + } + await test("gets object meta", async () => { expect(await adapter.getObjectMeta(filename)).toEqual({ type: file.type, // image/png diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 96ec791..490047b 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -1,13 +1,12 @@ -import { hash, pickHeaders } from "core/utils"; +import { hash, pickHeaders, s, parse, secret } from "bknd/utils"; import type { FileBody, FileListObject, FileMeta } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; -import { s, parse } from "bknd/utils"; export const cloudinaryAdapterConfig = s.object( { cloud_name: s.string(), - api_key: s.string(), - api_secret: s.string(), + api_key: secret(), + api_secret: secret(), upload_preset: s.string().optional(), }, { title: "Cloudinary", description: "Cloudinary media storage" }, diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts index 5a2fe89..62c86b6 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts @@ -22,6 +22,19 @@ afterAll(() => { cleanup(); }); */ +describe("StorageS3Adapter", async () => { + test("verify client's service is set to s3", async () => { + const adapter = new StorageS3Adapter({ + access_key: "", + secret_access_key: "", + url: "https://localhost", + }); + // this is important for minio to produce the correct headers + // and it won't harm s3 or r2 + expect(adapter.client.service).toBe("s3"); + }); +}); + describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { if (ALL_TESTS) return; diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts index 7f3da6e..c36e0e3 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts @@ -8,15 +8,15 @@ import type { } from "@aws-sdk/client-s3"; import { AwsClient } from "core/clients/aws/AwsClient"; import { isDebug } from "core/env"; -import { isFile, pickHeaders2, parse, s } from "bknd/utils"; +import { isFile, pickHeaders2, parse, s, secret } from "bknd/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; export const s3AdapterConfig = s.object( { - access_key: s.string(), - secret_access_key: s.string(), + access_key: secret(), + secret_access_key: secret(), url: s.string({ pattern: "^https?://(?:.*)?[^/.]+$", description: "URL to S3 compatible endpoint without trailing slash", @@ -45,6 +45,7 @@ export class StorageS3Adapter extends StorageAdapter { accessKeyId: config.access_key, secretAccessKey: config.secret_access_key, retries: isDebug() ? 0 : 10, + service: "s3", }, { convertParams: "pascalToKebab", @@ -183,13 +184,13 @@ export class StorageS3Adapter extends StorageAdapter { method: "GET", headers: pickHeaders2(headers, [ "if-none-match", - "accept-encoding", + //"accept-encoding", (causes 403 on r2) "accept", "if-modified-since", ]), }); - // Response has to be copied, because of middlewares that might set headers + // response has to be copied, because of middlewares that might set headers return new Response(res.body, { status: res.status, statusText: res.statusText, diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts index e7c42bb..bbc8d9d 100644 --- a/app/src/media/storage/mime-types-tiny.ts +++ b/app/src/media/storage/mime-types-tiny.ts @@ -3,7 +3,7 @@ export const Q = { audio: ["ogg"], image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"], text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"], - application: ["zip", "xml", "toml", "json", "json5"], + application: ["zip", "toml", "json", "json5", "pdf", "xml"], font: ["woff", "woff2", "ttf", "otf"], } as const; @@ -15,11 +15,13 @@ const c = { a: (w = "octet-stream") => `application/${w}`, i: (w) => `image/${w}`, v: (w) => `video/${w}`, + au: (w) => `audio/${w}`, } as const; export const M = new Map([ ["7z", c.z], ["7zip", c.z], - ["ai", c.a("pdf")], + ["txt", c.t()], + ["ai", c.a("postscript")], ["apk", c.a("vnd.android.package-archive")], ["doc", c.a("msword")], ["docx", `${c.vnd}.wordprocessingml.document`], @@ -32,12 +34,12 @@ export const M = new Map([ ["jpg", c.i("jpeg")], ["js", c.t("javascript")], ["log", c.t()], - ["m3u", c.t()], + ["m3u", c.au("x-mpegurl")], ["m3u8", c.a("vnd.apple.mpegurl")], ["manifest", c.t("cache-manifest")], ["md", c.t("markdown")], ["mkv", c.v("x-matroska")], - ["mp3", c.a("mpeg")], + ["mp3", c.au("mpeg")], ["mobi", c.a("x-mobipocket-ebook")], ["ppt", c.a("powerpoint")], ["pptx", `${c.vnd}.presentationml.presentation`], @@ -46,11 +48,10 @@ export const M = new Map([ ["tif", c.i("tiff")], ["tsv", c.t("tab-separated-values")], ["tgz", c.a("x-tar")], - ["txt", c.t()], ["text", c.t()], ["vcd", c.a("x-cdlink")], ["vcs", c.t("x-vcalendar")], - ["wav", c.a("x-wav")], + ["wav", c.au("vnd.wav")], ["webmanifest", c.a("manifest+json")], ["xls", c.a("vnd.ms-excel")], ["xlsx", `${c.vnd}.spreadsheetml.sheet`], 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..7a8022b --- /dev/null +++ b/app/src/modes/hybrid.ts @@ -0,0 +1,89 @@ +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; + } + await appConfig.beforeBuild?.(app); + }, + 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..f1bc4ff --- /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, +>({ app, ..._config }: Config, args: Args) { + const appConfig = typeof app === "function" ? await app(args) : 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 && syncSecretsOptions.enabled !== false) { + 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/Controller.ts b/app/src/modules/Controller.ts index 51ae026..fdda095 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -1,4 +1,4 @@ -import type { App, SafeUser } from "bknd"; +import type { App, Permission, SafeUser } from "bknd"; import { type Context, type Env, Hono } from "hono"; import * as middlewares from "modules/middlewares"; import type { EntityManager } from "data/entities"; @@ -19,20 +19,6 @@ export interface ServerEnv extends Env { [key: string]: any; } -/* export type ServerEnv = Env & { - Variables: { - app: App; - // to prevent resolving auth multiple times - auth?: { - resolved: boolean; - registered: boolean; - skip: boolean; - user?: SafeUser; - }; - html?: string; - }; -}; */ - export class Controller { protected middlewares = middlewares; @@ -65,7 +51,8 @@ export class Controller { protected getEntitiesEnum(em: EntityManager): s.StringSchema { const entities = em.entities.map((e) => e.name); - // @todo: current workaround to allow strings (sometimes building is not fast enough to get the entities) return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string(); } + + registerMcp(): void {} } diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 126a15e..f402e04 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -1,3 +1,4 @@ +import type { App } from "bknd"; import type { EventManager } from "core/events"; import type { Connection } from "data/connection"; import type { EntityManager } from "data/entities"; @@ -5,11 +6,15 @@ import type { Hono } from "hono"; import type { ServerEnv } from "modules/Controller"; import type { ModuleHelper } from "./ModuleHelper"; import { SchemaObject } from "core/object/SchemaObject"; -import type { DebugLogger } from "core/utils/DebugLogger"; import type { Guard } from "auth/authorize/Guard"; +import type { McpServer, DebugLogger } from "bknd/utils"; type PartialRec = { [P in keyof T]?: PartialRec }; +export type ModuleBuildContextMcpContext = { + app: App; + ctx: () => ModuleBuildContext; +}; export type ModuleBuildContext = { connection: Connection; server: Hono; @@ -19,6 +24,7 @@ export type ModuleBuildContext = { logger: DebugLogger; flags: (typeof Module)["ctx_flags"]; helper: ModuleHelper; + mcp: McpServer; }; export abstract class Module { 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) {} @@ -110,4 +113,30 @@ export class ModuleHelper { entity.__replaceField(name, newField); } + + 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; + + if (c.raw === undefined || c.raw === null) { + throw new Exception("Request/Headers/Context is not available in mcp context", 400); + } + + const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); + this.ctx.guard.granted(permission, user as any, context as any); + } } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 42d9a94..8406eaa 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,26 +1,30 @@ -import { mark, stripMark, $console, s, objectEach, transformObject } from "bknd/utils"; +import { + objectEach, + transformObject, + McpServer, + type s, + SecretSchema, + setPath, + mark, + $console, +} from "bknd/utils"; +import { DebugLogger } from "core/utils/DebugLogger"; import { Guard } from "auth/authorize/Guard"; import { env } from "core/env"; -import { BkndError } from "core/errors"; -import { DebugLogger } from "core/utils/DebugLogger"; import { EventManager, Event } from "core/events"; -import * as $diff from "core/object/diff"; import type { Connection } from "data/connection"; import { EntityManager } from "data/entities/EntityManager"; -import * as proto from "data/prototype"; -import { TransformPersistFailedException } from "data/errors"; import { Hono } from "hono"; -import type { Kysely } from "kysely"; -import { mergeWith } from "lodash-es"; -import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations"; -import { AppServer } from "modules/server/AppServer"; -import { AppAuth } from "../auth/AppAuth"; -import { AppData } from "../data/AppData"; -import { AppFlows } from "../flows/AppFlows"; -import { AppMedia } from "../media/AppMedia"; import type { ServerEnv } from "./Controller"; import { Module, type ModuleBuildContext } from "./Module"; import { ModuleHelper } from "./ModuleHelper"; +import { AppServer } from "modules/server/AppServer"; +import { AppAuth } from "auth/AppAuth"; +import { AppData } from "data/AppData"; +import { AppFlows } from "flows/AppFlows"; +import { AppMedia } from "media/AppMedia"; +import type { PartialRec } from "core/types"; +import { mergeWith, pick } from "lodash-es"; export type { ModuleBuildContext }; @@ -47,13 +51,8 @@ export type ModuleSchemas = { export type ModuleConfigs = { [K in keyof ModuleSchemas]: s.Static; }; -type PartialRec = { [P in keyof T]?: PartialRec }; -export type InitialModuleConfigs = - | ({ - version: number; - } & ModuleConfigs) - | PartialRec; +export type InitialModuleConfigs = { version?: number } & PartialRec; enum Verbosity { silent = 0, @@ -75,47 +74,19 @@ export type ModuleManagerOptions = { // callback after server was created onServerInit?: (server: Hono) => void; // doesn't perform validity checks for given/fetched config - trustFetched?: boolean; + skipValidation?: boolean; // runs when initial config provided on a fresh database seed?: (ctx: ModuleBuildContext) => Promise; // called right after modules are built, before finish onModulesBuilt?: (ctx: ModuleBuildContext) => Promise; + // whether to store secrets in the database + storeSecrets?: boolean; + // provided secrets + secrets?: Record; /** @deprecated */ verbosity?: Verbosity; }; -export type ConfigTable = { - id?: number; - version: number; - type: "config" | "diff" | "backup"; - json: Json; - created_at?: Date; - updated_at?: Date; -}; - -const configJsonSchema = s.anyOf([ - getDefaultSchema(), - s.array( - s.strictObject({ - t: s.string({ enum: ["a", "r", "e"] }), - p: s.array(s.anyOf([s.string(), s.number()])), - o: s.any().optional(), - n: s.any().optional(), - }), - ), -]); -export const __bknd = proto.entity(TABLE_NAME, { - version: proto.number().required(), - type: proto.enumm({ enum: ["config", "diff", "backup"] }).required(), - json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(), - created_at: proto.datetime(), - updated_at: proto.datetime(), -}); -type ConfigTable2 = proto.Schema; -interface T_INTERNAL_EM { - __bknd: ConfigTable2; -} - const debug_modules = env("modules_debug"); abstract class ModuleManagerEvent extends Event<{ ctx: ModuleBuildContext } & A> {} @@ -127,8 +98,14 @@ export class ModuleManagerConfigUpdateEvent< }> { static override slug = "mm-config-update"; } +export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{ + secrets: Record; +}> { + static override slug = "mm-secrets-extracted"; +} export const ModuleManagerEvents = { ModuleManagerConfigUpdateEvent, + ModuleManagerSecretsExtractedEvent, }; // @todo: cleanup old diffs on upgrade @@ -137,50 +114,36 @@ export class ModuleManager { static Events = ModuleManagerEvents; protected modules: Modules; - // internal em for __bknd config table - __em!: EntityManager; // ctx for modules em!: EntityManager; server!: Hono; emgr!: EventManager; guard!: Guard; + mcp!: ModuleBuildContext["mcp"]; - private _version: number = 0; - private _built = false; - private readonly _booted_with?: "provided" | "partial"; - private _stable_configs: ModuleConfigs | undefined; + protected _built = false; - private logger: DebugLogger; + protected logger: DebugLogger; constructor( - private readonly connection: Connection, - private options?: Partial, + protected readonly connection: Connection, + public options?: Partial, ) { - this.__em = new EntityManager([__bknd], this.connection); this.modules = {} as Modules; this.emgr = new EventManager({ ...ModuleManagerEvents }); this.logger = new DebugLogger(debug_modules); - let initial = {} as Partial; - if (options?.initial) { - if ("version" in options.initial) { - const { version, ...initialConfig } = options.initial; - this._version = version; - initial = stripMark(initialConfig); - - this._booted_with = "provided"; - } else { - initial = mergeWith(getDefaultConfig(), options.initial); - this._booted_with = "partial"; - } + const config = options?.initial ?? {}; + if (options?.skipValidation) { + mark(config, true); } - this.logger.log("booted with", this._booted_with); - - this.createModules(initial); + this.createModules(config); } - private createModules(initial: Partial) { + protected onModuleConfigUpdated(key: string, config: any) {} + + private createModules(initial: PartialRec) { this.logger.context("createModules").log("creating modules"); try { const context = this.ctx(true); @@ -210,46 +173,7 @@ export class ModuleManager { return this._built; } - /** - * This is set through module's setListener - * It's called everytime a module's config is updated in SchemaObject - * Needs to rebuild modules and save to database - */ - private async onModuleConfigUpdated(key: string, config: any) { - if (this.options?.onUpdated) { - await this.options.onUpdated(key as any, config); - } else { - await this.buildModules(); - } - } - - private repo() { - return this.__em.repo(__bknd, { - // to prevent exceptions when table doesn't exist - silent: true, - // disable counts for performance and compatibility - includeCounts: false, - }); - } - - private mutator() { - return this.__em.mutator(__bknd); - } - - private get db() { - // @todo: check why this is neccessary - return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>; - } - - // @todo: add indices for: version, type - async syncConfigTable() { - this.logger.context("sync").log("start"); - const result = await this.__em.schema().sync({ force: true }); - this.logger.log("done").clear(); - return result; - } - - private rebuildServer() { + protected rebuildServer() { this.server = new Hono(); if (this.options?.basePath) { this.server = this.server.basePath(this.options.basePath); @@ -271,6 +195,14 @@ export class ModuleManager { ? this.em.clear() : new EntityManager([], this.connection, [], [], this.emgr); this.guard = new Guard(); + this.mcp = new McpServer(undefined as any, { + app: new Proxy(this, { + get: () => { + throw new Error("app is not available in mcp context"); + }, + }) as any, + ctx: () => this.ctx(), + }); } const ctx = { @@ -281,6 +213,7 @@ export class ModuleManager { guard: this.guard, flags: Module.ctx_flags, logger: this.logger, + mcp: this.mcp, }; return { @@ -289,252 +222,81 @@ export class ModuleManager { }; } - private async fetch(): Promise { - this.logger.context("fetch").log("fetching"); - const startTime = performance.now(); + extractSecrets() { + const moduleConfigs = structuredClone(this.configs()); + const secrets = { ...this.options?.secrets }; + const extractedKeys: string[] = []; - // disabling console log, because the table might not exist yet - const { data: result } = await this.repo().findOne( - { type: "config" }, - { - sort: { by: "version", dir: "desc" }, - }, - ); + for (const [key, module] of Object.entries(this.modules)) { + const config = moduleConfigs[key]; + const schema = module.getSchema(); - if (!result) { - this.logger.log("error fetching").clear(); - return undefined; - } + const extracted = [...schema.walk({ data: config })].filter( + (n) => n.schema instanceof SecretSchema, + ); - this.logger - .log("took", performance.now() - startTime, "ms", { - version: result.version, - id: result.id, - }) - .clear(); + for (const n of extracted) { + const path = [key, ...n.instancePath].join("."); - return result as unknown as ConfigTable; - } - - async save() { - this.logger.context("save").log("saving version", this.version()); - const configs = this.configs(); - const version = this.version(); - - try { - const state = await this.fetch(); - if (!state) throw new BkndError("no config found"); - this.logger.log("fetched version", state.version); - - if (state.version !== version) { - // @todo: mark all others as "backup" - this.logger.log("version conflict, storing new version", state.version, version); - await this.mutator().insertOne({ - version: state.version, - type: "backup", - json: configs, - }); - await this.mutator().insertOne({ - version: version, - type: "config", - json: configs, - }); - } else { - this.logger.log("version matches", state.version); - - // clean configs because of Diff() function - const diffs = $diff.diff(state.json, $diff.clone(configs)); - this.logger.log("checking diff", [diffs.length]); - - if (diffs.length > 0) { - // validate diffs, it'll throw on invalid - this.validateDiffs(diffs); - - const date = new Date(); - // store diff - await this.mutator().insertOne({ - version, - type: "diff", - json: $diff.clone(diffs), - created_at: date, - updated_at: date, - }); - - // store new version - await this.mutator().updateWhere( - { - version, - json: configs, - updated_at: date, - } as any, - { - type: "config", - version, - }, - ); - } else { - this.logger.log("no diff, not saving"); + if (typeof n.data === "string") { + extractedKeys.push(path); + secrets[path] = n.data; + setPath(moduleConfigs, path, ""); } } - } catch (e) { - if (e instanceof BkndError && e.message === "no config found") { - this.logger.log("no config, just save fresh"); - // no config, just save - await this.mutator().insertOne({ - type: "config", - version, - json: configs, - created_at: new Date(), - updated_at: new Date(), - }); - } else if (e instanceof TransformPersistFailedException) { - $console.error("ModuleManager: Cannot save invalid config"); - this.revertModules(); - throw e; - } else { - $console.error("ModuleManager: Aborting"); - this.revertModules(); - throw e; - } } - // re-apply configs to all modules (important for system entities) - this.setConfigs(configs); - - // @todo: cleanup old versions? - - this.logger.clear(); - return this; + return { + configs: moduleConfigs, + secrets: pick(secrets, extractedKeys), + extractedKeys, + }; } - private revertModules() { - if (this._stable_configs) { - $console.warn("ModuleManager: Reverting modules"); - this.setConfigs(this._stable_configs as any); - } else { - $console.error("ModuleManager: No stable configs to revert to"); - } - } - - /** - * Validates received diffs for an additional security control. - * Checks: - * - check if module is registered - * - run modules onBeforeUpdate() for added protection - * - * **Important**: Throw `Error` so it won't get catched. - * - * @param diffs - * @private - */ - private validateDiffs(diffs: $diff.DiffEntry[]): void { - // check top level paths, and only allow a single module to be modified in a single transaction - const modules = [...new Set(diffs.map((d) => d.p[0]))]; - if (modules.length === 0) { - return; - } - - for (const moduleName of modules) { - const name = moduleName as ModuleKey; - const module = this.get(name) as Module; - if (!module) { - const msg = "validateDiffs: module not registered"; - // biome-ignore format: ... - $console.error(msg, JSON.stringify({ module: name, diffs }, null, 2)); - throw new Error(msg); - } - - // pass diffs to the module to allow it to throw - if (this._stable_configs?.[name]) { - const current = $diff.clone(this._stable_configs?.[name]); - const modified = $diff.apply({ [name]: current }, diffs)[name]; - module.onBeforeUpdate(current, modified); - } - } - } - - private setConfigs(configs: ModuleConfigs): void { + protected async setConfigs(configs: ModuleConfigs): Promise { this.logger.log("setting configs"); - objectEach(configs, (config, key) => { + for await (const [key, config] of Object.entries(configs)) { + if (!(key in this.modules)) continue; + try { // setting "noEmit" to true, to not force listeners to update - this.modules[key].schema().set(config as any, true); + const result = await this.modules[key].schema().set(config as any, true); } catch (e) { console.error(e); throw new Error( `Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`, ); } - }); + } } - async build(opts?: { fetch?: boolean }) { - this.logger.context("build").log("version", this.version()); - await this.ctx().connection.init(); + async build(opts?: any) { + this.createModules(this.options?.initial ?? {}); + await this.buildModules(); - // if no config provided, try fetch from db - if (this.version() === 0 || opts?.fetch === true) { - if (opts?.fetch) { - this.logger.log("force fetch"); - } + // if secrets were provided, extract, merge and build again + const provided_secrets = this.options?.secrets ?? {}; + if (Object.keys(provided_secrets).length > 0) { + const { configs, extractedKeys } = this.extractSecrets(); - const result = await this.fetch(); - - // if no version, and nothing found, go with initial - if (!result) { - this.logger.log("nothing in database, go initial"); - await this.setupInitial(); - } else { - this.logger.log("db has", result.version); - // set version and config from fetched - this._version = result.version; - - if (this.options?.trustFetched === true) { - this.logger.log("trusting fetched config (mark)"); - mark(result.json); - } - - // if version doesn't match, migrate before building - if (this.version() !== CURRENT_VERSION) { - this.logger.log("now migrating"); - - await this.syncConfigTable(); - - const version_before = this.version(); - const [_version, _configs] = await migrate(version_before, result.json, { - db: this.db, - }); - - this._version = _version; - this.ctx().flags.sync_required = true; - - this.logger.log("migrated to", _version); - $console.log("Migrated config from", version_before, "to", this.version()); - - this.createModules(_configs); - await this.buildModules(); - } else { - this.logger.log("version is current", this.version()); - this.createModules(result.json); - await this.buildModules(); + for (const key of extractedKeys) { + if (key in provided_secrets) { + setPath(configs, key, provided_secrets[key]); } } - } else { - if (this.version() !== CURRENT_VERSION) { - throw new Error( - `Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`, - ); - } - this.logger.log("current version is up to date", this.version()); + + await this.setConfigs(configs); await this.buildModules(); } - this.logger.log("done"); - this.logger.clear(); return this; } - private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) { + protected async buildModules(options?: { + graceful?: boolean; + ignoreFlags?: boolean; + drop?: boolean; + }) { const state = { built: false, modules: [] as ModuleKey[], @@ -569,13 +331,8 @@ export class ModuleManager { ctx.flags.sync_required = false; this.logger.log("db sync requested"); - // sync db - await ctx.em.schema().sync({ force: true }); - state.synced = true; - - // save - await this.save(); - state.saved = true; + // ignore sync request on code mode since system tables + // are probably never fully in provided config } if (ctx.flags.ctx_reload_required) { @@ -591,92 +348,12 @@ export class ModuleManager { ctx.flags = Module.ctx_flags; // storing last stable config version - this._stable_configs = $diff.clone(this.configs()); + //this._stable_configs = $diff.clone(this.configs()); this.logger.clear(); return state; } - protected async setupInitial() { - this.logger.context("initial").log("start"); - this._version = CURRENT_VERSION; - await this.syncConfigTable(); - const state = await this.buildModules(); - if (!state.saved) { - await this.save(); - } - - const ctx = { - ...this.ctx(), - // disable events for initial setup - em: this.ctx().em.fork(), - }; - - // perform a sync - await ctx.em.schema().sync({ force: true }); - await this.options?.seed?.(ctx); - - // run first boot event - await this.options?.onFirstBoot?.(); - this.logger.clear(); - } - - mutateConfigSafe( - name: Module, - ): Pick, "set" | "patch" | "overwrite" | "remove"> { - const module = this.modules[name]; - - return new Proxy(module.schema(), { - get: (target, prop: string) => { - if (!["set", "patch", "overwrite", "remove"].includes(prop)) { - throw new Error(`Method ${prop} is not allowed`); - } - - return async (...args) => { - $console.log("[Safe Mutate]", name); - try { - // overwrite listener to run build inside this try/catch - module.setListener(async () => { - await this.emgr.emit( - new ModuleManagerConfigUpdateEvent({ - ctx: this.ctx(), - module: name, - config: module.config as any, - }), - ); - await this.buildModules(); - }); - - const result = await target[prop](...args); - - // revert to original listener - module.setListener(async (c) => { - await this.onModuleConfigUpdated(name, c); - }); - - // if there was an onUpdated listener, call it after success - // e.g. App uses it to register module routes - if (this.options?.onUpdated) { - await this.options.onUpdated(name, module.config as any); - } - - return result; - } catch (e) { - $console.error(`[Safe Mutate] failed "${name}":`, e); - - // revert to previous config & rebuild using original listener - this.revertModules(); - await this.onModuleConfigUpdated(name, module.config as any); - $console.warn(`[Safe Mutate] reverted "${name}":`); - - // make sure to throw the error - throw e; - } - }; - }, - }); - } - get(key: K): Modules[K] { if (!(key in this.modules)) { throw new Error(`Module "${key}" doesn't exist, cannot get`); @@ -685,7 +362,7 @@ export class ModuleManager { } version() { - return this._version; + return 0; } built() { @@ -702,7 +379,7 @@ export class ModuleManager { return { version: this.version(), ...schemas, - }; + } as { version: number } & ModuleSchemas; } toJSON(secrets?: boolean): { version: number } & ModuleConfigs { 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/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts new file mode 100644 index 0000000..8af95e8 --- /dev/null +++ b/app/src/modules/db/DbModuleManager.ts @@ -0,0 +1,595 @@ +import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils"; +import { BkndError } from "core/errors"; +import * as $diff from "core/object/diff"; +import type { Connection } from "data/connection"; +import type { EntityManager } from "data/entities/EntityManager"; +import * as proto from "data/prototype"; +import { TransformPersistFailedException } from "data/errors"; +import type { Kysely } from "kysely"; +import { mergeWith } from "lodash-es"; +import { CURRENT_VERSION, TABLE_NAME, migrate } from "./migrations"; +import { Module, type ModuleBuildContext } from "../Module"; +import { + type InitialModuleConfigs, + type ModuleConfigs, + type Modules, + type ModuleKey, + getDefaultSchema, + getDefaultConfig, + ModuleManager, + ModuleManagerConfigUpdateEvent, + type ModuleManagerOptions, + ModuleManagerSecretsExtractedEvent, +} from "../ModuleManager"; + +export type { ModuleBuildContext }; + +export type ConfigTable = { + id?: number; + version: number; + type: "config" | "diff" | "backup"; + json: Json; + created_at?: Date; + updated_at?: Date; +}; + +const configJsonSchema = s.anyOf([ + getDefaultSchema(), + s.array( + s.strictObject({ + t: s.string({ enum: ["a", "r", "e"] }), + p: s.array(s.anyOf([s.string(), s.number()])), + o: s.any().optional(), + n: s.any().optional(), + }), + ), +]); +export const __bknd = proto.entity(TABLE_NAME, { + version: proto.number().required(), + type: proto.enumm({ enum: ["config", "diff", "backup", "secrets"] }).required(), + json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(), + created_at: proto.datetime(), + updated_at: proto.datetime(), +}); +const __schema = proto.em({ __bknd }, ({ index }, { __bknd }) => { + index(__bknd).on(["version", "type"]); +}); + +type ConfigTable2 = proto.Schema; +interface T_INTERNAL_EM { + __bknd: ConfigTable2; +} + +// @todo: cleanup old diffs on upgrade +// @todo: cleanup multiple backups on upgrade +export class DbModuleManager extends ModuleManager { + // internal em for __bknd config table + __em!: EntityManager; + + private _version: number = 0; + 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; + let version = 0; + + if (options?.initial) { + if ("version" in options.initial && options.initial.version) { + const { version: _v, ...config } = options.initial; + version = _v as number; + initial = stripMark(config) as any; + + booted_with = "provided"; + } else { + initial = mergeWith(getDefaultConfig(), options.initial); + booted_with = "partial"; + } + } + + super(connection, { ...options, initial }); + + this.__em = __schema.proto.withConnection(this.connection) as any; + //this.__em = new EntityManager(__schema.entities, this.connection); + this._version = version; + this._booted_with = booted_with; + + this.logger.log("booted with", this._booted_with); + } + + /** + * This is set through module's setListener + * It's called everytime a module's config is updated in SchemaObject + * Needs to rebuild modules and save to database + */ + protected override async onModuleConfigUpdated(key: string, config: any) { + if (this.options?.onUpdated) { + await this.options.onUpdated(key as any, config); + } else { + await this.buildModules(); + } + } + + private repo() { + return this.__em.repo(__bknd, { + // to prevent exceptions when table doesn't exist + silent: true, + // disable counts for performance and compatibility + includeCounts: false, + }); + } + + private mutator() { + return this.__em.mutator(__bknd); + } + + private get db() { + // @todo: check why this is neccessary + return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>; + } + + // @todo: add indices for: version, type + async syncConfigTable() { + this.logger.context("sync").log("start"); + const result = await this.__em.schema().sync({ force: true }); + this.logger.log("done").clear(); + return result; + } + + private async fetch(): Promise<{ configs?: ConfigTable; secrets?: ConfigTable } | undefined> { + this.logger.context("fetch").log("fetching"); + const startTime = performance.now(); + + // disabling console log, because the table might not exist yet + const { data: result } = await this.repo().findMany({ + where: { type: { $in: ["config", "secrets"] } }, + sort: { by: "version", dir: "desc" }, + }); + + if (!result.length) { + this.logger.log("error fetching").clear(); + return undefined; + } + + const configs = result.filter((r) => r.type === "config")[0]; + const secrets = result.filter((r) => r.type === "secrets")[0]; + + this.logger + .log("took", performance.now() - startTime, "ms", { + version: configs?.version, + id: configs?.id, + }) + .clear(); + + return { configs, secrets } as any; + } + + async save() { + this.logger.context("save").log("saving version", this.version()); + const { configs, secrets } = this.extractSecrets(); + const version = this.version(); + const store_secrets = this.options?.storeSecrets !== false; + + await this.emgr.emit( + new ModuleManagerSecretsExtractedEvent({ + ctx: this.ctx(), + secrets, + }), + ); + + try { + const state = await this.fetch(); + if (!state || !state.configs) throw new BkndError("no config found"); + this.logger.log("fetched version", state.configs.version); + + if (state.configs.version !== version) { + // @todo: mark all others as "backup" + this.logger.log( + "version conflict, storing new version", + state.configs.version, + version, + ); + const updates = [ + { + version: state.configs.version, + type: "backup", + json: state.configs.json, + }, + { + version: version, + type: "config", + json: configs, + }, + ]; + + if (store_secrets) { + updates.push({ + version: version, + type: "secrets", + json: secrets as any, + }); + } + await this.mutator().insertMany(updates); + } else { + this.logger.log("version matches", state.configs.version); + + // clean configs because of Diff() function + const diffs = $diff.diff(state.configs.json, $diff.clone(configs)); + this.logger.log("checking diff", [diffs.length]); + const date = new Date(); + + if (diffs.length > 0) { + // validate diffs, it'll throw on invalid + this.validateDiffs(diffs); + + // store diff + await this.mutator().insertOne({ + version, + type: "diff", + json: $diff.clone(diffs), + created_at: date, + updated_at: date, + }); + + // store new version + await this.mutator().updateWhere( + { + version, + json: configs, + updated_at: date, + } as any, + { + type: "config", + version, + }, + ); + } else { + this.logger.log("no diff, not saving"); + } + + // store secrets + if (store_secrets) { + if (!state.secrets || state.secrets?.version !== version) { + await this.mutator().insertOne({ + version, + type: "secrets", + json: secrets, + created_at: date, + updated_at: date, + }); + } else { + await this.mutator().updateOne(state.secrets.id!, { + version, + json: secrets, + updated_at: date, + } as any); + } + } + } + } catch (e) { + if (e instanceof BkndError && e.message === "no config found") { + this.logger.log("no config, just save fresh"); + // no config, just save + await this.mutator().insertOne({ + type: "config", + version, + json: configs, + created_at: new Date(), + updated_at: new Date(), + }); + if (store_secrets) { + await this.mutator().insertOne({ + type: "secrets", + version, + json: secrets, + created_at: new Date(), + updated_at: new Date(), + }); + } + } else if (e instanceof TransformPersistFailedException) { + $console.error("ModuleManager: Cannot save invalid config"); + this.revertModules(); + throw e; + } else { + $console.error("ModuleManager: Aborting"); + this.revertModules(); + throw e; + } + } + + // re-apply configs to all modules (important for system entities) + await this.setConfigs(this.configs()); + + // @todo: cleanup old versions? + + this.logger.clear(); + return this; + } + + private async revertModules() { + if (this._stable_configs) { + $console.warn("ModuleManager: Reverting modules"); + await this.setConfigs(this._stable_configs as any); + } else { + $console.error("ModuleManager: No stable configs to revert to"); + } + } + + /** + * Validates received diffs for an additional security control. + * Checks: + * - check if module is registered + * - run modules onBeforeUpdate() for added protection + * + * **Important**: Throw `Error` so it won't get catched. + * + * @param diffs + * @private + */ + private validateDiffs(diffs: $diff.DiffEntry[]): void { + // check top level paths, and only allow a single module to be modified in a single transaction + const modules = [...new Set(diffs.map((d) => d.p[0]))]; + if (modules.length === 0) { + return; + } + + for (const moduleName of modules) { + const name = moduleName as ModuleKey; + const module = this.get(name) as Module; + if (!module) { + const msg = "validateDiffs: module not registered"; + // biome-ignore format: ... + $console.error(msg, JSON.stringify({ module: name, diffs }, null, 2)); + throw new Error(msg); + } + + // pass diffs to the module to allow it to throw + if (this._stable_configs?.[name]) { + const current = $diff.clone(this._stable_configs?.[name]); + const modified = $diff.apply({ [name]: current }, diffs)[name]; + module.onBeforeUpdate(current, modified); + } + } + } + + override async build(opts?: { fetch?: boolean }) { + this.logger.context("build").log("version", this.version()); + await this.ctx().connection.init(); + + // if no config provided, try fetch from db + if (this.version() === 0 || opts?.fetch === true) { + if (opts?.fetch) { + this.logger.log("force fetch"); + } + + const result = await this.fetch(); + + // if no version, and nothing found, go with initial + if (!result?.configs) { + this.logger.log("nothing in database, go initial"); + await this.setupInitial(); + } else { + this.logger.log("db has", result.configs.version); + // set version and config from fetched + this._version = result.configs.version; + + if (result?.configs && result?.secrets) { + for (const [key, value] of Object.entries(result.secrets.json)) { + setPath(result.configs.json, key, value); + } + } + + if (this.options?.skipValidation === true) { + this.logger.log("skipping validation (mark)"); + mark(result.configs.json); + } + + // if version doesn't match, migrate before building + if (this.version() !== CURRENT_VERSION) { + this.logger.log("now migrating"); + + await this.syncConfigTable(); + + const version_before = this.version(); + const [_version, _configs] = await migrate(version_before, result.configs.json, { + db: this.db, + }); + + this._version = _version; + this.ctx().flags.sync_required = true; + + this.logger.log("migrated to", _version); + $console.log("Migrated config from", version_before, "to", this.version()); + + // @ts-expect-error + await this.setConfigs(_configs); + await this.buildModules(); + } else { + this.logger.log("version is current", this.version()); + + await this.setConfigs(result.configs.json); + await this.buildModules(); + } + } + } else { + if (this.version() !== CURRENT_VERSION) { + throw new Error( + `Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`, + ); + } + this.logger.log("current version is up to date", this.version()); + await this.buildModules(); + } + + this.logger.log("done"); + this.logger.clear(); + return this; + } + + protected override async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) { + const state = { + built: false, + modules: [] as ModuleKey[], + synced: false, + saved: false, + reloaded: false, + }; + + this.logger.context("buildModules").log("triggered", options, this._built); + if (options?.graceful && this._built) { + this.logger.log("skipping build (graceful)"); + return state; + } + + this.logger.log("building"); + const ctx = this.ctx(true); + for (const key in this.modules) { + await this.modules[key].setContext(ctx).build(); + this.logger.log("built", key); + state.modules.push(key as ModuleKey); + } + + this._built = state.built = true; + this.logger.log("modules built", ctx.flags); + + if (this.options?.onModulesBuilt) { + await this.options.onModulesBuilt(ctx); + } + + if (options?.ignoreFlags !== true) { + if (ctx.flags.sync_required) { + ctx.flags.sync_required = false; + this.logger.log("db sync requested"); + + // sync db + await ctx.em.schema().sync(this.buildSyncConfig); + state.synced = true; + + // save + await this.save(); + state.saved = true; + } + + if (ctx.flags.ctx_reload_required) { + ctx.flags.ctx_reload_required = false; + this.logger.log("ctx reload requested"); + this.ctx(true); + state.reloaded = true; + } + } + + // reset all falgs + this.logger.log("resetting flags"); + ctx.flags = Module.ctx_flags; + + // storing last stable config version + this._stable_configs = $diff.clone(this.configs()); + + this.logger.clear(); + return state; + } + + protected async setupInitial() { + this.logger.context("initial").log("start"); + this._version = CURRENT_VERSION; + await this.syncConfigTable(); + + const state = await this.buildModules(); + + // in case there are secrets provided, we need to extract the keys and merge them with the configs. Then another build is required. + if (this.options?.secrets) { + const { configs, extractedKeys } = this.extractSecrets(); + for (const key of extractedKeys) { + if (key in this.options.secrets) { + setPath(configs, key, this.options.secrets[key]); + } + } + await this.setConfigs(configs); + await this.buildModules(); + } + + // generally only save if not already done + // unless secrets are provided, then we need to save again + if (!state.saved || this.options?.secrets) { + await this.save(); + } + + const ctx = { + ...this.ctx(), + // disable events for initial setup + em: this.ctx().em.fork(), + }; + + // perform a sync + await ctx.em.schema().sync({ force: true }); + await this.options?.seed?.(ctx); + + // run first boot event + await this.options?.onFirstBoot?.(); + this.logger.clear(); + } + + mutateConfigSafe( + name: Module, + ): Pick, "set" | "patch" | "overwrite" | "remove"> { + const module = this.modules[name]; + + return new Proxy(module.schema(), { + get: (target, prop: string) => { + if (!["set", "patch", "overwrite", "remove"].includes(prop)) { + throw new Error(`Method ${prop} is not allowed`); + } + + return async (...args) => { + $console.log("[Safe Mutate]", name); + try { + // overwrite listener to run build inside this try/catch + module.setListener(async () => { + await this.emgr.emit( + new ModuleManagerConfigUpdateEvent({ + ctx: this.ctx(), + module: name, + config: module.config as any, + }), + ); + await this.buildModules(); + }); + + const result = await target[prop](...args); + + // revert to original listener + module.setListener(async (c) => { + await this.onModuleConfigUpdated(name, c); + }); + + // if there was an onUpdated listener, call it after success + // e.g. App uses it to register module routes + if (this.options?.onUpdated) { + await this.options.onUpdated(name, module.config as any); + } + + return result; + } catch (e) { + $console.error(`[Safe Mutate] failed "${name}":`, e); + + // revert to previous config & rebuild using original listener + this.revertModules(); + await this.onModuleConfigUpdated(name, module.config as any); + $console.warn(`[Safe Mutate] reverted "${name}":`); + + // make sure to throw the error + throw e; + } + }; + }, + }); + } + + override version() { + return this._version; + } +} diff --git a/app/src/modules/migrations.ts b/app/src/modules/db/migrations.ts similarity index 93% rename from app/src/modules/migrations.ts rename to app/src/modules/db/migrations.ts index 3ce4ffb..13f39ee 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/db/migrations.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; @@ -99,6 +99,14 @@ export const migrations: Migration[] = [ }; }, }, + { + // remove secrets, automatic + // change media table `entity_id` from integer to text + version: 10, + up: async (config) => { + return config; + }, + }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts new file mode 100644 index 0000000..f5fa6b4 --- /dev/null +++ b/app/src/modules/mcp/$object.ts @@ -0,0 +1,138 @@ +import { Tool, getPath, limitObjectDepth, s } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type McpSchema, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {} + +export class ObjectToolSchema< + const P extends s.TProperties = s.TProperties, + const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions, + > + extends s.ObjectSchema + implements McpSchema +{ + constructor(name: string, properties: P, options?: ObjectToolSchemaOptions) { + const { mcp, ...rest } = options || {}; + + super(properties, rest as any); + this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {}); + } + + get mcp(): McpSchemaHelper { + return this[mcpSchemaSymbol]; + } + + private toolGet(node: s.Node) { + return new Tool( + [this.mcp.name, "get"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + path: s + .string({ + pattern: /^[a-zA-Z0-9_.]{0,}$/, + title: "Path", + description: "Path to the property to get, e.g. `key.subkey`", + }) + .optional(), + depth: s + .number({ + description: "Limit the depth of the response", + }) + .optional(), + secrets: s + .boolean({ + default: false, + description: "Include secrets in the response config", + }) + .optional(), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const config = getPath(configs, node.instancePath); + let value = getPath(config, params.path ?? []); + + if (params.depth) { + value = limitObjectDepth(value, params.depth); + } + + return ctx.json({ + path: params.path ?? "", + secrets: params.secrets ?? false, + partial: !!params.depth, + value: value ?? null, + }); + }, + ); + } + + private toolUpdate(node: s.Node) { + const schema = this.mcp.cleanSchema; + + return new Tool( + [this.mcp.name, "update"].join("_"), + { + ...this.mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + full: s.boolean({ default: false }).optional(), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), + value: s.strictObject(schema.properties as {}).partial(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const { full, value, return_config } = params; + const [module_name] = node.instancePath; + const manager = this.mcp.getManager(ctx); + + if (full) { + await manager.mutateConfigSafe(module_name as any).set(value); + } else { + await manager.mutateConfigSafe(module_name as any).patch("", value); + } + + let config: any = undefined; + if (return_config) { + const configs = ctx.context.app.toJSON(); + config = getPath(configs, node.instancePath); + } + + return ctx.json({ + success: true, + module: module_name, + config, + }); + }, + ); + } + + getTools(node: s.Node): Tool[] { + const { tools = [] } = this.mcp.options; + return [this.toolGet(node), this.toolUpdate(node), ...tools]; + } +} + +export const $object = < + const P extends s.TProperties = s.TProperties, + const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions, +>( + name: string, + properties: P, + options?: s.StrictOptions, +): ObjectToolSchema & O => { + return new ObjectToolSchema(name, properties, options) as any; +}; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts new file mode 100644 index 0000000..fc6dfaa --- /dev/null +++ b/app/src/modules/mcp/$record.ts @@ -0,0 +1,268 @@ +import { getPath, s, Tool } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type McpSchema, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +type RecordToolAdditionalOptions = { + get?: boolean; + add?: boolean; + update?: boolean; + remove?: boolean; +}; + +export interface RecordToolSchemaOptions + extends s.IRecordOptions, + SchemaWithMcpOptions {} + +const opts = Symbol.for("bknd-mcp-record-opts"); + +export class RecordToolSchema< + AP extends s.Schema, + O extends RecordToolSchemaOptions = RecordToolSchemaOptions, + > + extends s.RecordSchema + implements McpSchema +{ + constructor(name: string, ap: AP, options?: RecordToolSchemaOptions, new_schema?: s.Schema) { + const { mcp, ...rest } = options || {}; + super(ap, rest as any); + + this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {}); + this[opts] = { + new_schema, + }; + } + + get mcp(): McpSchemaHelper { + return this[mcpSchemaSymbol]; + } + + private getNewSchema(fallback: s.Schema = this.additionalProperties) { + return this[opts].new_schema ?? this.additionalProperties ?? fallback; + } + + private toolGet(node: s.Node>) { + return new Tool( + [this.mcp.name, "get"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + key: s + .string({ + description: "key to get", + }) + .optional(), + secrets: s + .boolean({ + default: false, + description: "(optional) include secrets in the response config", + }) + .optional(), + schema: s + .boolean({ + default: false, + description: "(optional) include the schema in the response", + }) + .optional(), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const config = getPath(configs, node.instancePath); + const [module_name] = node.instancePath; + + // @todo: add schema to response + const schema = params.schema ? this.getNewSchema().toJSON() : undefined; + + if (params.key) { + if (!(params.key in config)) { + throw new Error(`Key "${params.key}" not found in config`); + } + const value = getPath(config, params.key); + return ctx.json({ + secrets: params.secrets ?? false, + module: module_name, + key: params.key, + value: value ?? null, + schema, + }); + } + + return ctx.json({ + secrets: params.secrets ?? false, + module: module_name, + key: null, + value: config ?? null, + schema, + }); + }, + ); + } + + private toolAdd(node: s.Node>) { + return new Tool( + [this.mcp.name, "add"].join("_"), + { + ...this.mcp.getToolOptions("add"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to add", + }), + value: this.getNewSchema(), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(true); + const config = getPath(configs, node.instancePath); + const [module_name, ...rest] = node.instancePath; + const manager = this.mcp.getManager(ctx); + + if (params.key in config) { + throw new Error(`Key "${params.key}" already exists in config`); + } + + await manager + .mutateConfigSafe(module_name as any) + .patch([...rest, params.key], params.value); + + const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); + + return ctx.json({ + success: true, + module: module_name, + action: { + type: "add", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, + }); + }, + ); + } + + private toolUpdate(node: s.Node>) { + return new Tool( + [this.mcp.name, "update"].join("_"), + { + ...this.mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to update", + }), + value: this.mcp.getCleanSchema(this.getNewSchema(s.object({}))), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(true); + const config = getPath(configs, node.instancePath); + const [module_name, ...rest] = node.instancePath; + const manager = this.mcp.getManager(ctx); + + if (!(params.key in config)) { + throw new Error(`Key "${params.key}" not found in config`); + } + + await manager + .mutateConfigSafe(module_name as any) + .patch([...rest, params.key], params.value); + + const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); + + return ctx.json({ + success: true, + module: module_name, + action: { + type: "update", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, + }); + }, + ); + } + + private toolRemove(node: s.Node>) { + return new Tool( + [this.mcp.name, "remove"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to remove", + }), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(true); + const config = getPath(configs, node.instancePath); + const [module_name, ...rest] = node.instancePath; + const manager = this.mcp.getManager(ctx); + + if (!(params.key in config)) { + throw new Error(`Key "${params.key}" not found in config`); + } + + await manager + .mutateConfigSafe(module_name as any) + .remove([...rest, params.key].join(".")); + + const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); + + return ctx.json({ + success: true, + module: module_name, + action: { + type: "remove", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, + }); + }, + ); + } + + getTools(node: s.Node>): Tool[] { + const { tools = [], get = true, add = true, update = true, remove = true } = this.mcp.options; + + return [ + get && this.toolGet(node), + add && this.toolAdd(node), + update && this.toolUpdate(node), + remove && this.toolRemove(node), + ...tools, + ].filter(Boolean) as Tool[]; + } +} + +export const $record = ( + name: string, + ap: AP, + options?: s.StrictOptions, + new_schema?: s.Schema, +): RecordToolSchema => new RecordToolSchema(name, ap, options, new_schema) as any; diff --git a/app/src/modules/mcp/$schema.ts b/app/src/modules/mcp/$schema.ts new file mode 100644 index 0000000..c71424b --- /dev/null +++ b/app/src/modules/mcp/$schema.ts @@ -0,0 +1,89 @@ +import { Tool, getPath, s } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +export interface SchemaToolSchemaOptions extends s.ISchemaOptions, SchemaWithMcpOptions {} + +export const $schema = < + const S extends s.Schema, + const O extends SchemaToolSchemaOptions = SchemaToolSchemaOptions, +>( + name: string, + schema: S, + options?: O, +): S => { + const mcp = new McpSchemaHelper(schema, name, options || {}); + + const toolGet = (node: s.Node) => { + return new Tool( + [mcp.name, "get"].join("_"), + { + ...mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + secrets: s + .boolean({ + default: false, + description: "Include secrets in the response config", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const value = getPath(configs, node.instancePath); + + return ctx.json({ + secrets: params.secrets ?? false, + value: value ?? null, + }); + }, + ); + }; + + const toolUpdate = (node: s.Node) => { + return new Tool( + [mcp.name, "update"].join("_"), + { + ...mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + value: schema as any, + return_config: s.boolean({ default: false }).optional(), + secrets: s.boolean({ default: false }).optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const { value, return_config, secrets } = params; + const [module_name, ...rest] = node.instancePath; + const manager = mcp.getManager(ctx); + + await manager.mutateConfigSafe(module_name as any).overwrite(rest, value); + + let config: any = undefined; + if (return_config) { + const configs = ctx.context.app.toJSON(secrets); + config = getPath(configs, node.instancePath); + } + + return ctx.json({ + success: true, + module: module_name, + config, + }); + }, + ); + }; + + const getTools = (node: s.Node) => { + const { tools = [] } = mcp.options; + return [toolGet(node), toolUpdate(node), ...tools]; + }; + + return Object.assign(schema, { + [mcpSchemaSymbol]: mcp, + getTools, + }); +}; diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts new file mode 100644 index 0000000..ecb16e4 --- /dev/null +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -0,0 +1,87 @@ +import type { App } from "bknd"; +import { + type Tool, + type ToolAnnotation, + type Resource, + type ToolHandlerCtx, + s, + isPlainObject, + autoFormatString, +} from "bknd/utils"; +import type { ModuleBuildContext } from "modules"; +import { excludePropertyTypes, rescursiveClean } from "./utils"; +import type { DbModuleManager } from "modules/db/DbModuleManager"; + +export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema"); + +export interface McpToolOptions { + title?: string; + description?: string; + annotations?: ToolAnnotation; + tools?: Tool[]; + resources?: Resource[]; +} + +export type SchemaWithMcpOptions = { + mcp?: McpToolOptions & AdditionalOptions; +}; + +export type AppToolContext = { + app: App; + ctx: () => ModuleBuildContext; +}; +export type AppToolHandlerCtx = ToolHandlerCtx; + +export interface McpSchema extends s.Schema { + getTools(node: s.Node): Tool[]; +} + +export class McpSchemaHelper { + cleanSchema: s.ObjectSchema; + + constructor( + public schema: s.Schema, + public name: string, + public options: McpToolOptions & AdditionalOptions, + ) { + this.cleanSchema = this.getCleanSchema(this.schema as s.ObjectSchema); + } + + getCleanSchema(schema: s.ObjectSchema) { + if (schema.type !== "object") return schema; + + const props = excludePropertyTypes( + schema as any, + (i) => isPlainObject(i) && mcpSchemaSymbol in i, + ); + const _schema = s.strictObject(props); + return rescursiveClean(_schema, { + removeRequired: true, + removeDefault: false, + }) as s.ObjectSchema; + } + + getToolOptions(suffix?: string) { + const { tools, resources, ...rest } = this.options; + const label = (text?: string) => + text && [suffix && autoFormatString(suffix), text].filter(Boolean).join(" "); + return { + title: label(this.options.title ?? this.schema.title), + description: label(this.options.description ?? this.schema.description), + annotations: { + destructiveHint: true, + idempotentHint: true, + ...rest.annotations, + }, + }; + } + + getManager(ctx: AppToolHandlerCtx): DbModuleManager { + const manager = ctx.context.app.modules; + if ("mutateConfigSafe" in manager) { + return manager as DbModuleManager; + } + + throw new Error("Manager not found"); + } +} diff --git a/app/src/modules/mcp/index.ts b/app/src/modules/mcp/index.ts new file mode 100644 index 0000000..9a19c09 --- /dev/null +++ b/app/src/modules/mcp/index.ts @@ -0,0 +1,4 @@ +export * from "./$object"; +export * from "./$record"; +export * from "./$schema"; +export * from "./McpSchemaHelper"; diff --git a/app/src/modules/mcp/system-mcp.ts b/app/src/modules/mcp/system-mcp.ts new file mode 100644 index 0000000..deabb4d --- /dev/null +++ b/app/src/modules/mcp/system-mcp.ts @@ -0,0 +1,39 @@ +import type { App } from "App"; +import { mcpSchemaSymbol, type McpSchema } from "modules/mcp"; +import { getMcpServer, isObject, s, McpServer } from "bknd/utils"; +import { getVersion } from "core/env"; + +export function getSystemMcp(app: App) { + const middlewareServer = getMcpServer(app.server); + + //const appConfig = app.modules.configs(); + const { version, ...appSchema } = app.getSchema(); + const schema = s.strictObject(appSchema); + const result = [...schema.walk({ maxDepth: 3 })]; + const nodes = result.filter((n) => mcpSchemaSymbol in n.schema) as s.Node[]; + const tools = [ + // tools from hono routes + ...middlewareServer.tools, + // tools added from ctx + ...app.modules.ctx().mcp.tools, + ].sort((a, b) => a.name.localeCompare(b.name)); + + // tools from app schema + if (!app.isReadOnly()) { + tools.push( + ...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)), + ); + } + + const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; + + return new McpServer( + { + name: "bknd", + version: getVersion(), + }, + { app, ctx: () => app.modules.ctx() }, + tools, + resources, + ); +} diff --git a/app/src/modules/mcp/utils.spec.ts b/app/src/modules/mcp/utils.spec.ts new file mode 100644 index 0000000..a947ac1 --- /dev/null +++ b/app/src/modules/mcp/utils.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "bun:test"; +import { excludePropertyTypes, rescursiveClean } from "./utils"; +import { s } from "../../core/utils/schema"; + +describe("rescursiveOptional", () => { + it("should make all properties optional", () => { + const schema = s.strictObject({ + a: s.string({ default: "a" }), + b: s.number(), + nested: s.strictObject({ + c: s.string().optional(), + d: s.number(), + nested2: s.record(s.string()), + }), + }); + + //console.log(schema.toJSON()); + const result = rescursiveClean(schema, { + removeRequired: true, + removeDefault: true, + }); + const json = result.toJSON(); + + expect(json.required).toBeUndefined(); + expect(json.properties.a.default).toBeUndefined(); + expect(json.properties.nested.required).toBeUndefined(); + expect(json.properties.nested.properties.nested2.required).toBeUndefined(); + }); + + it("should exclude properties", () => { + const schema = s.strictObject({ + a: s.string(), + b: s.number(), + }); + + const result = excludePropertyTypes(schema, (instance) => instance instanceof s.StringSchema); + expect(Object.keys(result).length).toBe(1); + }); +}); diff --git a/app/src/modules/mcp/utils.ts b/app/src/modules/mcp/utils.ts new file mode 100644 index 0000000..1307ea3 --- /dev/null +++ b/app/src/modules/mcp/utils.ts @@ -0,0 +1,49 @@ +import { isPlainObject, transformObject, s } from "bknd/utils"; + +export function rescursiveClean( + input: s.Schema, + opts?: { + removeRequired?: boolean; + removeDefault?: boolean; + }, +): s.Schema { + const json = input.toJSON(); + + const removeRequired = (obj: any) => { + if (isPlainObject(obj)) { + if ("required" in obj && opts?.removeRequired) { + obj.required = undefined; + } + + if ("default" in obj && opts?.removeDefault) { + obj.default = undefined; + } + + if ("properties" in obj && isPlainObject(obj.properties)) { + for (const key in obj.properties) { + obj.properties[key] = removeRequired(obj.properties[key]); + } + } + } + + return obj; + }; + + removeRequired(json); + return s.fromSchema(json); +} + +export function excludePropertyTypes( + input: s.ObjectSchema, + props: (instance: s.Schema | unknown) => boolean, +): s.TProperties { + const properties = { ...input.properties }; + + return transformObject(properties, (value, key) => { + if (props(value)) { + return undefined; + } + + return value; + }); +} 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 cc54754..152072d 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,9 +1,35 @@ -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"); 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 schemaRead = new Permission("system.schema.read"); +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", + {}, + 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 e80e652..a579ebb 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -12,6 +12,7 @@ import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; import type { TApiUser } from "Api"; import type { AppTheme } from "ui/client/use-theme"; +import type { Manifest } from "vite"; const htmlBkndContextReplace = ""; @@ -33,6 +34,7 @@ export type AdminControllerOptions = { debugRerenders?: boolean; theme?: AppTheme; logoReturnPath?: string; + manifest?: Manifest; }; export class AdminController extends Controller { @@ -92,7 +94,7 @@ export class AdminController extends Controller { logout: "/api/auth/logout", }; - const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"]; + const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*", "/tools/*"]; if (isDebug()) { paths.push("/test/*"); } @@ -113,8 +115,9 @@ 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) => ({}), }), async (c) => { const obj: AdminBkndWindowContext = { @@ -138,17 +141,19 @@ 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); + } + }, + context: (c) => ({}), + }; 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 as any), + permission(SystemPermissions.schemaRead, options), async (c) => { return c.html(c.get("html")!); }, @@ -191,12 +196,14 @@ export class AdminController extends Controller { const assets = { js: "main.js", - css: "styles.css", + css: ["styles.css"], }; if (isProd) { - let manifest: any; - if (this.options.assetsPath.startsWith("http")) { + let manifest: Manifest; + if (this.options.manifest) { + manifest = this.options.manifest; + } else if (this.options.assetsPath.startsWith("http")) { manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", { headers: { Accept: "application/json", @@ -205,14 +212,17 @@ export class AdminController extends Controller { } else { // @ts-ignore manifest = await import("bknd/dist/manifest.json", { - assert: { type: "json" }, + with: { type: "json" }, }).then((res) => res.default); } try { - // @todo: load all marked as entry (incl. css) - assets.js = manifest["src/ui/main.tsx"].file; - assets.css = manifest["src/ui/main.tsx"].css[0] as any; + const entry = Object.values(manifest).find((m) => m.isEntry); + if (!entry) { + throw new Error("No entry found in manifest"); + } + assets.js = entry?.file; + assets.css = entry?.css ?? []; } catch (e) { $console.warn("Couldn't find assets in manifest", e); } @@ -242,7 +252,9 @@ export class AdminController extends Controller { {isProd ? (