diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc89cce..d9f51ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,21 @@ jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: bknd + ports: + - 5430:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 @@ -20,11 +35,11 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.2.22" + bun-version: "1.3.3" - name: Install dependencies working-directory: ./app - run: bun install + run: bun install #--linker=hoisted - name: Build working-directory: ./app diff --git a/.gitignore b/.gitignore index d26f2de..55aaa73 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,8 @@ packages/media/.env .npmrc /.verdaccio .idea -.vscode +.vscode/* +!.vscode/settings.json .git_old docker/tmp .debug diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5c3e1c6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "biome.enabled": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + //"source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" + }, + "typescript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.autoImportFileExcludePatterns": [ + "**/dist/**", + "**/node_modules/**/dist/**", + "**/node_modules/**/!(src|lib|esm)/**" // optional, stricter + ], + "typescript.preferences.includePackageJsonAutoImports": "on", + "typescript.tsserver.watchOptions": { + "excludeDirectories": ["**/dist", "**/node_modules/**/dist"] + } +} diff --git a/README.md b/README.md index 0cba095..2d08d83 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu * 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 +* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS) * **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc. **For documentation and examples, please visit https://docs.bknd.io.** diff --git a/app/.env.example b/app/.env.example index a70d8e7..463c8a7 100644 --- a/app/.env.example +++ b/app/.env.example @@ -20,6 +20,7 @@ VITE_SHOW_ROUTES= # ===== Test Credentials ===== RESEND_API_KEY= +PLUNK_API_KEY= R2_TOKEN= R2_ACCESS_KEY= diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json new file mode 100644 index 0000000..adf0255 --- /dev/null +++ b/app/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "typescript.preferences.includePackageJsonAutoImports": "off", + "typescript.suggest.autoImports": true, + "typescript.preferences.importModuleSpecifier": "relative", + "search.exclude": { + "**/dist/**": true, + "**/node_modules/**": true + }, + "files.exclude": { + "**/dist/**": true + } +} diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 7213216..589bd73 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -108,7 +108,7 @@ describe("App tests", async () => { expect(Array.from(app.plugins.keys())).toEqual(["test"]); }); - test.only("drivers", async () => { + test("drivers", async () => { const called: string[] = []; const app = new App(dummyConnection, undefined, { drivers: { diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index 95cac8e..02a35a6 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -15,7 +15,7 @@ const mockedBackend = new Hono() .get("/file/:name", async (c) => { const { name } = c.req.param(); const file = Bun.file(`${assetsPath}/${name}`); - return new Response(file, { + return new Response(new File([await file.bytes()], name, { type: file.type }), { headers: { "Content-Type": file.type, "Content-Length": file.size.toString(), @@ -67,7 +67,7 @@ describe("MediaApi", () => { const res = await mockedBackend.request("/api/media/file/" + name); await Bun.write(path, res); - const file = await Bun.file(path); + const file = Bun.file(path); expect(file.size).toBeGreaterThan(0); expect(file.type).toBe("image/png"); await file.delete(); @@ -154,15 +154,12 @@ describe("MediaApi", () => { } // upload via readable from bun - await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); + await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); // upload via readable from response { const response = (await mockedBackend.request(url)) as Response; - await matches( - await api.upload(response.body!, { filename: "readable.png" }), - "readable.png", - ); + await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png"); } }); }); diff --git a/app/__test__/app/code-only.test.ts b/app/__test__/app/code-only.test.ts index 26fb8e9..003c1e4 100644 --- a/app/__test__/app/code-only.test.ts +++ b/app/__test__/app/code-only.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, mock, test, beforeAll, afterAll } 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"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); async function createApp(config: CreateAppConfig = {}) { const app = internalCreateApp({ diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts index de52198..63f6880 100644 --- a/app/__test__/app/mcp/mcp.system.test.ts +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -1,7 +1,11 @@ import { AppEvents } from "App"; -import { describe, test, expect, beforeAll, mock } from "bun:test"; +import { describe, test, expect, beforeAll, mock, 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] system_config diff --git a/app/__test__/app/modes.test.ts b/app/__test__/app/modes.test.ts new file mode 100644 index 0000000..034cf92 --- /dev/null +++ b/app/__test__/app/modes.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { code, hybrid } from "modes"; + +describe("modes", () => { + describe("code", () => { + test("verify base configuration", async () => { + const c = code({}) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["options"]); + expect(config.options.mode).toEqual("code"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + + test("keeps overrides", async () => { + const c = code({ + connection: { + url: ":memory:", + }, + }) as any; + const config = await c.app?.({} as any); + expect(config.connection.url).toEqual(":memory:"); + }); + }); + + describe("hybrid", () => { + test("fails if no reader is provided", () => { + // @ts-ignore + expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/); + }); + test("verify base configuration", async () => { + const c = hybrid({ reader: async () => ({}) }) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]); + expect(config.options.mode).toEqual("db"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + }); +}); diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts index 54ff7e2..fc099bb 100644 --- a/app/__test__/app/repro.spec.ts +++ b/app/__test__/app/repro.spec.ts @@ -76,7 +76,7 @@ describe("repros", async () => { expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); }); - test.only("verify inversedBy", async () => { + test("verify inversedBy", async () => { const schema = proto.em( { products: proto.entity("products", { diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index caa5566..2f920ff 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; 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"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); function createGuard( permissionNames: string[], diff --git a/app/__test__/auth/authorize/data.permissions.test.ts b/app/__test__/auth/authorize/data.permissions.test.ts index 6ff0c3e..5b796c2 100644 --- a/app/__test__/auth/authorize/data.permissions.test.ts +++ b/app/__test__/auth/authorize/data.permissions.test.ts @@ -7,8 +7,8 @@ import type { App, DB } from "bknd"; import type { CreateUserPayload } from "auth/AppAuth"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; -beforeAll(() => disableConsoleLog()); -afterAll(() => enableConsoleLog()); +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); async function makeApp(config: Partial = {}) { const app = createApp({ diff --git a/app/__test__/auth/authorize/http/DataController.test.ts b/app/__test__/auth/authorize/http/DataController.test.ts new file mode 100644 index 0000000..0a4fc52 --- /dev/null +++ b/app/__test__/auth/authorize/http/DataController.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { em, entity, text } from "data/prototype"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +const schema = em( + { + posts: entity("posts", { + title: text(), + content: text(), + }), + comments: entity("comments", { + content: text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(posts).manyToOne(comments); + }, +); + +describe("DataController (auth)", () => { + test("reading schema.json", async () => { + const { request } = await createAuthTestApp( + { + permission: ["system.access.api", "data.entity.read", "system.schema.read"], + request: new Request("http://localhost/api/data/schema.json"), + }, + { + config: { data: schema.toJSON() }, + }, + ); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts deleted file mode 100644 index 40e6493..0000000 --- a/app/__test__/auth/authorize/http/SystemController.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/http/SystemController.test.ts b/app/__test__/auth/authorize/http/SystemController.test.ts new file mode 100644 index 0000000..7974a0d --- /dev/null +++ b/app/__test__/auth/authorize/http/SystemController.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("SystemController (auth)", () => { + test("reading info", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.info"], + request: new Request("http://localhost/api/system/info"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("reading permissions", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.schema.read"], + request: new Request("http://localhost/api/system/permissions"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("access openapi", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.openapi"], + request: new Request("http://localhost/api/system/openapi.json"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/shared.ts b/app/__test__/auth/authorize/http/shared.ts new file mode 100644 index 0000000..7ec79f9 --- /dev/null +++ b/app/__test__/auth/authorize/http/shared.ts @@ -0,0 +1,171 @@ +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import type { RoleSchema } from "auth/authorize/Role"; +import { isPlainObject } from "core/utils"; + +export type AuthTestConfig = { + guest?: RoleSchema; + member?: RoleSchema; + authorized?: RoleSchema; +}; + +export async function createAuthTestApp( + testConfig: { + permission: AuthTestConfig | string | string[]; + request: Request; + }, + config: Partial = {}, +) { + let member: RoleSchema | undefined; + let authorized: RoleSchema | undefined; + let guest: RoleSchema | undefined; + if (isPlainObject(testConfig.permission)) { + if (testConfig.permission.guest) + guest = { + ...testConfig.permission.guest, + is_default: true, + }; + if (testConfig.permission.member) member = testConfig.permission.member; + if (testConfig.permission.authorized) authorized = testConfig.permission.authorized; + } else { + member = { + permissions: [], + }; + authorized = { + permissions: Array.isArray(testConfig.permission) + ? testConfig.permission + : [testConfig.permission], + }; + guest = { + permissions: [], + is_default: true, + }; + } + + console.log("authorized", authorized); + + const app = createApp({ + ...config, + config: { + ...config.config, + auth: { + ...config.config?.auth, + enabled: true, + guard: { + enabled: true, + ...config.config?.auth?.guard, + }, + jwt: { + ...config.config?.auth?.jwt, + secret: "secret", + }, + roles: { + ...config.config?.auth?.roles, + guest, + member, + authorized, + admin: { + implicit_allow: true, + }, + }, + }, + }, + }); + await app.build(); + + const users = { + guest: null, + member: await app.createUser({ + email: "member@test.com", + password: "12345678", + role: "member", + }), + authorized: await app.createUser({ + email: "authorized@test.com", + password: "12345678", + role: "authorized", + }), + admin: await app.createUser({ + email: "admin@test.com", + password: "12345678", + role: "admin", + }), + } as const; + + const tokens = {} as Record; + for (const [key, user] of Object.entries(users)) { + if (user) { + tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user); + } + } + + async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) { + const headers = new Headers(init.headers ?? {}); + if (user in tokens) { + headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`); + } + const res = await app.server.request(input, { + ...init, + headers, + }); + + let data: any; + if (res.headers.get("Content-Type")?.startsWith("application/json")) { + data = await res.json(); + } else if (res.headers.get("Content-Type")?.startsWith("text/")) { + data = await res.text(); + } + + return { + status: res.status, + ok: res.ok, + headers: Object.fromEntries(res.headers.entries()), + data, + }; + } + + const requestFn = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async (input: string, init: RequestInit = {}) => { + return makeRequest(prop, input, init); + }; + }, + }, + ) as { + [K in keyof typeof users]: ( + input: string, + init?: RequestInit, + ) => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + const request = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async () => { + return makeRequest(prop, testConfig.request.url, { + headers: testConfig.request.headers, + method: testConfig.request.method, + body: testConfig.request.body, + }); + }; + }, + }, + ) as { + [K in keyof typeof users]: () => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + return { app, users, request, requestFn }; +} diff --git a/app/__test__/auth/strategies/PasswordStrategy.spec.ts b/app/__test__/auth/strategies/PasswordStrategy.spec.ts new file mode 100644 index 0000000..7b290a4 --- /dev/null +++ b/app/__test__/auth/strategies/PasswordStrategy.spec.ts @@ -0,0 +1,13 @@ +import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; +import { describe, expect, it } from "bun:test"; + +describe("PasswordStrategy", () => { + it("should enforce provided minimum length", async () => { + const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" }); + + expect(strategy.verify("password")({} as any)).rejects.toThrow(); + expect( + strategy.verify("password1234")({ strategy_value: "password1234" } as any), + ).resolves.toBeUndefined(); + }); +}); diff --git a/app/__test__/data/postgres.test.ts b/app/__test__/data/postgres.test.ts new file mode 100644 index 0000000..1560204 --- /dev/null +++ b/app/__test__/data/postgres.test.ts @@ -0,0 +1,85 @@ +import { describe, beforeAll, afterAll, test } from "bun:test"; +import type { PostgresConnection } from "data/connection/postgres/PostgresConnection"; +import { pg, postgresJs } from "bknd"; +import { Pool } from "pg"; +import postgres from "postgres"; +import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils"; +import { $ } from "bun"; +import { connectionTestSuite } from "data/connection/connection-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +const credentials = { + host: "localhost", + port: 5430, + user: "postgres", + password: "postgres", + database: "bknd", +}; + +async function cleanDatabase(connection: InstanceType) { + const kysely = connection.kysely; + + // drop all tables+indexes & create new schema + await kysely.schema.dropSchema("public").ifExists().cascade().execute(); + await kysely.schema.dropIndex("public").ifExists().cascade().execute(); + await kysely.schema.createSchema("public").execute(); +} + +async function isPostgresRunning() { + try { + // Try to actually connect to PostgreSQL + const conn = pg({ pool: new Pool(credentials) }); + await conn.ping(); + await conn.close(); + return true; + } catch (e) { + return false; + } +} + +describe("postgres", () => { + beforeAll(async () => { + if (!(await isPostgresRunning())) { + await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`; + await $waitUntil("Postgres is running", isPostgresRunning); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + disableConsoleLog(); + }); + afterAll(async () => { + if (await isPostgresRunning()) { + try { + await $`docker stop bknd-test-postgres`; + } catch (e) {} + } + + enableConsoleLog(); + }); + + describe.serial.each([ + ["pg", () => pg({ pool: new Pool(credentials) })], + ["postgresjs", () => postgresJs({ postgres: postgres(credentials) })], + ])("%s", (name, createConnection) => { + connectionTestSuite( + { + ...bunTestRunner, + test: test.serial, + }, + { + makeConnection: () => { + const connection = createConnection(); + return { + connection, + dispose: async () => { + await cleanDatabase(connection); + await connection.close(); + }, + }; + }, + rawDialectDetails: [], + disableConsoleLog: false, + }, + ); + }); +}); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index d9b2dc2..efb987d 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -124,6 +124,81 @@ describe("[Repository]", async () => { .then((r) => [r.count, r.total]), ).resolves.toEqual([undefined, undefined]); }); + + test("auto join", async () => { + const schema = $em( + { + posts: $entity("posts", { + title: $text(), + content: $text(), + }), + comments: $entity("comments", { + content: $text(), + }), + another: $entity("another", { + title: $text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(comments).manyToOne(posts); + }, + ); + const em = schema.proto.withConnection(getDummyConnection().dummyConnection); + await em.schema().sync({ force: true }); + + await em.mutator("posts").insertOne({ title: "post1", content: "content1" }); + await em + .mutator("comments") + .insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any); + + const res = await em.repo("comments").findMany({ + where: { + "posts.title": "post1", + }, + }); + expect(res.data as any).toEqual([ + { + id: 1, + content: "comment1", + posts_id: 1, + }, + ]); + + { + // manual join should still work + const res = await em.repo("comments").findMany({ + join: ["posts"], + where: { + "posts.title": "post1", + }, + }); + expect(res.data as any).toEqual([ + { + id: 1, + content: "comment1", + posts_id: 1, + }, + ]); + } + + // inexistent should be detected and thrown + expect( + em.repo("comments").findMany({ + where: { + "random.title": "post1", + }, + }), + ).rejects.toThrow(/Invalid where field/); + + // existing alias, but not a relation should throw + expect( + em.repo("comments").findMany({ + where: { + "another.title": "post1", + }, + }), + ).rejects.toThrow(/Invalid where field/); + }); }); describe("[data] Repository (Events)", async () => { diff --git a/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts b/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts index ee46b7b..a884674 100644 --- a/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts +++ b/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts @@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => { dataType: "INTEGER", isNullable: false, isAutoIncrementing: true, - hasDefaultValue: false, + hasDefaultValue: true, comment: undefined, }, { @@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => { dataType: "INTEGER", isNullable: false, isAutoIncrementing: true, - hasDefaultValue: false, + hasDefaultValue: true, comment: undefined, }, { diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index 477951f..b63b2b5 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,9 +1,15 @@ 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 { + mergeObject, + randomString, + secureRandomString, + withDisabledConsole, +} from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { getDummyConnection } from "../helper"; +import type { AppAuthSchema } from "auth/auth-schema"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -62,12 +68,12 @@ const configs = { }, }; -function createAuthApp() { +function createAuthApp(config?: Partial) { const { dummyConnection } = getDummyConnection(); const app = createApp({ connection: dummyConnection, config: { - auth: configs.auth, + auth: mergeObject(configs.auth, config ?? {}), }, }); @@ -132,6 +138,16 @@ const fns = (app: App, mode?: Mode) = return { res, data }; }, + register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => { + const res = (await app.server.request("/api/auth/password/register", { + method: "POST", + headers: headers(), + body: body(user), + })) as Response; + const data = mode === "cookie" ? getCookie(res, "auth") : await res.json(); + + return { res, data }; + }, me: async (token?: string): Promise> => { const res = (await app.server.request("/api/auth/me", { method: "GET", @@ -245,4 +261,61 @@ describe("integration auth", () => { expect(await $fns.me()).toEqual({ user: null as any }); } }); + + it("should register users with default role", async () => { + const app = createAuthApp({ default_role_register: "guest" }); + await app.build(); + const $fns = fns(app); + + // takes default role + expect( + await app + .createUser({ + email: "test@bknd.io", + password: "12345678", + }) + .then((r) => r.role), + ).toBe("guest"); + + // throws error if role doesn't exist + expect( + app.createUser({ + email: "test@bknd.io", + password: "12345678", + role: "doesnt exist", + }), + ).rejects.toThrow(); + + // takes role if provided + expect( + await app + .createUser({ + email: "test2@bknd.io", + password: "12345678", + role: "admin", + }) + .then((r) => r.role), + ).toBe("admin"); + + // registering with role is not allowed + expect( + await $fns + .register({ + email: "test3@bknd.io", + password: "12345678", + role: "admin", + }) + .then((r) => r.res.ok), + ).toBe(false); + + // takes default role + expect( + await $fns + .register({ + email: "test3@bknd.io", + password: "12345678", + }) + .then((r) => r.data.user.role), + ).toBe("guest"); + }); }); diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index bf62599..3eae83e 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(() => { - //disableConsoleLog(); + disableConsoleLog(); registries.media.register("local", StorageLocalAdapter); }); afterAll(enableConsoleLog); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 89872de..59b0b1c 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -10,12 +10,6 @@ beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("AppAuth", () => { - test.skip("...", () => { - const auth = new AppAuth({}); - console.log(auth.toJSON()); - console.log(auth.config); - }); - moduleTestSuite(AppAuth); let ctx: ModuleBuildContext; @@ -39,11 +33,9 @@ describe("AppAuth", () => { await auth.build(); const oldConfig = auth.toJSON(true); - //console.log(oldConfig); await auth.schema().patch("enabled", true); await auth.build(); const newConfig = auth.toJSON(true); - //console.log(newConfig); expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); }); @@ -69,7 +61,6 @@ describe("AppAuth", () => { const app = new AuthController(auth).getController(); { - disableConsoleLog(); const res = await app.request("/password/register", { method: "POST", headers: { @@ -80,7 +71,6 @@ describe("AppAuth", () => { password: "12345678", }), }); - enableConsoleLog(); expect(res.status).toBe(200); const { data: users } = await ctx.em.repository("users").findMany(); @@ -119,7 +109,6 @@ describe("AppAuth", () => { const app = new AuthController(auth).getController(); { - disableConsoleLog(); const res = await app.request("/password/register", { method: "POST", headers: { @@ -130,7 +119,6 @@ describe("AppAuth", () => { password: "12345678", }), }); - enableConsoleLog(); expect(res.status).toBe(200); const { data: users } = await ctx.em.repository("users").findMany(); @@ -235,4 +223,32 @@ describe("AppAuth", () => { } } }); + + test("default role for registration must be a valid role", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "123456", + }, + allow_register: true, + roles: { + guest: { + is_default: true, + }, + }, + }, + }, + }); + + await app.build(); + + const auth = app.module.auth; + // doesn't allow invalid role + expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow(); + // allows valid role + await auth.schema().patch("default_role_register", "guest"); + expect(auth.toJSON().default_role_register).toBe("guest"); + }); }); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index fb5464a..5a1f6ce 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { createApp } from "core/test/utils"; import { em, entity, text } from "data/prototype"; import { registries } from "modules/registries"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { AppMedia } from "../../src/media/AppMedia"; import { moduleTestSuite } from "./module-test-suite"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("AppMedia", () => { test.skip("...", () => { diff --git a/app/__test__/modules/DbModuleManager.spec.ts b/app/__test__/modules/DbModuleManager.spec.ts index b85ebbf..657ab06 100644 --- a/app/__test__/modules/DbModuleManager.spec.ts +++ b/app/__test__/modules/DbModuleManager.spec.ts @@ -1,7 +1,11 @@ -import { it, expect, describe } from "bun:test"; +import { it, expect, describe, beforeAll, afterAll } from "bun:test"; import { DbModuleManager } from "modules/db/DbModuleManager"; import { getDummyConnection } from "../helper"; import { TABLE_NAME } from "modules/db/migrations"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("DbModuleManager", () => { it("should extract secrets", async () => { diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index de5c889..5448c78 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema"; import { Connection } from "data/connection/Connection"; import { entity, text } from "data/prototype"; -beforeAll(disableConsoleLog); +beforeAll(() => disableConsoleLog()); afterAll(enableConsoleLog); describe("ModuleManager", async () => { @@ -82,7 +82,6 @@ describe("ModuleManager", async () => { }, }, } as any; - //const { version, ...json } = mm.toJSON() as any; const { dummyConnection } = getDummyConnection(); const db = dummyConnection.kysely; @@ -97,10 +96,6 @@ describe("ModuleManager", async () => { await mm2.build(); - /* console.log({ - json, - configs: mm2.configs(), - }); */ //expect(stripMark(json)).toEqual(stripMark(mm2.configs())); expect(mm2.configs().data.entities?.test).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); @@ -228,8 +223,6 @@ describe("ModuleManager", async () => { const c = getDummyConnection(); const mm = new ModuleManager(c.dummyConnection); await mm.build(); - console.log("==".repeat(30)); - console.log(""); const json = mm.configs(); const c2 = getDummyConnection(); @@ -275,7 +268,6 @@ describe("ModuleManager", async () => { } override async build() { - //console.log("building FailingModule", this.config); if (this.config.value && this.config.value < 0) { throw new Error("value must be positive, given: " + this.config.value); } @@ -296,9 +288,6 @@ describe("ModuleManager", async () => { } } - beforeEach(() => disableConsoleLog(["log", "warn", "error"])); - afterEach(enableConsoleLog); - test("it builds", async () => { const { dummyConnection } = getDummyConnection(); const mm = new TestModuleManager(dummyConnection); diff --git a/app/build.cli.ts b/app/build.cli.ts index fee5373..0b7ca13 100644 --- a/app/build.cli.ts +++ b/app/build.cli.ts @@ -1,30 +1,10 @@ import pkg from "./package.json" with { type: "json" }; import c from "picocolors"; 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"], target: "node", diff --git a/app/build.ts b/app/build.ts index 4a30da9..ee6e428 100644 --- a/app/build.ts +++ b/app/build.ts @@ -2,6 +2,8 @@ import { $ } from "bun"; import * as tsup from "tsup"; import pkg from "./package.json" with { type: "json" }; import c from "picocolors"; +import { watch as fsWatch } from "node:fs"; +import { join } from "node:path"; const args = process.argv.slice(2); const watch = args.includes("--watch"); @@ -83,7 +85,8 @@ async function buildApi() { await tsup.build({ minify, sourcemap, - watch, + // don't use tsup's broken watch, we'll handle it ourselves + watch: false, define, entry: [ "src/index.ts", @@ -96,6 +99,7 @@ async function buildApi() { metafile: true, target: "esnext", platform: "browser", + removeNodeProtocol: false, format: ["esm"], splitting: false, loader: { @@ -120,7 +124,7 @@ async function buildUi() { const base = { minify, sourcemap, - watch, + watch: false, define, external: [ ...external, @@ -179,12 +183,15 @@ async function buildUiElements() { await tsup.build({ minify, sourcemap, - watch, + watch: false, define, entry: ["src/ui/elements/index.ts"], outDir: "dist/ui/elements", external: [ "ui/client", + "bknd", + /^bknd\/.*/, + "wouter", "react", "react-dom", "react/jsx-runtime", @@ -221,13 +228,14 @@ function baseConfig(adapter: string, overrides: Partial = {}): tsu return { minify, sourcemap, - watch, + watch: false, entry: [`src/adapter/${adapter}/index.ts`], format: ["esm"], platform: "neutral", outDir: `dist/adapter/${adapter}`, metafile: true, splitting: false, + removeNodeProtocol: false, onSuccess: async () => { delayTypes(); oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); @@ -263,6 +271,11 @@ async function buildAdapters() { // specific adatpers tsup.build(baseConfig("react-router")), + tsup.build( + baseConfig("browser", { + external: [/^sqlocal\/?.*?/, "wouter"], + }), + ), tsup.build( baseConfig("bun", { external: [/^bun\:.*/], @@ -325,4 +338,48 @@ async function buildAdapters() { ]); } -await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]); +async function buildAll() { + await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]); +} + +// initial build +await buildAll(); + +// custom watcher since tsup's watch is broken in 8.3.5+ +if (watch) { + oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/..."); + + let debounceTimer: ReturnType | null = null; + let isBuilding = false; + + const rebuild = async () => { + if (isBuilding) return; + isBuilding = true; + oldConsole.log(c.cyan("[Watch]"), "rebuilding..."); + try { + await buildAll(); + oldConsole.log(c.cyan("[Watch]"), c.green("done")); + } catch (e) { + oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e); + } + isBuilding = false; + }; + + const debouncedRebuild = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(rebuild, 100); + }; + + // watch src directory recursively + fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => { + if (!filename) return; + // ignore non-source files + if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css")) + return; + oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`)); + debouncedRebuild(); + }); + + // keep process alive + await new Promise(() => {}); +} diff --git a/app/bunfig.toml b/app/bunfig.toml deleted file mode 100644 index c39b588..0000000 --- a/app/bunfig.toml +++ /dev/null @@ -1,6 +0,0 @@ -[install] -#registry = "http://localhost:4873" - -[test] -coverageSkipTestFiles = true -console.depth = 10 \ No newline at end of file diff --git a/app/package.json b/app/package.json index e5008fc..b3fa2f2 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.19.0", + "version": "0.20.0-rc.2", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -13,7 +13,7 @@ "bugs": { "url": "https://github.com/bknd-io/bknd/issues" }, - "packageManager": "bun@1.2.22", + "packageManager": "bun@1.3.3", "engines": { "node": ">=22.13" }, @@ -49,93 +49,103 @@ "license": "FSL-1.1-MIT", "dependencies": { "@cfworker/json-schema": "^4.1.1", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-json": "^6.0.2", "@hello-pangea/dnd": "^18.0.1", - "@hono/swagger-ui": "^0.5.1", + "@hono/swagger-ui": "^0.5.2", "@mantine/core": "^7.17.1", "@mantine/hooks": "^7.17.1", "@tanstack/react-form": "^1.0.5", - "@uiw/react-codemirror": "^4.23.10", - "@xyflow/react": "^12.4.4", + "@uiw/react-codemirror": "^4.25.2", + "@xyflow/react": "^12.9.2", "aws4fetch": "^1.0.20", - "bcryptjs": "^3.0.2", - "dayjs": "^1.11.13", - "fast-xml-parser": "^5.0.8", - "hono": "4.8.3", + "bcryptjs": "^3.0.3", + "dayjs": "^1.11.19", + "fast-xml-parser": "^5.3.1", + "hono": "4.10.4", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.9.1", - "kysely": "0.27.6", + "jsonv-ts": "^0.10.1", + "kysely": "0.28.8", "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" + "radix-ui": "^1.1.3", + "swr": "^2.3.6", + "use-sync-external-store": "^1.6.0", + "zustand": "^4" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.758.0", - "@bluwy/giget-core": "^0.1.2", + "@aws-sdk/client-s3": "^3.922.0", + "@bluwy/giget-core": "^0.1.6", "@clack/prompts": "^0.11.0", - "@cloudflare/vitest-pool-workers": "^0.9.3", - "@cloudflare/workers-types": "^4.20250606.0", + "@cloudflare/vitest-pool-workers": "^0.10.4", + "@cloudflare/workers-types": "^4.20251014.0", "@dagrejs/dagre": "^1.1.4", - "@hono/vite-dev-server": "^0.21.0", - "@hookform/resolvers": "^4.1.3", - "@libsql/client": "^0.15.9", + "@hono/vite-dev-server": "^0.23.0", + "@hookform/resolvers": "^5.2.2", + "@libsql/client": "^0.15.15", "@mantine/modals": "^7.17.1", "@mantine/notifications": "^7.17.1", - "@playwright/test": "^1.51.1", + "@playwright/test": "^1.56.1", "@rjsf/core": "5.22.2", + "@rjsf/utils": "5.22.0", "@standard-schema/spec": "^1.0.0", - "@tabler/icons-react": "3.18.0", - "@tailwindcss/postcss": "^4.0.12", - "@tailwindcss/vite": "^4.0.12", + "@tabler/icons-react": "3.35.0", + "@tailwindcss/postcss": "^4.1.16", + "@tailwindcss/vite": "^4.1.16", + "@tanstack/react-store": "^0.8.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", - "@types/node": "^22.13.10", + "@types/node": "^24.10.0", + "@types/pg": "^8.15.6", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^3.0.9", + "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "3.0.9", "autoprefixer": "^10.4.21", "clsx": "^2.1.1", - "dotenv": "^16.4.7", + "commander": "^14.0.2", + "dotenv": "^17.2.3", "jotai": "^2.12.2", - "jsdom": "^26.0.0", - "kysely-d1": "^0.3.0", + "jsdom": "^26.1.0", "kysely-generic-sqlite": "^1.2.1", + "kysely-postgres-js": "^2.0.0", + "libsql": "^0.5.22", "libsql-stateless-easy": "^1.8.0", - "open": "^10.1.0", + "miniflare": "^4.20251011.2", + "open": "^10.2.0", "openapi-types": "^12.1.3", + "pg": "^8.16.3", "postcss": "^8.5.3", - "postcss-preset-mantine": "^1.17.0", + "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "posthog-js-lite": "^3.4.2", + "postgres": "^3.4.7", + "posthog-js-lite": "^3.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-hook-form": "^7.54.2", - "react-icons": "5.2.1", - "react-json-view-lite": "^2.4.1", - "sql-formatter": "^15.4.11", + "react-hook-form": "^7.66.0", + "react-icons": "5.5.0", + "react-json-view-lite": "^2.5.0", + "sql-formatter": "^15.6.10", + "sqlocal": "^0.16.0", "tailwind-merge": "^3.0.2", - "tailwindcss": "^4.0.12", + "tailwindcss": "^4.1.16", "tailwindcss-animate": "^1.0.7", - "tsc-alias": "^1.8.11", - "tsup": "^8.4.0", - "tsx": "^4.19.3", - "uuid": "^11.1.0", - "vite": "^6.3.5", + "tsc-alias": "^1.8.16", + "tsup": "^8.5.0", + "tsx": "^4.20.6", + "uuid": "^13.0.0", + "vite": "^7.1.12", "vite-plugin-circular-dependency": "^0.5.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.9", - "wouter": "^3.6.0", - "wrangler": "^4.37.1", - "miniflare": "^4.20250913.0" + "vitest": "3.0.9", + "wouter": "^3.7.1", + "wrangler": "^4.45.4" }, "optionalDependencies": { - "@hono/node-server": "^1.14.3" + "@hono/node-server": "^1.19.6" }, "peerDependencies": { "react": ">=19", @@ -248,6 +258,11 @@ "import": "./dist/adapter/aws/index.js", "require": "./dist/adapter/aws/index.js" }, + "./adapter/browser": { + "types": "./dist/types/adapter/browser/index.d.ts", + "import": "./dist/adapter/browser/index.js", + "require": "./dist/adapter/browser/index.js" + }, "./dist/main.css": "./dist/ui/main.css", "./dist/styles.css": "./dist/ui/styles.css", "./dist/manifest.json": "./dist/static/.vite/manifest.json", diff --git a/app/src/Api.ts b/app/src/Api.ts index fd4394a..adf69e6 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -61,7 +61,7 @@ export class Api { private token?: string; private user?: TApiUser; private verified = false; - private token_transport: "header" | "cookie" | "none" = "header"; + public token_transport: "header" | "cookie" | "none" = "header"; public system!: SystemApi; public data!: DataApi; diff --git a/app/src/App.ts b/app/src/App.ts index 633b9fa..2a8d203 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -5,7 +5,6 @@ import type { em as prototypeEm } from "data/prototype"; import { Connection } from "data/connection/Connection"; import type { Hono } from "hono"; import { - type InitialModuleConfigs, type ModuleConfigs, type Modules, ModuleManager, @@ -381,8 +380,10 @@ export class App< if (results.length > 0) { for (const { name, result } of results) { if (result) { - $console.log(`[Plugin:${name}] schema`); ctx.helper.ensureSchema(result); + if (ctx.flags.sync_required) { + $console.log(`[Plugin:${name}] schema, sync required`); + } } } } diff --git a/app/src/adapter/browser/BkndBrowserApp.tsx b/app/src/adapter/browser/BkndBrowserApp.tsx new file mode 100644 index 0000000..3cf9875 --- /dev/null +++ b/app/src/adapter/browser/BkndBrowserApp.tsx @@ -0,0 +1,153 @@ +import { + createContext, + lazy, + Suspense, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { checksum } from "bknd/utils"; +import { App, registries, sqlocal, type BkndConfig } from "bknd"; +import { Route, Router, Switch } from "wouter"; +import { ClientProvider } from "bknd/client"; +import { SQLocalKysely } from "sqlocal/kysely"; +import type { ClientConfig, DatabasePath } from "sqlocal"; +import { OpfsStorageAdapter } from "bknd/adapter/browser"; +import type { BkndAdminConfig } from "bknd/ui"; + +const Admin = lazy(() => + Promise.all([ + import("bknd/ui"), + // @ts-ignore + import("bknd/dist/styles.css"), + ]).then(([mod]) => ({ + default: mod.Admin, + })), +); + +function safeViewTransition(fn: () => void) { + if (document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } +} + +export type BrowserBkndConfig = Omit< + BkndConfig, + "connection" | "app" +> & { + adminConfig?: BkndAdminConfig; + connection?: ClientConfig | DatabasePath; +}; + +export type BkndBrowserAppProps = { + children: ReactNode; + header?: ReactNode; + loading?: ReactNode; + notFound?: ReactNode; +} & BrowserBkndConfig; + +const BkndBrowserAppContext = createContext<{ + app: App; + hash: string; +}>(undefined!); + +export function BkndBrowserApp({ + children, + adminConfig, + header, + loading, + notFound, + ...config +}: BkndBrowserAppProps) { + const [app, setApp] = useState(undefined); + const [hash, setHash] = useState(""); + const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?"; + + async function onBuilt(app: App) { + safeViewTransition(async () => { + setApp(app); + setHash(await checksum(app.toJSON())); + }); + } + + useEffect(() => { + setup({ ...config, adminConfig }) + .then((app) => onBuilt(app as any)) + .catch(console.error); + }, []); + + if (!app) { + return ( + loading ?? ( +
+ Loading... +
+ ) + ); + } + + return ( + + + {header} + + + {children} + + + + + + + + {notFound ?? ( +
404
+ )} +
+
+
+
+
+ ); +} + +export function useApp() { + return useContext(BkndBrowserAppContext); +} + +const Center = (props: React.HTMLAttributes) => ( +
+); + +let initialized = false; +async function setup(config: BrowserBkndConfig = {}) { + if (initialized) return; + initialized = true; + + registries.media.register("opfs", OpfsStorageAdapter); + + const app = App.create({ + ...config, + // @ts-ignore + connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")), + }); + + await config.beforeBuild?.(app); + await app.build({ sync: true }); + await config.onBuilt?.(app); + + return app; +} diff --git a/app/src/adapter/browser/OpfsStorageAdapter.spec.ts b/app/src/adapter/browser/OpfsStorageAdapter.spec.ts new file mode 100644 index 0000000..fa78a4f --- /dev/null +++ b/app/src/adapter/browser/OpfsStorageAdapter.spec.ts @@ -0,0 +1,34 @@ +import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test"; +import { OpfsStorageAdapter } from "./OpfsStorageAdapter"; +// @ts-ignore +import { assetsPath } from "../../../__test__/helper"; +import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import { MockFileSystemDirectoryHandle } from "adapter/browser/mock"; + +describe("OpfsStorageAdapter", async () => { + let mockRoot: MockFileSystemDirectoryHandle; + let testSuiteAdapter: OpfsStorageAdapter; + + const _mock = spyOn(global, "navigator"); + + beforeAll(() => { + // mock navigator.storage.getDirectory() + mockRoot = new MockFileSystemDirectoryHandle("opfs-root"); + const mockNavigator = { + storage: { + getDirectory: vi.fn().mockResolvedValue(mockRoot), + }, + }; + // @ts-ignore + _mock.mockReturnValue(mockNavigator); + testSuiteAdapter = new OpfsStorageAdapter(); + }); + + afterAll(() => { + _mock.mockRestore(); + }); + + const file = Bun.file(`${assetsPath}/image.png`); + await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file); +}); diff --git a/app/src/adapter/browser/OpfsStorageAdapter.ts b/app/src/adapter/browser/OpfsStorageAdapter.ts new file mode 100644 index 0000000..693451a --- /dev/null +++ b/app/src/adapter/browser/OpfsStorageAdapter.ts @@ -0,0 +1,265 @@ +import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd"; +import { StorageAdapter, guessMimeType } from "bknd"; +import { parse, s, isFile, isBlob } from "bknd/utils"; + +export const opfsAdapterConfig = s.object( + { + root: s.string({ default: "" }).optional(), + }, + { + title: "OPFS", + description: "Origin Private File System storage", + additionalProperties: false, + }, +); +export type OpfsAdapterConfig = s.Static; + +/** + * Storage adapter for OPFS (Origin Private File System) + * Provides browser-based file storage using the File System Access API + */ +export class OpfsStorageAdapter extends StorageAdapter { + private config: OpfsAdapterConfig; + private rootPromise: Promise; + + constructor(config: Partial = {}) { + super(); + this.config = parse(opfsAdapterConfig, config); + this.rootPromise = this.initializeRoot(); + } + + private async initializeRoot(): Promise { + const opfsRoot = await navigator.storage.getDirectory(); + if (!this.config.root) { + return opfsRoot; + } + + // navigate to or create nested directory structure + const parts = this.config.root.split("/").filter(Boolean); + let current = opfsRoot; + for (const part of parts) { + current = await current.getDirectoryHandle(part, { create: true }); + } + return current; + } + + getSchema() { + return opfsAdapterConfig; + } + + getName(): string { + return "opfs"; + } + + async listObjects(prefix?: string): Promise { + const root = await this.rootPromise; + const files: FileListObject[] = []; + + for await (const [name, handle] of root.entries()) { + if (handle.kind === "file") { + if (!prefix || name.startsWith(prefix)) { + const file = await (handle as FileSystemFileHandle).getFile(); + files.push({ + key: name, + last_modified: new Date(file.lastModified), + size: file.size, + }); + } + } + } + + return files; + } + + private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise { + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); + + // wrap the hex string in quotes for ETag format + return `"${hashHex}"`; + } + + async putObject(key: string, body: FileBody): Promise { + if (body === null) { + throw new Error("Body is empty"); + } + + const root = await this.rootPromise; + const fileHandle = await root.getFileHandle(key, { create: true }); + const writable = await fileHandle.createWritable(); + + try { + let contentBuffer: ArrayBuffer; + + if (isFile(body)) { + contentBuffer = await body.arrayBuffer(); + await writable.write(contentBuffer); + } else if (body instanceof ReadableStream) { + const chunks: Uint8Array[] = []; + const reader = body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + await writable.write(value); + } + } finally { + reader.releaseLock(); + } + // compute total size and combine chunks for etag + const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combined = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + contentBuffer = combined.buffer; + } else if (isBlob(body)) { + contentBuffer = await (body as Blob).arrayBuffer(); + await writable.write(contentBuffer); + } else { + // body is ArrayBuffer or ArrayBufferView + if (ArrayBuffer.isView(body)) { + const view = body as ArrayBufferView; + contentBuffer = view.buffer.slice( + view.byteOffset, + view.byteOffset + view.byteLength, + ) as ArrayBuffer; + } else { + contentBuffer = body as ArrayBuffer; + } + await writable.write(body); + } + + await writable.close(); + return await this.computeEtagFromArrayBuffer(contentBuffer); + } catch (error) { + await writable.abort(); + throw error; + } + } + + async deleteObject(key: string): Promise { + try { + const root = await this.rootPromise; + await root.removeEntry(key); + } catch { + // file doesn't exist, which is fine + } + } + + async objectExists(key: string): Promise { + try { + const root = await this.rootPromise; + await root.getFileHandle(key); + return true; + } catch { + return false; + } + } + + 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 root = await this.rootPromise; + const fileHandle = await root.getFileHandle(key); + const file = await fileHandle.getFile(); + const fileSize = file.size; + const mimeType = guessMimeType(key); + + 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 arrayBuffer = await file.arrayBuffer(); + const chunk = arrayBuffer.slice(start, end + 1); + + responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`); + responseHeaders.set("Content-Length", chunk.byteLength.toString()); + + return new Response(chunk, { + status: 206, // Partial Content + headers: responseHeaders, + }); + } else { + // normal request - return entire file + const content = await file.arrayBuffer(); + responseHeaders.set("Content-Length", content.byteLength.toString()); + + return new Response(content, { + status: 200, + headers: responseHeaders, + }); + } + } catch { + // handle file reading errors + return new Response("", { status: 404 }); + } + } + + getObjectUrl(_key: string): string { + throw new Error("Method not implemented."); + } + + async getObjectMeta(key: string): Promise { + const root = await this.rootPromise; + const fileHandle = await root.getFileHandle(key); + const file = await fileHandle.getFile(); + + return { + type: guessMimeType(key) || "application/octet-stream", + size: file.size, + }; + } + + toJSON(_secrets?: boolean) { + return { + type: this.getName(), + config: this.config, + }; + } +} diff --git a/app/src/adapter/browser/index.ts b/app/src/adapter/browser/index.ts new file mode 100644 index 0000000..46607dc --- /dev/null +++ b/app/src/adapter/browser/index.ts @@ -0,0 +1,2 @@ +export * from "./OpfsStorageAdapter"; +export * from "./BkndBrowserApp"; diff --git a/app/src/adapter/browser/mock.ts b/app/src/adapter/browser/mock.ts new file mode 100644 index 0000000..f7787e8 --- /dev/null +++ b/app/src/adapter/browser/mock.ts @@ -0,0 +1,136 @@ +// mock OPFS API for testing +class MockFileSystemFileHandle { + kind: "file" = "file"; + name: string; + private content: ArrayBuffer; + private lastModified: number; + + constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) { + this.name = name; + this.content = content; + this.lastModified = Date.now(); + } + + async getFile(): Promise { + return new File([this.content], this.name, { + lastModified: this.lastModified, + type: this.guessMimeType(), + }); + } + + async createWritable(): Promise { + const handle = this; + return { + async write(data: any) { + if (data instanceof ArrayBuffer) { + handle.content = data; + } else if (ArrayBuffer.isView(data)) { + handle.content = data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ) as ArrayBuffer; + } else if (data instanceof Blob) { + handle.content = await data.arrayBuffer(); + } + handle.lastModified = Date.now(); + }, + async close() {}, + async abort() {}, + async seek(_position: number) {}, + async truncate(_size: number) {}, + } as FileSystemWritableFileStream; + } + + private guessMimeType(): string { + const ext = this.name.split(".").pop()?.toLowerCase(); + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + txt: "text/plain", + json: "application/json", + pdf: "application/pdf", + }; + return mimeTypes[ext || ""] || "application/octet-stream"; + } +} + +export class MockFileSystemDirectoryHandle { + kind: "directory" = "directory"; + name: string; + private files: Map = new Map(); + private directories: Map = new Map(); + + constructor(name: string = "root") { + this.name = name; + } + + async getFileHandle( + name: string, + options?: FileSystemGetFileOptions, + ): Promise { + if (this.files.has(name)) { + return this.files.get(name) as any; + } + if (options?.create) { + const handle = new MockFileSystemFileHandle(name); + this.files.set(name, handle); + return handle as any; + } + throw new Error(`File not found: ${name}`); + } + + async getDirectoryHandle( + name: string, + options?: FileSystemGetDirectoryOptions, + ): Promise { + if (this.directories.has(name)) { + return this.directories.get(name) as any; + } + if (options?.create) { + const handle = new MockFileSystemDirectoryHandle(name); + this.directories.set(name, handle); + return handle as any; + } + throw new Error(`Directory not found: ${name}`); + } + + async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise { + this.files.delete(name); + this.directories.delete(name); + } + + async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> { + for (const [name, handle] of this.files) { + yield [name, handle as any]; + } + for (const [name, handle] of this.directories) { + yield [name, handle as any]; + } + } + + async *keys(): AsyncIterableIterator { + for (const name of this.files.keys()) { + yield name; + } + for (const name of this.directories.keys()) { + yield name; + } + } + + async *values(): AsyncIterableIterator { + for (const handle of this.files.values()) { + yield handle as any; + } + for (const handle of this.directories.values()) { + yield handle as any; + } + } + + [Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> { + return this.entries(); + } +} diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 44e7ccf..c5d640d 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,14 +1,12 @@ -/// - import path from "node:path"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import { registerLocalMediaAdapter } from "."; import { config, type App } from "bknd"; -import type { ServeOptions } from "bun"; import { serveStatic } from "hono/bun"; type BunEnv = Bun.Env; -export type BunBkndConfig = RuntimeBkndConfig & Omit; +export type BunBkndConfig = RuntimeBkndConfig & + Omit, "fetch">; export async function createApp( { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, @@ -45,6 +43,7 @@ export function createHandler( export function serve( { + app, distPath, connection, config: _config, @@ -60,10 +59,11 @@ export function serve( args: Env = Bun.env as Env, ) { Bun.serve({ - ...serveOptions, + ...(serveOptions as any), port, fetch: createHandler( { + app, connection, config: _config, options, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index e263756..98df2b0 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -3,7 +3,7 @@ import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { MaybePromise } from "bknd"; +import type { App, MaybePromise } from "bknd"; import { $console } from "bknd/utils"; import { createRuntimeApp } from "bknd/adapter"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; @@ -55,8 +55,12 @@ export async function createApp( // compatiblity export const getFresh = createApp; +let app: App | undefined; export function serve( config: CloudflareBkndConfig = {}, + serveOptions?: (args: Env) => { + warm?: boolean; + }, ) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { @@ -92,8 +96,11 @@ export function serve( } } - const context = { request, env, ctx } as CloudflareContext; - const app = await createApp(config, context); + const { warm } = serveOptions?.(env) ?? {}; + if (!app || warm !== true) { + const context = { request, env, ctx } as CloudflareContext; + app = await createApp(config, context); + } return app.fetch(request, env, ctx); }, diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts index 9efd5c4..3476315 100644 --- a/app/src/adapter/cloudflare/proxy.ts +++ b/app/src/adapter/cloudflare/proxy.ts @@ -65,37 +65,31 @@ export function withPlatformProxy( } 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; + const appConfig = typeof config.app === "function" ? await config.app(env) : config; + const connection = + use_proxy && binding + ? d1Sqlite({ + binding: binding.value as any, + }) + : appConfig.connection; - 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 || {}; + return { + ...appConfig, + 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))) || {}; + }, + connection, + }; }, } satisfies CloudflareBkndConfig; } diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 79f4c97..6568d29 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -14,14 +14,15 @@ import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; 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 + CreateAppConfig & + Omit & { + app?: + | Omit, "app"> + | ((args: Args) => MaybePromise, "app">>); + onBuilt?: (app: App) => MaybePromise; + beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; + buildConfig?: Parameters[0]; + } >; export type FrameworkBkndConfig = BkndConfig; diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index eed1c35..c6de63f 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,5 +1,6 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter"; import { isNode } from "bknd/utils"; +// @ts-expect-error next is not installed import type { NextApiRequest } from "next"; type NextjsEnv = NextApiRequest["env"]; @@ -18,7 +19,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ if (!cleanRequest) return req; const url = new URL(req.url); - cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k)); + cleanRequest?.searchParams?.forEach((k) => { + url.searchParams.delete(k); + }); if (isNode()) { return new Request(url.toString(), { diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 83feba8..d85d197 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -24,7 +24,7 @@ export async function createApp( path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), ); if (relativeDistPath) { - console.warn("relativeDistPath is deprecated, please use distPath instead"); + $console.warn("relativeDistPath is deprecated, please use distPath instead"); } registerLocalMediaAdapter(); diff --git a/app/src/adapter/node/node.adapter.vi-test.ts b/app/src/adapter/node/node.adapter.vi-test.ts index 11bb0f7..76d062d 100644 --- a/app/src/adapter/node/node.adapter.vi-test.ts +++ b/app/src/adapter/node/node.adapter.vi-test.ts @@ -1,4 +1,4 @@ -import { describe, beforeAll, afterAll } from "vitest"; +import { describe } from "vitest"; import * as node from "./node.adapter"; import { adapterTestSuite } from "adapter/adapter-test-suite"; import { viTestRunner } from "adapter/node/vitest"; diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 4b23919..4d3b319 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -46,6 +46,22 @@ export class AppAuth extends Module { to.strategies!.password!.enabled = true; } + if (to.default_role_register && to.default_role_register?.length > 0) { + const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register); + + if (!valid_to_role) { + const msg = `Default role for registration not found: ${to.default_role_register}`; + // if changing to a new value + if (from.default_role_register !== to.default_role_register) { + throw new Error(msg); + } + + // resetting gracefully, since role doesn't exist anymore + $console.warn(`${msg}, resetting to undefined`); + to.default_role_register = undefined; + } + } + return to; } @@ -82,6 +98,7 @@ export class AppAuth extends Module { this._authenticator = new Authenticator(strategies, new AppUserPool(this), { jwt: this.config.jwt, cookie: this.config.cookie, + default_role_register: this.config.default_role_register, }); this.registerEntities(); @@ -171,10 +188,20 @@ export class AppAuth extends Module { } catch (e) {} } - async createUser({ email, password, ...additional }: CreateUserPayload): Promise { + async createUser({ + email, + password, + role, + ...additional + }: CreateUserPayload): Promise { if (!this.enabled) { throw new Error("Cannot create user, auth not enabled"); } + if (role) { + if (!Object.keys(this.config.roles ?? {}).includes(role)) { + throw new Error(`Role "${role}" not found`); + } + } const strategy = "password" as const; const pw = this.authenticator.strategy(strategy) as PasswordStrategy; @@ -183,6 +210,7 @@ export class AppAuth extends Module { mutator.__unstable_toggleSystemEntityCreation(false); const { data: created } = await mutator.insertOne({ ...(additional as any), + role: role || this.config.default_role_register || undefined, email, strategy, strategy_value, diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 99f1000..af846ce 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -13,6 +13,7 @@ import { InvalidSchemaError, transformObject, mcpTool, + $console, } from "bknd/utils"; import type { PasswordStrategy } from "auth/authenticate/strategies"; @@ -210,7 +211,7 @@ export class AuthController extends Controller { 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; + let user: DB["users"] | undefined; if (params.id) { const { data } = await this.userRepo.findId(params.id); user = data; @@ -225,26 +226,33 @@ export class AuthController extends Controller { }; 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); + try { + const actions = this.auth.authenticator.strategy("password").getActions(); + if (actions.create) { + const schema = actions.create.schema; + mcp.tool( + "auth_user_create", + { + description: "Create a new user", + inputSchema: s.object({ + ...schema.properties, + 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)); - }, - ); + return c.json(await this.auth.createUser(params)); + }, + ); + } + } catch (e) { + $console.warn("error creating auth_user_create tool", e); + } mcp.tool( "auth_user_token", diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index e479ea1..405fe66 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -51,6 +51,7 @@ export const authConfigSchema = $object( basepath: s.string({ default: "/api/auth" }), entity_name: s.string({ default: "users" }), allow_register: s.boolean({ default: true }).optional(), + default_role_register: s.string().optional(), jwt: jwtConfig, cookie: cookieConfig, strategies: $record( diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 9a2b8b1..03f13e9 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -74,6 +74,7 @@ export const jwtConfig = s.strictObject( export const authenticatorConfig = s.object({ jwt: jwtConfig, cookie: cookieConfig, + default_role_register: s.string().optional(), }); type AuthConfig = s.Static; @@ -164,9 +165,13 @@ export class Authenticator< if (!("strategy_value" in profile)) { throw new InvalidConditionsException("Profile must have a strategy value"); } + if ("role" in profile) { + throw new InvalidConditionsException("Role cannot be provided during registration"); + } const user = await this.userPool.create(strategy.getName(), { ...profile, + role: this.config.default_role_register, strategy_value: profile.strategy_value, }); diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 0c6066b..7f2c964 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -10,6 +10,7 @@ const schema = s .object({ hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }), rounds: s.number({ minimum: 1, maximum: 10 }).optional(), + minLength: s.number({ default: 8, minimum: 1 }).optional(), }) .strict(); @@ -37,7 +38,7 @@ export class PasswordStrategy extends AuthStrategy { format: "email", }), password: s.string({ - minLength: 8, // @todo: this should be configurable + minLength: this.config.minLength, }), }); } @@ -65,12 +66,21 @@ export class PasswordStrategy extends AuthStrategy { return await bcryptCompare(compare, actual); } - return false; + return actual === compare; } verify(password: string) { return async (user: User) => { - const compare = await this.compare(user?.strategy_value!, password); + if (!user || !user.strategy_value) { + throw new InvalidCredentialsException(); + } + + if (!this.getPayloadSchema().properties.password.validate(password).valid) { + $console.debug("PasswordStrategy: Invalid password", password); + throw new InvalidCredentialsException(); + } + + const compare = await this.compare(user.strategy_value, password); if (compare !== true) { throw new InvalidCredentialsException(); } diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index ed2e1aa..b20822a 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -67,7 +67,10 @@ export async function startServer( $console.info("Server listening on", url); if (options.open) { - await open(url); + const p = await open(url, { wait: false }); + p.on("error", () => { + $console.warn("Couldn't open url in browser"); + }); } } diff --git a/app/src/core/drivers/email/plunk.spec.ts b/app/src/core/drivers/email/plunk.spec.ts new file mode 100644 index 0000000..82fb544 --- /dev/null +++ b/app/src/core/drivers/email/plunk.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "bun:test"; +import { plunkEmail } from "./plunk"; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("plunk", () => { + it("should throw on failed", async () => { + const driver = plunkEmail({ apiKey: "invalid" }); + expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); + }); + + it("should send an email", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: undefined, // Default to what Plunk sets + }); + const response = await driver.send( + "help@bknd.io", + "Test Email from Plunk", + "This is a test email", + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + expect(response.emails).toBeDefined(); + expect(response.timestamp).toBeDefined(); + }); + + it("should send HTML email", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: undefined, + }); + const htmlBody = "

Test Email

This is a test email

"; + const response = await driver.send( + "help@bknd.io", + "HTML Test", + htmlBody, + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); + + it("should send with text and html", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: undefined, + }); + const response = await driver.send("test@example.com", "Test Email", { + text: "help@bknd.io", + html: "

This is HTML

", + }); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); +}); diff --git a/app/src/core/drivers/email/plunk.ts b/app/src/core/drivers/email/plunk.ts new file mode 100644 index 0000000..a3c7761 --- /dev/null +++ b/app/src/core/drivers/email/plunk.ts @@ -0,0 +1,70 @@ +import type { IEmailDriver } from "./index"; + +export type PlunkEmailOptions = { + apiKey: string; + host?: string; + from?: string; +}; + +export type PlunkEmailSendOptions = { + subscribed?: boolean; + name?: string; + from?: string; + reply?: string; + headers?: Record; +}; + +export type PlunkEmailResponse = { + success: boolean; + emails: Array<{ + contact: { + id: string; + email: string; + }; + email: string; + }>; + timestamp: string; +}; + +export const plunkEmail = ( + config: PlunkEmailOptions, +): IEmailDriver => { + const host = config.host ?? "https://api.useplunk.com/v1/send"; + const from = config.from; + + return { + send: async ( + to: string, + subject: string, + body: string | { text: string; html: string }, + options?: PlunkEmailSendOptions, + ) => { + const payload: any = { + from, + to, + subject, + }; + + if (typeof body === "string") { + payload.body = body; + } else { + payload.body = body.html; + } + + const res = await fetch(host, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ ...payload, ...options }), + }); + + if (!res.ok) { + throw new Error(`Plunk API error: ${await res.text()}`); + } + + return (await res.json()) as PlunkEmailResponse; + }, + }; +}; diff --git a/app/src/core/drivers/email/resend.spec.ts b/app/src/core/drivers/email/resend.spec.ts index 5c1cfcf..04d005a 100644 --- a/app/src/core/drivers/email/resend.spec.ts +++ b/app/src/core/drivers/email/resend.spec.ts @@ -4,7 +4,7 @@ import { resendEmail } from "./resend"; const ALL_TESTS = !!process.env.ALL_TESTS; describe.skipIf(ALL_TESTS)("resend", () => { - it.only("should throw on failed", async () => { + it("should throw on failed", async () => { const driver = resendEmail({ apiKey: "invalid" } as any); expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); }); diff --git a/app/src/core/drivers/index.ts b/app/src/core/drivers/index.ts index da356b7..963b9c4 100644 --- a/app/src/core/drivers/index.ts +++ b/app/src/core/drivers/index.ts @@ -5,3 +5,4 @@ export type { IEmailDriver } from "./email"; export { resendEmail } from "./email/resend"; export { sesEmail } from "./email/ses"; export { mailchannelsEmail } from "./email/mailchannels"; +export { plunkEmail } from "./email/plunk"; diff --git a/app/src/core/env.ts b/app/src/core/env.ts index 7771073..3d7c4ca 100644 --- a/app/src/core/env.ts +++ b/app/src/core/env.ts @@ -8,7 +8,7 @@ export function isDebug(): boolean { try { // @ts-expect-error - this is a global variable in dev return is_toggled(__isDev); - } catch (e) { + } catch (_e) { return false; } } diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts index 76a067e..7e8bc75 100644 --- a/app/src/core/events/EventListener.ts +++ b/app/src/core/events/EventListener.ts @@ -1,3 +1,4 @@ +import type { MaybePromise } from "bknd"; import type { Event } from "./Event"; import type { EventClass } from "./EventManager"; @@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number]; export type ListenerHandler> = ( event: E, slug: string, -) => E extends Event ? R | Promise : never; +) => E extends Event ? MaybePromise : never; export class EventListener { mode: ListenerMode = "async"; diff --git a/app/src/core/server/flash.ts b/app/src/core/server/flash.ts index 6c7b259..3c7712a 100644 --- a/app/src/core/server/flash.ts +++ b/app/src/core/server/flash.ts @@ -32,6 +32,7 @@ export function getFlashMessage( ): { type: FlashMessageType; message: string } | undefined { const flash = getCookieValue(flash_key); if (flash && clear) { + // biome-ignore lint/suspicious/noDocumentCookie: . document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; } return flash ? JSON.parse(flash) : undefined; diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 33c6a43..afdf183 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record { export function omitKeys( obj: T, - keys_: readonly K[], + keys_: readonly K[] | K[] | string[], ): Omit> { - const keys = new Set(keys_); + const keys = new Set(keys_ as readonly K[]); const result = {} as Omit>; for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { if (!keys.has(key as K)) { diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 5b943ff..e43a8ad 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -1,3 +1,4 @@ +import type { MaybePromise } from "core/types"; import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter"; /** @@ -77,3 +78,37 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) { return true; } } + +export async function threwAsync(fn: Promise, instance?: new (...args: any[]) => Error) { + try { + await fn; + return false; + } catch (e) { + if (instance) { + if (e instanceof instance) { + return true; + } + // if instance given but not what expected, throw + throw e; + } + return true; + } +} + +export async function $waitUntil( + message: string, + condition: () => MaybePromise, + delay = 100, + maxAttempts = 10, +) { + let attempts = 0; + while (attempts < maxAttempts) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + attempts++; + } + + throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`); +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index c052331..6d0d4ed 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -120,17 +120,14 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean } export function slugify(str: string): string { - return ( - String(str) - .normalize("NFKD") // split accented characters into their base characters and diacritical marks - // biome-ignore lint/suspicious/noMisleadingCharacterClass: - .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. - .trim() // trim leading or trailing whitespace - .toLowerCase() // convert to lowercase - .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters - .replace(/\s+/g, "-") // replace spaces with hyphens - .replace(/-+/g, "-") // remove consecutive hyphens - ); + return String(str) + .normalize("NFKD") // split accented characters into their base characters and diacritical marks + .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. + .trim() // trim leading or trailing whitespace + .toLowerCase() // convert to lowercase + .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters + .replace(/\s+/g, "-") // replace spaces with hyphens + .replace(/-+/g, "-"); // remove consecutive hyphens } export function truncate(str: string, length = 50, end = "..."): string { diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 082ae0c..531be0c 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -96,6 +96,9 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -124,6 +127,9 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -161,7 +167,7 @@ export class DataController extends Controller { hono.get( "/types", permission(SystemPermissions.schemaRead, { - context: (c) => ({ module: "data" }), + context: (_c) => ({ module: "data" }), }), describeRoute({ summary: "Retrieve data typescript definitions", @@ -182,6 +188,9 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index 1cc8b52..c9353aa 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -6,17 +6,15 @@ import { type CompiledQuery, type DatabaseIntrospector, type Dialect, - type Expression, type Kysely, type KyselyPlugin, type OnModifyForeignAction, type QueryResult, - type RawBuilder, type SelectQueryBuilder, type SelectQueryNode, - type Simplify, sql, } from "kysely"; +import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector"; import type { DB } from "bknd"; import type { Constructor } from "core/registry/Registry"; @@ -70,15 +68,9 @@ export type IndexSpec = { }; export type DbFunctions = { - jsonObjectFrom(expr: SelectQueryBuilderExpression): RawBuilder | null>; - jsonArrayFrom(expr: SelectQueryBuilderExpression): RawBuilder[]>; - jsonBuildObject>>( - obj: O, - ): RawBuilder< - Simplify<{ - [K in keyof O]: O[K] extends Expression ? V : never; - }> - >; + jsonObjectFrom: typeof jsonObjectFrom; + jsonArrayFrom: typeof jsonArrayFrom; + jsonBuildObject: typeof jsonBuildObject; }; export type ConnQuery = CompiledQuery | Compilable; diff --git a/app/src/data/connection/connection-test-suite.ts b/app/src/data/connection/connection-test-suite.ts index 270ccb0..bf45e0e 100644 --- a/app/src/data/connection/connection-test-suite.ts +++ b/app/src/data/connection/connection-test-suite.ts @@ -14,27 +14,31 @@ export function connectionTestSuite( { makeConnection, rawDialectDetails, + disableConsoleLog: _disableConsoleLog = true, }: { makeConnection: () => MaybePromise<{ connection: Connection; dispose: () => MaybePromise; }>; rawDialectDetails: string[]; + disableConsoleLog?: boolean; }, ) { const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner; - beforeAll(() => disableConsoleLog()); - afterAll(() => enableConsoleLog()); + if (_disableConsoleLog) { + beforeAll(() => disableConsoleLog()); + afterAll(() => enableConsoleLog()); + } - describe("base", () => { - let ctx: Awaited>; - beforeEach(async () => { - ctx = await makeConnection(); - }); - afterEach(async () => { - await ctx.dispose(); - }); + let ctx: Awaited>; + beforeEach(async () => { + ctx = await makeConnection(); + }); + afterEach(async () => { + await ctx.dispose(); + }); + describe("base", async () => { test("pings", async () => { const res = await ctx.connection.ping(); expect(res).toBe(true); @@ -98,52 +102,54 @@ export function connectionTestSuite( }); describe("schema", async () => { - const { connection, dispose } = await makeConnection(); - afterAll(async () => { - await dispose(); - }); + const makeSchema = async () => { + const fields = [ + { + type: "integer", + name: "id", + primary: true, + }, + { + type: "text", + name: "text", + }, + { + type: "json", + name: "json", + }, + ] as const satisfies FieldSpec[]; - const fields = [ - { - type: "integer", - name: "id", - primary: true, - }, - { - type: "text", - name: "text", - }, - { - type: "json", - name: "json", - }, - ] as const satisfies FieldSpec[]; + let b = ctx.connection.kysely.schema.createTable("test"); + for (const field of fields) { + // @ts-expect-error + b = b.addColumn(...ctx.connection.getFieldSchema(field)); + } + await b.execute(); - let b = connection.kysely.schema.createTable("test"); - for (const field of fields) { - // @ts-expect-error - b = b.addColumn(...connection.getFieldSchema(field)); - } - await b.execute(); - - // add index - await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute(); + // add index + await ctx.connection.kysely.schema + .createIndex("test_index") + .on("test") + .columns(["id"]) + .execute(); + }; test("executes query", async () => { - await connection.kysely + await makeSchema(); + await ctx.connection.kysely .insertInto("test") .values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) }) .execute(); const expected = { id: 1, text: "test", json: { a: 1 } }; - const qb = connection.kysely.selectFrom("test").selectAll(); - const res = await connection.executeQuery(qb); + const qb = ctx.connection.kysely.selectFrom("test").selectAll(); + const res = await ctx.connection.executeQuery(qb); expect(res.rows).toEqual([expected]); expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true); { - const res = await connection.executeQueries(qb, qb); + const res = await ctx.connection.executeQueries(qb, qb); expect(res.length).toBe(2); res.map((r) => { expect(r.rows).toEqual([expected]); @@ -155,15 +161,21 @@ export function connectionTestSuite( }); test("introspects", async () => { - const tables = await connection.getIntrospector().getTables({ + await makeSchema(); + const tables = await ctx.connection.getIntrospector().getTables({ withInternalKyselyTables: false, }); const clean = tables.map((t) => ({ ...t, - columns: t.columns.map((c) => ({ - ...c, - dataType: undefined, - })), + columns: t.columns + .map((c) => ({ + ...c, + // ignore data type + dataType: undefined, + // ignore default value if "id" + hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined, + })) + .sort((a, b) => a.name.localeCompare(b.name)), })); expect(clean).toEqual([ @@ -176,14 +188,8 @@ export function connectionTestSuite( dataType: undefined, isNullable: false, isAutoIncrementing: true, - hasDefaultValue: false, - }, - { - name: "text", - dataType: undefined, - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, + hasDefaultValue: undefined, + comment: undefined, }, { name: "json", @@ -191,25 +197,34 @@ export function connectionTestSuite( isNullable: true, isAutoIncrementing: false, hasDefaultValue: false, + comment: undefined, + }, + { + name: "text", + dataType: undefined, + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + comment: undefined, + }, + ], + }, + ]); + + expect(await ctx.connection.getIntrospector().getIndices()).toEqual([ + { + name: "test_index", + table: "test", + isUnique: false, + columns: [ + { + name: "id", + order: 0, }, ], }, ]); }); - - expect(await connection.getIntrospector().getIndices()).toEqual([ - { - name: "test_index", - table: "test", - isUnique: false, - columns: [ - { - name: "id", - order: 0, - }, - ], - }, - ]); }); describe("integration", async () => { diff --git a/app/src/data/connection/postgres/PgPostgresConnection.ts b/app/src/data/connection/postgres/PgPostgresConnection.ts new file mode 100644 index 0000000..f9b5466 --- /dev/null +++ b/app/src/data/connection/postgres/PgPostgresConnection.ts @@ -0,0 +1,33 @@ +import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely"; +import { PostgresIntrospector } from "./PostgresIntrospector"; +import { PostgresConnection, plugins } from "./PostgresConnection"; +import { customIntrospector } from "../Connection"; +import type { Pool } from "pg"; + +export type PostgresDialectConfig = Omit & { + pool: Pool; +}; + +export class PgPostgresConnection extends PostgresConnection { + override name = "pg"; + + constructor(config: PostgresDialectConfig) { + const kysely = new Kysely({ + dialect: customIntrospector(PostgresDialect, PostgresIntrospector, { + excludeTables: [], + }).create(config), + plugins, + }); + + super(kysely); + this.client = config.pool; + } + + override async close(): Promise { + await this.client.end(); + } +} + +export function pg(config: PostgresDialectConfig): PgPostgresConnection { + return new PgPostgresConnection(config); +} diff --git a/packages/postgres/src/PostgresConnection.ts b/app/src/data/connection/postgres/PostgresConnection.ts similarity index 92% rename from packages/postgres/src/PostgresConnection.ts rename to app/src/data/connection/postgres/PostgresConnection.ts index ff67991..b55df6d 100644 --- a/packages/postgres/src/PostgresConnection.ts +++ b/app/src/data/connection/postgres/PostgresConnection.ts @@ -5,7 +5,7 @@ import { type SchemaResponse, type ConnQuery, type ConnQueryResults, -} from "bknd"; +} from "../Connection"; import { ParseJSONResultsPlugin, type ColumnDataType, @@ -20,7 +20,7 @@ export type QB = SelectQueryBuilder; export const plugins = [new ParseJSONResultsPlugin()]; -export abstract class PostgresConnection extends Connection { +export abstract class PostgresConnection extends Connection { protected override readonly supported = { batching: true, softscans: true, @@ -68,7 +68,7 @@ export abstract class PostgresConnection extends Connection { type, (col: ColumnDefinitionBuilder) => { if (spec.primary) { - return col.primaryKey(); + return col.primaryKey().notNull(); } if (spec.references) { return col @@ -76,7 +76,7 @@ export abstract class PostgresConnection extends Connection { .onDelete(spec.onDelete ?? "set null") .onUpdate(spec.onUpdate ?? "no action"); } - return spec.nullable ? col : col.notNull(); + return col; }, ]; } diff --git a/packages/postgres/src/PostgresIntrospector.ts b/app/src/data/connection/postgres/PostgresIntrospector.ts similarity index 81% rename from packages/postgres/src/PostgresIntrospector.ts rename to app/src/data/connection/postgres/PostgresIntrospector.ts index 4b1c928..c4b52b3 100644 --- a/packages/postgres/src/PostgresIntrospector.ts +++ b/app/src/data/connection/postgres/PostgresIntrospector.ts @@ -1,5 +1,5 @@ import { type SchemaMetadata, sql } from "kysely"; -import { BaseIntrospector } from "bknd"; +import { BaseIntrospector } from "../BaseIntrospector"; type PostgresSchemaSpec = { name: string; @@ -102,26 +102,27 @@ export class PostgresIntrospector extends BaseIntrospector { return tables.map((table) => ({ name: table.name, isView: table.type === "VIEW", - columns: table.columns.map((col) => { - return { - name: col.name, - dataType: col.type, - isNullable: !col.notnull, - // @todo: check default value on 'nextval' see https://www.postgresql.org/docs/17/datatype-numeric.html#DATATYPE-SERIAL - isAutoIncrementing: true, // just for now - hasDefaultValue: col.dflt != null, - comment: undefined, - }; - }), - indices: table.indices.map((index) => ({ - name: index.name, - table: table.name, - isUnique: index.sql?.match(/unique/i) != null, - columns: index.columns.map((col) => ({ - name: col.name, - order: col.seqno, - })), + columns: table.columns.map((col) => ({ + name: col.name, + dataType: col.type, + isNullable: !col.notnull, + isAutoIncrementing: col.dflt?.toLowerCase().includes("nextval") ?? false, + hasDefaultValue: col.dflt != null, + comment: undefined, })), + indices: table.indices + // filter out db-managed primary key index + .filter((index) => index.name !== `${table.name}_pkey`) + .map((index) => ({ + name: index.name, + table: table.name, + isUnique: index.sql?.match(/unique/i) != null, + columns: index.columns.map((col) => ({ + name: col.name, + // seqno starts at 1 + order: col.seqno - 1, + })), + })), })); } } diff --git a/app/src/data/connection/postgres/PostgresJsConnection.ts b/app/src/data/connection/postgres/PostgresJsConnection.ts new file mode 100644 index 0000000..710b033 --- /dev/null +++ b/app/src/data/connection/postgres/PostgresJsConnection.ts @@ -0,0 +1,31 @@ +import { Kysely } from "kysely"; +import { PostgresIntrospector } from "./PostgresIntrospector"; +import { PostgresConnection, plugins } from "./PostgresConnection"; +import { customIntrospector } from "../Connection"; +import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js"; + +export class PostgresJsConnection extends PostgresConnection { + override name = "postgres-js"; + + constructor(config: PostgresJSDialectConfig) { + const kysely = new Kysely({ + dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, { + excludeTables: [], + }).create(config), + plugins, + }); + + super(kysely); + this.client = config.postgres; + } + + override async close(): Promise { + await this.client.end(); + } +} + +export function postgresJs( + config: PostgresJSDialectConfig, +): PostgresJsConnection { + return new PostgresJsConnection(config); +} diff --git a/packages/postgres/src/custom.ts b/app/src/data/connection/postgres/custom.ts similarity index 91% rename from packages/postgres/src/custom.ts rename to app/src/data/connection/postgres/custom.ts index 9d626a0..22e7809 100644 --- a/packages/postgres/src/custom.ts +++ b/app/src/data/connection/postgres/custom.ts @@ -1,4 +1,4 @@ -import { customIntrospector, type DbFunctions } from "bknd"; +import { customIntrospector, type DbFunctions } from "../Connection"; import { Kysely, type Dialect, type KyselyPlugin } from "kysely"; import { plugins, PostgresConnection } from "./PostgresConnection"; import { PostgresIntrospector } from "./PostgresIntrospector"; @@ -6,7 +6,7 @@ import { PostgresIntrospector } from "./PostgresIntrospector"; export type Constructor = new (...args: any[]) => T; export type CustomPostgresConnection = { - supports?: PostgresConnection["supported"]; + supports?: Partial; fn?: Partial; plugins?: KyselyPlugin[]; excludeTables?: string[]; diff --git a/app/src/data/connection/sqlite/SqliteConnection.ts b/app/src/data/connection/sqlite/SqliteConnection.ts index afecc38..21fe5a5 100644 --- a/app/src/data/connection/sqlite/SqliteConnection.ts +++ b/app/src/data/connection/sqlite/SqliteConnection.ts @@ -13,31 +13,43 @@ import { customIntrospector } from "../Connection"; import { SqliteIntrospector } from "./SqliteIntrospector"; import type { Field } from "data/fields/Field"; -// @todo: add pragmas export type SqliteConnectionConfig< CustomDialect extends Constructor = Constructor, > = { excludeTables?: string[]; - dialect: CustomDialect; - dialectArgs?: ConstructorParameters; additionalPlugins?: KyselyPlugin[]; customFn?: Partial; -}; +} & ( + | { + dialect: CustomDialect; + dialectArgs?: ConstructorParameters; + } + | { + kysely: Kysely; + } +); export abstract class SqliteConnection extends Connection { override name = "sqlite"; constructor(config: SqliteConnectionConfig) { - const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config; + const { excludeTables, additionalPlugins } = config; const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])]; - const kysely = new Kysely({ - dialect: customIntrospector(dialect, SqliteIntrospector, { - excludeTables, + let kysely: Kysely; + if ("dialect" in config) { + kysely = new Kysely({ + dialect: customIntrospector(config.dialect, SqliteIntrospector, { + excludeTables, + plugins, + }).create(...(config.dialectArgs ?? [])), plugins, - }).create(...dialectArgs), - plugins, - }); + }); + } else if ("kysely" in config) { + kysely = config.kysely; + } else { + throw new Error("Either dialect or kysely must be provided"); + } super( kysely, diff --git a/app/src/data/connection/sqlite/SqliteIntrospector.ts b/app/src/data/connection/sqlite/SqliteIntrospector.ts index 8ff2688..2ce4345 100644 --- a/app/src/data/connection/sqlite/SqliteIntrospector.ts +++ b/app/src/data/connection/sqlite/SqliteIntrospector.ts @@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector { dataType: col.type, isNullable: !col.notnull, isAutoIncrementing: col.name === autoIncrementCol, - hasDefaultValue: col.dflt_value != null, + hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null, comment: undefined, }; }) ?? [], diff --git a/app/src/data/connection/sqlite/libsql/LibsqlConnection.ts b/app/src/data/connection/sqlite/libsql/LibsqlConnection.ts index 32de8fc..1126233 100644 --- a/app/src/data/connection/sqlite/libsql/LibsqlConnection.ts +++ b/app/src/data/connection/sqlite/libsql/LibsqlConnection.ts @@ -18,7 +18,7 @@ export type LibsqlClientFns = { function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client { if (clientOrCredentials && "url" in clientOrCredentials) { const { url, authToken } = clientOrCredentials; - return createClient({ url, authToken }); + return createClient({ url, authToken }) as unknown as Client; } return clientOrCredentials as Client; diff --git a/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.spec.ts b/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.spec.ts new file mode 100644 index 0000000..15d06fe --- /dev/null +++ b/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.spec.ts @@ -0,0 +1,15 @@ +import { describe } from "bun:test"; +import { SQLocalConnection } from "./SQLocalConnection"; +import { connectionTestSuite } from "data/connection/connection-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import { SQLocalKysely } from "sqlocal/kysely"; + +describe("SQLocalConnection", () => { + connectionTestSuite(bunTestRunner, { + makeConnection: () => ({ + connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })), + dispose: async () => {}, + }), + rawDialectDetails: [], + }); +}); diff --git a/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.ts b/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.ts new file mode 100644 index 0000000..f9244e2 --- /dev/null +++ b/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.ts @@ -0,0 +1,50 @@ +import { Kysely, ParseJSONResultsPlugin } from "kysely"; +import { SqliteConnection } from "../SqliteConnection"; +import { SqliteIntrospector } from "../SqliteIntrospector"; +import type { DB } from "bknd"; +import type { SQLocalKysely } from "sqlocal/kysely"; + +const plugins = [new ParseJSONResultsPlugin()]; + +export class SQLocalConnection extends SqliteConnection { + private connected: boolean = false; + + constructor(client: SQLocalKysely) { + // @ts-expect-error - config is protected + client.config.onConnect = () => { + // we need to listen for the connection, it will be awaited in init() + this.connected = true; + }; + super({ + kysely: new Kysely({ + dialect: { + ...client.dialect, + createIntrospector: (db: Kysely) => { + return new SqliteIntrospector(db as any, { + plugins, + }); + }, + }, + plugins, + }) as any, + }); + this.client = client; + } + + override async init() { + if (this.initialized) return; + let tries = 0; + while (!this.connected && tries < 100) { + tries++; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (!this.connected) { + throw new Error("Failed to connect to SQLite database"); + } + this.initialized = true; + } +} + +export function sqlocal(instance: InstanceType): SQLocalConnection { + return new SQLocalConnection(instance); +} diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 3d8f432..218f25e 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -103,6 +103,7 @@ export class Repository 0) { for (const entry of options.join) { const related = this.em.relationOf(entity.name, entry); @@ -127,12 +128,28 @@ export class Repository { if (field.includes(".")) { const [alias, prop] = field.split(".") as [string, string]; - if (!aliases.includes(alias)) { + // check aliases first (added joins) + if (aliases.includes(alias)) { + this.checkIndex(alias, prop, "where"); + return !this.em.entity(alias).getField(prop); + } + // check if alias (entity) exists + if (!this.em.hasEntity(alias)) { return true; } + // check related fields for auto join + const related = this.em.relationOf(entity.name, alias); + if (related) { + const other = related.other(entity); + if (other.entity.getField(prop)) { + // if related field is found, add join to validated options + validated.join?.push(alias); + this.checkIndex(alias, prop, "where"); + return false; + } + } - this.checkIndex(alias, prop, "where"); - return !this.em.entity(alias).getField(prop); + return true; } this.checkIndex(entity.name, field, "where"); diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 5e9fd6a..07b4ac6 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -4,6 +4,7 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { RepoQuery } from "data/server/query"; import { InvalidSearchParamsException } from "data/errors"; import type { Entity, EntityManager, RepositoryQB } from "data/entities"; +import { $console } from "bknd/utils"; export class WithBuilder { static addClause( @@ -13,7 +14,7 @@ export class WithBuilder { withs: RepoQuery["with"], ) { if (!withs || !isObject(withs)) { - console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); + $console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); return qb; } @@ -37,9 +38,7 @@ export class WithBuilder { let subQuery = relation.buildWith(entity, ref)(eb); if (query) { subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, { - ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter( - Boolean, - ) as any, + ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any, }); } @@ -57,7 +56,7 @@ export class WithBuilder { static validateWiths(em: EntityManager, entity: string, withs: RepoQuery["with"]) { let depth = 0; if (!withs || !isObject(withs)) { - withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); + withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); return depth; } diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index fed47bf..b182ac1 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -26,7 +26,12 @@ export class JsonSchemaField< constructor(name: string, config: Partial) { super(name, config); - this.validator = new Validator({ ...this.getJsonSchema() }); + + // make sure to hand over clean json + const schema = this.getJsonSchema(); + this.validator = new Validator( + typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {}, + ); } protected getSchema() { diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index b2e4516..0f7481f 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -52,7 +52,7 @@ export class NumberField extends Field< switch (context) { case "submit": - return Number.parseInt(value); + return Number.parseInt(value, 10); } return value; diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 12c531b..12120d8 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -28,7 +28,7 @@ export function getChangeSet( const value = _value === "" ? null : _value; // normalize to null if undefined - const newValue = field.getValue(value, "submit") || null; + const newValue = field.getValue(value, "submit") ?? null; // @todo: add typing for "action" if (action === "create" || newValue !== data[key]) { acc[key] = newValue; diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index 43df6a1..0741f95 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -289,7 +289,7 @@ class EntityManagerPrototype> extends En super(Object.values(__entities), new DummyConnection(), relations, indices); } - withConnection(connection: Connection): EntityManager> { + withConnection(connection: Connection): EntityManager> { return new EntityManager(this.entities, connection, this.relations.all, this.indices); } } diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index eb2eb2b..8a336b5 100644 --- a/app/src/data/server/query.spec.ts +++ b/app/src/data/server/query.spec.ts @@ -1,8 +1,9 @@ -import { test, describe, expect } from "bun:test"; +import { test, describe, expect, beforeAll, afterAll } from "bun:test"; import * as q from "./query"; import { parse as $parse, type ParseOptions } from "bknd/utils"; import type { PrimaryFieldType } from "modules"; import type { Generated } from "kysely"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, { @@ -15,6 +16,9 @@ const decode = (input: any, output: any) => { expect(parse(input)).toEqual(output); }; +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); + describe("server/query", () => { test("limit & offset", () => { //expect(() => parse({ limit: false })).toThrow(); diff --git a/app/src/index.ts b/app/src/index.ts index e30af8a..c158502 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -132,6 +132,8 @@ export type * from "data/entities/Entity"; export type { EntityManager } from "data/entities/EntityManager"; export type { SchemaManager } from "data/schema/SchemaManager"; export type * from "data/entities"; + +// data connection export { BaseIntrospector, Connection, @@ -144,9 +146,32 @@ export { type ConnQuery, type ConnQueryResults, } from "data/connection"; + +// data sqlite export { SqliteConnection } from "data/connection/sqlite/SqliteConnection"; export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector"; export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection"; + +// data sqlocal +export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection"; + +// data postgres +export { + pg, + PgPostgresConnection, +} from "data/connection/postgres/PgPostgresConnection"; +export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector"; +export { PostgresConnection } from "data/connection/postgres/PostgresConnection"; +export { + postgresJs, + PostgresJsConnection, +} from "data/connection/postgres/PostgresJsConnection"; +export { + createCustomPostgresConnection, + type CustomPostgresConnection, +} from "data/connection/postgres/custom"; + +// data prototype export { text, number, diff --git a/app/src/media/storage/adapters/adapter-test-suite.ts b/app/src/media/storage/adapters/adapter-test-suite.ts index a6d1917..562367f 100644 --- a/app/src/media/storage/adapters/adapter-test-suite.ts +++ b/app/src/media/storage/adapters/adapter-test-suite.ts @@ -5,7 +5,7 @@ import type { BunFile } from "bun"; export async function adapterTestSuite( testRunner: TestRunner, - adapter: StorageAdapter, + _adapter: StorageAdapter | (() => StorageAdapter), file: File | BunFile, opts?: { retries?: number; @@ -25,7 +25,12 @@ export async function adapterTestSuite( const _filename = randomString(10); const filename = `${_filename}.png`; + const getAdapter = ( + typeof _adapter === "function" ? _adapter : () => _adapter + ) as () => StorageAdapter; + await test("puts an object", async () => { + const adapter = getAdapter(); objects = (await adapter.listObjects()).length; const result = await adapter.putObject(filename, file as unknown as File); expect(result).toBeDefined(); @@ -38,6 +43,7 @@ export async function adapterTestSuite( }); await test("lists objects", async () => { + const adapter = getAdapter(); const length = await retry( () => adapter.listObjects().then((res) => res.length), (length) => length > objects, @@ -49,10 +55,12 @@ export async function adapterTestSuite( }); await test("file exists", async () => { + const adapter = getAdapter(); expect(await adapter.objectExists(filename)).toBe(true); }); await test("gets an object", async () => { + const adapter = getAdapter(); const res = await adapter.getObject(filename, new Headers()); expect(res.ok).toBe(true); expect(res.headers.get("Accept-Ranges")).toBe("bytes"); @@ -62,6 +70,7 @@ export async function adapterTestSuite( if (options.testRange) { await test("handles range request - partial content", async () => { const headers = new Headers({ Range: "bytes=0-99" }); + const adapter = getAdapter(); 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); @@ -70,6 +79,7 @@ export async function adapterTestSuite( await test("handles range request - suffix range", async () => { const headers = new Headers({ Range: "bytes=-100" }); + const adapter = getAdapter(); 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); @@ -77,6 +87,7 @@ export async function adapterTestSuite( await test("handles invalid range request", async () => { const headers = new Headers({ Range: "bytes=invalid" }); + const adapter = getAdapter(); 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); @@ -84,6 +95,7 @@ export async function adapterTestSuite( } await test("gets object meta", async () => { + const adapter = getAdapter(); expect(await adapter.getObjectMeta(filename)).toEqual({ type: file.type, // image/png size: file.size, @@ -91,6 +103,7 @@ export async function adapterTestSuite( }); await test("deletes an object", async () => { + const adapter = getAdapter(); expect(await adapter.deleteObject(filename)).toBeUndefined(); if (opts?.skipExistsAfterDelete !== true) { diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts index 30e4dc3..6c147a3 100644 --- a/app/src/modes/code.ts +++ b/app/src/modes/code.ts @@ -10,16 +10,19 @@ export type CodeMode = AdapterConfig extends B ? BkndModeConfig : never; -export function code(config: BkndCodeModeConfig): BkndConfig { +export function code< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(codeConfig: CodeMode): BkndConfig { return { - ...config, + ...codeConfig, app: async (args) => { const { config: appConfig, plugins, isProd, syncSchemaOptions, - } = await makeModeConfig(config, args); + } = await makeModeConfig(codeConfig, args); if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { $console.warn("You should not set a different mode than `db` when using code mode"); diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts index 7a8022b..40fca8c 100644 --- a/app/src/modes/hybrid.ts +++ b/app/src/modes/hybrid.ts @@ -1,6 +1,6 @@ import type { BkndConfig } from "bknd/adapter"; import { makeModeConfig, type BkndModeConfig } from "./shared"; -import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import { getDefaultConfig, type MaybePromise, type Merge } from "bknd"; import type { DbModuleManager } from "modules/db/DbModuleManager"; import { invariant, $console } from "bknd/utils"; @@ -9,7 +9,7 @@ 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; + reader?: (path: string) => MaybePromise; /** * Provided secrets to be merged into the configuration */ @@ -23,42 +23,36 @@ export type HybridMode = AdapterConfig extends ? BkndModeConfig> : never; -export function hybrid({ - configFilePath = "bknd-config.json", - ...rest -}: HybridBkndConfig): BkndConfig { +export function hybrid< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(hybridConfig: HybridMode): BkndConfig { return { - ...rest, - config: undefined, + ...hybridConfig, app: async (args) => { const { config: appConfig, isProd, plugins, syncSchemaOptions, - } = await makeModeConfig( - { - ...rest, - configFilePath, - }, - args, - ); + } = await makeModeConfig(hybridConfig, args); + + const configFilePath = appConfig.configFilePath ?? "bknd-config.json"; 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", + "You must set a `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; + const fileContent = await appConfig.reader?.(configFilePath); + let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent; + if (!fileConfig) { + $console.warn("No config found, using default config"); + fileConfig = getDefaultConfig(); + await appConfig.writer?.(configFilePath, JSON.stringify(fileConfig, null, 2)); } return { @@ -80,6 +74,13 @@ export function hybrid({ skipValidation: isProd, // secrets are required for hybrid mode secrets: appConfig.secrets, + onModulesBuilt: async (ctx) => { + if (ctx.flags.sync_required && !isProd && syncSchemaOptions.force) { + $console.log("[hybrid] syncing schema"); + await ctx.em.schema().sync(syncSchemaOptions); + } + await appConfig?.options?.manager?.onModulesBuilt?.(ctx); + }, ...appConfig?.options?.manager, }, }, diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts index f1bc4ff..6b226c2 100644 --- a/app/src/modes/shared.ts +++ b/app/src/modes/shared.ts @@ -1,7 +1,7 @@ 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"; +import { $console } from "bknd/utils"; export type BkndModeOptions = { /** @@ -56,6 +56,14 @@ export type BkndModeConfig = BkndConfig< Merge >; +function _isProd() { + try { + return process.env.NODE_ENV === "production"; + } catch (_e) { + return false; + } +} + export async function makeModeConfig< Args = any, Config extends BkndModeConfig = BkndModeConfig, @@ -69,25 +77,24 @@ export async function makeModeConfig< if (typeof config.isProduction !== "boolean") { $console.warn( - "You should set `isProduction` option when using managed modes to prevent accidental issues", + "You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to", + _isProd(), ); } - invariant( - typeof config.writer === "function", - "You must set the `writer` option when using managed modes", - ); + let needsWriter = false; const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; - const isProd = config.isProduction; - const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const isProd = config.isProduction ?? _isProd(); + const plugins = config?.options?.plugins ?? ([] as AppPlugin[]); + const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncSchemaOptions = typeof config.syncSchema === "object" ? config.syncSchema : { - force: config.syncSchema !== false, - drop: true, + force: syncFallback, + drop: syncFallback, }; if (!isProd) { @@ -95,6 +102,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-types")) { throw new Error("You have to unregister the `syncTypes` plugin"); } + needsWriter = true; plugins.push( syncTypes({ enabled: true, @@ -114,6 +122,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-config")) { throw new Error("You have to unregister the `syncConfig` plugin"); } + needsWriter = true; plugins.push( syncConfig({ enabled: true, @@ -142,6 +151,7 @@ export async function makeModeConfig< .join("."); } + needsWriter = true; plugins.push( syncSecrets({ enabled: true, @@ -174,6 +184,10 @@ export async function makeModeConfig< } } + if (needsWriter && typeof config.writer !== "function") { + $console.warn("You must set a `writer` function, attempts to write will fail"); + } + return { config, isProd, diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 8406eaa..f52b2a1 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -87,7 +87,7 @@ export type ModuleManagerOptions = { verbosity?: Verbosity; }; -const debug_modules = env("modules_debug"); +const debug_modules = env("modules_debug", false); abstract class ModuleManagerEvent extends Event<{ ctx: ModuleBuildContext } & A> {} export class ModuleManagerConfigUpdateEvent< @@ -223,7 +223,7 @@ export class ModuleManager { } extractSecrets() { - const moduleConfigs = structuredClone(this.configs()); + const moduleConfigs = JSON.parse(JSON.stringify(this.configs())); const secrets = { ...this.options?.secrets }; const extractedKeys: string[] = []; diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts index 8af95e8..51e78ff 100644 --- a/app/src/modules/db/DbModuleManager.ts +++ b/app/src/modules/db/DbModuleManager.ts @@ -1,4 +1,4 @@ -import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils"; +import { mark, stripMark, $console, s, setPath } from "bknd/utils"; import { BkndError } from "core/errors"; import * as $diff from "core/object/diff"; import type { Connection } from "data/connection"; @@ -290,13 +290,12 @@ export class DbModuleManager extends ModuleManager { updated_at: new Date(), }); } - } else if (e instanceof TransformPersistFailedException) { - $console.error("ModuleManager: Cannot save invalid config"); - this.revertModules(); - throw e; } else { + if (e instanceof TransformPersistFailedException) { + $console.error("ModuleManager: Cannot save invalid config"); + } $console.error("ModuleManager: Aborting"); - this.revertModules(); + await this.revertModules(); throw e; } } diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 152072d..0a58180 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -33,3 +33,5 @@ export const schemaRead = new Permission( ); export const build = new Permission("system.build"); export const mcp = new Permission("system.mcp"); +export const info = new Permission("system.info"); +export const openapi = new Permission("system.openapi"); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 9434309..8352982 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -105,7 +105,10 @@ export class AppServer extends Module { if (err instanceof Error) { if (isDebug()) { - return c.json({ error: err.message, stack: err.stack }, 500); + return c.json( + { error: err.message, stack: err.stack?.split("\n").map((line) => line.trim()) }, + 500, + ); } } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 3ae6cd2..45988ad 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -1,5 +1,3 @@ -/// - import type { App } from "App"; import { datetimeStringLocal, @@ -125,7 +123,7 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController - const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */ + const hono = this.create(); if (!this.app.isReadOnly()) { const manager = this.app.modules as DbModuleManager; @@ -317,6 +315,11 @@ export class SystemController extends Controller { summary: "Get the config for a module", tags: ["system"], }), + permission(SystemPermissions.configRead, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), mcpTool("system_config", { annotations: { readOnlyHint: true, @@ -354,7 +357,7 @@ export class SystemController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth()); + const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {})); this.registerConfigController(hono); @@ -429,6 +432,9 @@ export class SystemController extends Controller { hono.get( "/permissions", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "auth" }), + }), describeRoute({ summary: "Get the permissions", tags: ["system"], @@ -441,6 +447,7 @@ export class SystemController extends Controller { hono.post( "/build", + permission(SystemPermissions.build, {}), describeRoute({ summary: "Build the app", tags: ["system"], @@ -471,6 +478,7 @@ export class SystemController extends Controller { hono.get( "/info", + permission(SystemPermissions.info, {}), mcpTool("system_info"), describeRoute({ summary: "Get the server info", @@ -504,6 +512,7 @@ export class SystemController extends Controller { hono.get( "/openapi.json", + permission(SystemPermissions.openapi, {}), openAPISpecs(this.ctx.server, { info: { title: "bknd API", @@ -511,7 +520,11 @@ export class SystemController extends Controller { }, }), ); - hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); + hono.get( + "/swagger", + permission(SystemPermissions.openapi, {}), + swaggerUI({ url: "/api/system/openapi.json" }), + ); return hono; } diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts new file mode 100644 index 0000000..cadc0a3 --- /dev/null +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -0,0 +1,683 @@ +import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test"; +import { emailOTP } from "./email-otp.plugin"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("otp plugin", () => { + test("should not work if auth is not enabled", async () => { + const app = createApp({ + options: { + plugins: [emailOTP({ showActualErrors: true })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + }); + + test("should require email driver if sendEmail is true", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP()], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + + { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + } + }); + + test("should prevent mutations of the OTP entity", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + drivers: { + email: { + send: async () => {}, + }, + }, + plugins: [emailOTP({ showActualErrors: true })], + }, + }); + await app.build(); + + const payload = { + email: "test@test.com", + code: "123456", + action: "login", + created_at: new Date(), + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24), + used_at: null, + }; + + expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow(); + expect( + await app + .getApi() + .data.createOne("users_otp", payload) + .then((r) => r.ok), + ).toBe(false); + }); + + test("should generate a token", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ showActualErrors: true })], + drivers: { + email: { + send: async (to) => { + expect(to).toBe("test@test.com"); + called(); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("login"); + expect(data.data.expires_at).toBeDefined(); + + { + const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" }); + expect(data?.code).toBeDefined(); + expect(data?.code?.length).toBe(6); + expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + true, + ); + expect(data?.email).toBe("test@test.com"); + } + expect(called).toHaveBeenCalled(); + }); + + test("should login with a code", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (to, _subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should register with a code", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (to, _subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("register"); + expect(data.data.expires_at).toBeDefined(); + + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should not send email if sendEmail is false", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + drivers: { + email: { + send: async () => { + called(); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + expect(called).not.toHaveBeenCalled(); + }); + + test("should reject invalid codes", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // First send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Try to use an invalid code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: "999999" }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + }); + + test("should reject code reuse", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (_to, _subject, body) => { + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Use the code successfully + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + } + + // Try to use the same code again + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should reject expired codes", async () => { + // Set a fixed system time + const baseTime = Date.now(); + setSystemTime(new Date(baseTime)); + + try { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + ttl: 1, // 1 second TTL + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + const sendRes = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(sendRes.status).toBe(201); + + // Get the code from the database + const { data: otpData } = await app.em + .fork() + .repo("users_otp") + .findOne({ email: "test@test.com" }); + expect(otpData?.code).toBeDefined(); + + // Advance system time by more than 1 second to expire the code + setSystemTime(new Date(baseTime + 1100)); + + // Try to use the expired code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: otpData?.code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } finally { + // Reset system time + setSystemTime(); + } + }); + + test("should reject codes with different actions", async () => { + let loginCode = ""; + let registerCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a login code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the login code + const { data: loginOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } }); + loginCode = loginOtp?.code || ""; + + // Send a register code + await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the register code + const { data: registerOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } }); + registerCode = registerOtp?.code || ""; + + // Try to use login code for register + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: loginCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // Try to use register code for login + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: registerCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should invalidate previous codes when sending new code", async () => { + let firstCode = ""; + let secondCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + const em = app.em.fork(); + + // Send first code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the first code + const { data: firstOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + firstCode = firstOtp?.code || ""; + expect(firstCode).toBeDefined(); + + // Send second code (should invalidate the first) + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the second code + const { data: secondOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + secondCode = secondOtp?.code || ""; + expect(secondCode).toBeDefined(); + expect(secondCode).not.toBe(firstCode); + + // Try to use the first code (should fail as it's been invalidated) + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: firstCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // The second code should work + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: secondCode }), + }); + expect(res.status).toBe(200); + } + }); +}); diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts new file mode 100644 index 0000000..db00012 --- /dev/null +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -0,0 +1,387 @@ +import { + datetime, + em, + entity, + enumm, + Exception, + text, + type App, + type AppPlugin, + type DB, + type FieldSchema, + type MaybePromise, + type EntityConfig, + DatabaseEvents, +} from "bknd"; +import { + invariant, + s, + jsc, + HttpStatus, + threwAsync, + randomString, + $console, + pickKeys, +} from "bknd/utils"; +import { Hono } from "hono"; + +export type EmailOTPPluginOptions = { + /** + * Customize code generation. If not provided, a random 6-digit code will be generated. + */ + generateCode?: (user: Pick) => string; + + /** + * The base path for the API endpoints. + * @default "/api/auth/otp" + */ + apiBasePath?: string; + + /** + * The TTL for the OTP tokens in seconds. + * @default 600 (10 minutes) + */ + ttl?: number; + + /** + * The name of the OTP entity. + * @default "users_otp" + */ + entity?: string; + + /** + * The config for the OTP entity. + */ + entityConfig?: EntityConfig; + + /** + * Customize email content. If not provided, a default email will be sent. + */ + generateEmail?: ( + otp: EmailOTPFieldSchema, + ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; + + /** + * Enable debug mode for error messages. + * @default false + */ + showActualErrors?: boolean; + + /** + * Allow direct mutations (create/update) of OTP codes outside of this plugin, + * e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows. + * @default false + */ + allowExternalMutations?: boolean; + + /** + * Whether to send the email with the OTP code. + * @default true + */ + sendEmail?: boolean; +}; + +const otpFields = { + action: enumm({ + enum: ["login", "register"], + }), + code: text().required(), + email: text().required(), + created_at: datetime(), + expires_at: datetime().required(), + used_at: datetime(), +}; + +export type EmailOTPFieldSchema = FieldSchema; + +class OTPError extends Exception { + override name = "OTPError"; + override code = HttpStatus.BAD_REQUEST; +} + +export function emailOTP({ + generateCode: _generateCode, + apiBasePath = "/api/auth/otp", + ttl = 600, + entity: entityName = "users_otp", + entityConfig, + generateEmail: _generateEmail, + showActualErrors = false, + allowExternalMutations = false, + sendEmail = true, +}: EmailOTPPluginOptions = {}): AppPlugin { + return (app: App) => { + return { + name: "email-otp", + schema: () => + em( + { + [entityName]: entity( + entityName, + otpFields, + { + name: "Users OTP", + sort_dir: "desc", + primary_format: app.module.data.config.default_primary_format, + ...entityConfig, + }, + "generated", + ) as any, + }, + ({ index }, schema) => { + const otp = schema[entityName]!; + index(otp).on(["email", "expires_at", "code"]); + }, + ), + onBuilt: async () => { + const auth = app.module.auth; + invariant(auth && auth.enabled === true, "Auth is not enabled"); + invariant(!sendEmail || app.drivers?.email, "Email driver is not registered"); + + const generateCode = + _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); + const generateEmail = + _generateEmail ?? + ((otp: EmailOTPFieldSchema) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + })); + const em = app.em.fork(); + + const hono = new Hono() + .post( + "/login", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string({ minLength: 1 }).optional(), + }), + ), + jsc("query", s.object({ redirect: s.string().optional() })), + async (c) => { + const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); + const user = await findUser(app, email); + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "login", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); + } else { + const otpData = await invalidateAndGenerateCode( + app, + { generateCode, ttl, entity: entityName }, + user, + "login", + ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } + + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); + } + }, + ) + .post( + "/register", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string({ minLength: 1 }).optional(), + }), + ), + jsc("query", s.object({ redirect: s.string().optional() })), + async (c) => { + const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); + + // throw if user exists + if (!(await threwAsync(findUser(app, email)))) { + throw new Exception("User already exists", HttpStatus.BAD_REQUEST); + } + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "register", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const user = await app.createUser({ + email, + password: randomString(32, true), + }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); + } else { + const otpData = await invalidateAndGenerateCode( + app, + { generateCode, ttl, entity: entityName }, + { email }, + "register", + ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } + + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); + } + }, + ) + .onError((err) => { + if (showActualErrors || err instanceof OTPError) { + throw err; + } + + throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST); + }); + + app.server.route(apiBasePath, hono); + + if (allowExternalMutations !== true) { + registerListeners(app, entityName); + } + }, + }; + }; +} + +async function findUser(app: App, email: string) { + const user_entity = app.module.auth.config.entity_name as "users"; + const { data: user } = await app.em.repo(user_entity).findOne({ email }); + if (!user) { + throw new Exception("User not found", HttpStatus.BAD_REQUEST); + } + + return user; +} + +async function invalidateAndGenerateCode( + app: App, + opts: Required>, + user: Pick, + action: EmailOTPFieldSchema["action"], +) { + const { generateCode, ttl, entity: entityName } = opts; + const newCode = generateCode?.(user); + if (!newCode) { + throw new OTPError("Failed to generate code"); + } + + await invalidateAllUserCodes(app, entityName, user.email, ttl); + const { data: otpData } = await app.em + .fork() + .mutator(entityName) + .insertOne({ + code: newCode, + email: user.email, + action, + created_at: new Date(), + expires_at: new Date(Date.now() + ttl * 1000), + }); + + $console.log("[OTP Code]", newCode); + + return otpData; +} + +async function sendCode( + app: App, + otpData: EmailOTPFieldSchema, + opts: Required>, +) { + const { generateEmail } = opts; + const { subject, body } = await generateEmail(otpData); + await app.drivers?.email?.send(otpData.email, subject, body); +} + +async function getValidatedCode( + app: App, + entityName: string, + email: string, + code: string, + action: EmailOTPFieldSchema["action"], +) { + invariant(email, "[OTP Plugin]: Email is required"); + invariant(code, "[OTP Plugin]: Code is required"); + const em = app.em.fork(); + const { data: otpData } = await em.repo(entityName).findOne({ email, code, action }); + if (!otpData) { + throw new OTPError("Invalid code"); + } + + if (otpData.expires_at < new Date()) { + throw new OTPError("Code expired"); + } + + if (otpData.used_at) { + throw new OTPError("Code already used"); + } + + return otpData; +} + +async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) { + invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0"); + invariant(email, "[OTP Plugin]: Email is required"); + const em = app.em.fork(); + await em + .mutator(entityName) + .updateWhere( + { expires_at: new Date(Date.now() - 1000) }, + { email, used_at: { $isnull: true } }, + ); +} + +function registerListeners(app: App, entityName: string) { + [DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => { + app.emgr.onEvent( + event, + (e: { params: { entity: { name: string } } }) => { + if (e.params.entity.name === entityName) { + throw new OTPError("Mutations of the OTP entity are not allowed"); + } + }, + { + mode: "sync", + id: "bknd-email-otp", + }, + ); + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index b0090ff..fcb23c1 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; +export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin"; diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index 17263bb..766b56a 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd"; import { useTheme, type AppTheme } from "ui/client/use-theme"; import { Logo } from "ui/components/display/Logo"; import * as AppShell from "ui/layouts/AppShell/AppShell"; -import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client"; +import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client"; import { createMantineTheme } from "./lib/mantine/theme"; import { Routes } from "./routes"; import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options"; @@ -52,26 +52,30 @@ export type BkndAdminProps = { children?: ReactNode; }; -export default function Admin({ - baseUrl: baseUrlOverride, - withProvider = false, - config: _config = {}, - children, -}: BkndAdminProps) { - const { theme } = useTheme(); +export default function Admin(props: BkndAdminProps) { const Provider = ({ children }: any) => - withProvider ? ( + props.withProvider ? ( {children} ) : ( children ); + + return ( + + + + ); +} + +function AdminInner(props: BkndAdminProps) { + const { theme } = useTheme(); const config = { - ..._config, + ...props.config, ...useBkndWindowContext(), }; @@ -82,14 +86,12 @@ export default function Admin({ ); return ( - - - - - {children} - - - + + + + {props.children} + + ); } diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 3ac5d5d..056e52d 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -9,7 +9,7 @@ import { useState, type ReactNode, } from "react"; -import { useApi } from "ui/client"; +import { useApi } from "bknd/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; import { Message } from "ui/components/display/Message"; diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 88a54c1..f6a7c8c 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -14,18 +14,20 @@ const ClientContext = createContext(undefined!); export type ClientProviderProps = { children?: ReactNode; baseUrl?: string; + api?: Api; } & ApiOptions; export const ClientProvider = ({ children, host, baseUrl: _baseUrl = host, + api: _api, ...props }: ClientProviderProps) => { const winCtx = useBkndWindowContext(); const _ctx = useClientContext(); let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; - let user: any = undefined; + let user: any; if (winCtx) { user = winCtx.user; @@ -40,6 +42,7 @@ export const ClientProvider = ({ const apiProps = { user, ...props, host: actualBaseUrl }; const api = useMemo( () => + _api ?? new Api({ ...apiProps, verbose: isDebug(), @@ -50,7 +53,7 @@ export const ClientProvider = ({ } }, }), - [JSON.stringify(apiProps)], + [_api, JSON.stringify(apiProps)], ); const [authState, setAuthState] = useState | undefined>(api.getAuthState()); @@ -64,9 +67,14 @@ export const ClientProvider = ({ export const useApi = (host?: ApiOptions["host"]): Api => { const context = useContext(ClientContext); + if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { + console.info("creating new api", { host }); return new Api({ host: host ?? "" }); } + if (!context) { + throw new Error("useApi must be used within a ClientProvider"); + } return context.api; }; diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 573b990..a93aaf2 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -2,7 +2,7 @@ import type { Api } from "Api"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWRInfinite from "swr/infinite"; -import { useApi } from "ui/client"; +import { useApi } from "../ClientProvider"; import { useState } from "react"; export const useApiQuery = < diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index f53798c..7edeaf1 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -8,9 +8,9 @@ import type { ModuleApi, } from "bknd"; import { objectTransform, encodeSearch } from "bknd/utils"; -import type { Insertable, Selectable, Updateable } from "kysely"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; -import { type Api, useApi } from "ui/client"; +import { type Api, useApi } from "bknd/client"; export class UseEntityApiError extends Error { constructor( @@ -33,6 +33,7 @@ interface UseEntityReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined, Data = Entity extends keyof DB ? DB[Entity] : EntityData, + ActualId = Data extends { id: infer I } ? (I extends Generated ? T : I) : never, Response = ResponseObject>>, > { create: (input: Insertable) => Promise; @@ -42,9 +43,11 @@ interface UseEntityReturn< ResponseObject[] : Selectable>> >; update: Id extends undefined - ? (input: Updateable, id: Id) => Promise + ? (input: Updateable, id: ActualId) => Promise : (input: Updateable) => Promise; - _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; + _delete: Id extends undefined + ? (id: PrimaryFieldType) => Promise + : () => Promise; } export const useEntity = < diff --git a/app/src/ui/client/index.ts b/app/src/ui/client/index.ts index 2fb520e..f611752 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -4,6 +4,7 @@ export { type ClientProviderProps, useApi, useBaseUrl, + useClientContext } from "./ClientProvider"; export * from "./api/use-api"; diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index 291c963..816db01 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,7 +1,6 @@ import type { AuthState } from "Api"; import type { AuthResponse } from "bknd"; -import { useApi, useInvalidate } from "ui/client"; -import { useClientContext } from "ui/client/ClientProvider"; +import { useApi, useInvalidate, useClientContext } from "bknd/client"; type LoginData = { email: string; @@ -19,6 +18,7 @@ type UseAuth = { logout: () => Promise; verify: () => Promise; setToken: (token: string) => void; + local: boolean; }; export const useAuth = (options?: { baseUrl?: string }): UseAuth => { @@ -61,5 +61,6 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => { logout, setToken, verify, + local: !!api.options.storage, }; }; diff --git a/app/src/ui/components/buttons/Button.tsx b/app/src/ui/components/buttons/Button.tsx index 79ee3cc..e1ba53b 100644 --- a/app/src/ui/components/buttons/Button.tsx +++ b/app/src/ui/components/buttons/Button.tsx @@ -1,6 +1,5 @@ import type React from "react"; -import { Children } from "react"; -import { forwardRef } from "react"; +import { Children, forwardRef } from "react"; import { twMerge } from "tailwind-merge"; import { Link } from "ui/components/wouter/Link"; diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index 3ad9146..83a69b5 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -1,6 +1,6 @@ import { Tooltip } from "@mantine/core"; import clsx from "clsx"; -import { getBrowser } from "core/utils"; +import { getBrowser } from "bknd/utils"; import type { Field } from "data/fields"; import { Switch as RadixSwitch } from "radix-ui"; import { diff --git a/app/src/ui/components/form/native-form/NativeForm.tsx b/app/src/ui/components/form/native-form/NativeForm.tsx index 17cc649..9618160 100644 --- a/app/src/ui/components/form/native-form/NativeForm.tsx +++ b/app/src/ui/components/form/native-form/NativeForm.tsx @@ -16,15 +16,18 @@ import { setPath, } from "./utils"; -export type NativeFormProps = { +export type NativeFormProps = Omit, "onChange" | "onSubmit"> & { hiddenSubmit?: boolean; validateOn?: "change" | "submit"; - errorFieldSelector?: (name: string) => any | null; + errorFieldSelector?: (selector: string) => any | null; reportValidity?: boolean; - onSubmit?: (data: any, ctx: { event: FormEvent }) => Promise | void; + onSubmit?: ( + data: any, + ctx: { event: FormEvent; form: HTMLFormElement }, + ) => Promise | void; onSubmitInvalid?: ( errors: InputError[], - ctx: { event: FormEvent }, + ctx: { event: FormEvent; form: HTMLFormElement }, ) => Promise | void; onError?: (errors: InputError[]) => void; disableSubmitOnError?: boolean; @@ -33,7 +36,7 @@ export type NativeFormProps = { ctx: { event: ChangeEvent; key: string; value: any; errors: InputError[] }, ) => Promise | void; clean?: CleanOptions | true; -} & Omit, "onChange" | "onSubmit">; +}; export type InputError = { name: string; @@ -188,12 +191,12 @@ export function NativeForm({ const errors = validate({ report: true }); if (errors.length > 0) { - onSubmitInvalid?.(errors, { event: e }); + onSubmitInvalid?.(errors, { event: e, form }); return; } if (onSubmit) { - await onSubmit(getFormValues(), { event: e }); + await onSubmit(getFormValues(), { event: e, form }); } else { form.submit(); } diff --git a/app/src/ui/components/wouter/Link.tsx b/app/src/ui/components/wouter/Link.tsx index 116555c..4c5238d 100644 --- a/app/src/ui/components/wouter/Link.tsx +++ b/app/src/ui/components/wouter/Link.tsx @@ -1,5 +1,4 @@ -import { useInsertionEffect, useRef } from "react"; -import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter"; +import { type LinkProps, Link as WouterLink, useRouter } from "wouter"; import { useEvent } from "../../hooks/use-event"; /* diff --git a/app/src/ui/elements/auth/AuthForm.tsx b/app/src/ui/elements/auth/AuthForm.tsx index 0865317..edf837e 100644 --- a/app/src/ui/elements/auth/AuthForm.tsx +++ b/app/src/ui/elements/auth/AuthForm.tsx @@ -1,13 +1,16 @@ import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema"; import clsx from "clsx"; import { NativeForm } from "ui/components/form/native-form/NativeForm"; -import { transform } from "lodash-es"; -import type { ComponentPropsWithoutRef } from "react"; +import { transformObject } from "bknd/utils"; +import { useEffect, useState, type ComponentPropsWithoutRef, type FormEvent } from "react"; import { Button } from "ui/components/buttons/Button"; import { Group, Input, Password, Label } from "ui/components/form/Formy/components"; import { SocialLink } from "./SocialLink"; +import { useAuth } from "bknd/client"; +import { Alert } from "ui/components/display/Alert"; +import { useLocation } from "wouter"; -export type LoginFormProps = Omit, "onSubmit" | "action"> & { +export type LoginFormProps = Omit, "action"> & { className?: string; formData?: any; action: "login" | "register"; @@ -23,25 +26,50 @@ export function AuthForm({ action, auth, buttonLabel = action === "login" ? "Sign in" : "Sign up", + onSubmit: _onSubmit, ...props }: LoginFormProps) { + const $auth = useAuth(); const basepath = auth?.basepath ?? "/api/auth"; + const [error, setError] = useState(); + const [, navigate] = useLocation(); const password = { action: `${basepath}/password/${action}`, strategy: auth?.strategies?.password ?? ({ type: "password" } as const), }; - const oauth = transform( - auth?.strategies ?? {}, - (result, value, key) => { - if (value.type !== "password") { - result[key] = value.config; - } - }, - {}, - ) as Record; + const oauth = transformObject(auth?.strategies ?? {}, (value) => { + return value.type !== "password" ? value.config : undefined; + }) as Record; const has_oauth = Object.keys(oauth).length > 0; + async function onSubmit( + data: any, + ctx: { event: FormEvent; form: HTMLFormElement }, + ) { + if ($auth?.local) { + ctx.event.preventDefault(); + + const res = await $auth.login(data); + if ("token" in res) { + navigate("/"); + } else { + setError((res as any).error); + return; + } + } + + await _onSubmit?.(ctx.event); + // submit form + ctx.form.submit(); + } + + useEffect(() => { + if ($auth.user) { + navigate("/"); + } + }, [$auth.user]); + return (
{has_oauth && ( @@ -63,17 +91,19 @@ export function AuthForm({ + {error && } - + - - )} +
+ Resources +
+ + + + + + + + + + + +
- + ); } diff --git a/app/src/ui/routes/tools/mcp/state.ts b/app/src/ui/routes/tools/mcp/state.ts index 877f324..cbbff4c 100644 --- a/app/src/ui/routes/tools/mcp/state.ts +++ b/app/src/ui/routes/tools/mcp/state.ts @@ -3,23 +3,16 @@ import { combine } from "zustand/middleware"; import type { ToolJson } from "jsonv-ts/mcp"; -const FEATURES = ["tools", "resources"] as const; -export type Feature = (typeof FEATURES)[number]; - export const useMcpStore = create( combine( { tools: [] as ToolJson[], - feature: "tools" as Feature | null, - content: null as ToolJson | null, history: [] as { type: "request" | "response"; data: any }[], historyLimit: 50, historyVisible: false, }, (set) => ({ setTools: (tools: ToolJson[]) => set({ tools }), - setFeature: (feature: Feature) => set({ feature }), - setContent: (content: ToolJson | null) => set({ content }), addHistory: (type: "request" | "response", data: any) => set((state) => ({ history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)], diff --git a/app/src/ui/routes/tools/mcp/tools.tsx b/app/src/ui/routes/tools/mcp/tools.tsx index 6c475dd..2951b70 100644 --- a/app/src/ui/routes/tools/mcp/tools.tsx +++ b/app/src/ui/routes/tools/mcp/tools.tsx @@ -5,7 +5,6 @@ import { AppShell } from "ui/layouts/AppShell"; import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb"; import { IconButton } from "ui/components/buttons/IconButton"; import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer"; -import { twMerge } from "ui/elements/mocks/tailwind-merge"; import { Field, Form } from "ui/components/form/json-schema-form"; import { Button } from "ui/components/buttons/Button"; import * as Formy from "ui/components/form/Formy"; @@ -13,17 +12,18 @@ import { appShellStore } from "ui/store"; import { Icon } from "ui/components/display/Icon"; import { useMcpClient } from "./hooks/use-mcp-client"; import { Tooltip } from "@mantine/core"; +import { Link } from "ui/components/wouter/Link"; +import { useParams } from "wouter"; -export function Sidebar({ open, toggle }) { +export function Sidebar() { const client = useMcpClient(); const closeSidebar = appShellStore((store) => store.closeSidebar("default")); const tools = useMcpStore((state) => state.tools); const setTools = useMcpStore((state) => state.setTools); - const setContent = useMcpStore((state) => state.setContent); - const content = useMcpStore((state) => state.content); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [error, setError] = useState(null); + const scrollContainerRef = useRef(undefined!); const handleRefresh = useCallback(async () => { setLoading(true); @@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) { }, []); useEffect(() => { - handleRefresh(); + handleRefresh().then(() => { + if (scrollContainerRef.current) { + const selectedTool = scrollContainerRef.current.querySelector(".active"); + if (selectedTool) { + selectedTool.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }); }, []); return ( - ( + identifier="tools" + renderHeaderRight={({ active }) => (
{error && ( @@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) { {tools.length} - +
)} > @@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) { return ( { - setContent(tool); + //setContent(tool); closeSidebar(); }} > @@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) { })} -
+ ); } export function Content() { - const content = useMcpStore((state) => state.content); + const { toolName } = useParams(); + const tools = useMcpStore((state) => state.tools); + const tool = tools.find((tool) => tool.name === toolName); const addHistory = useMcpStore((state) => state.addHistory); - const [payload, setPayload] = useState(getTemplate(content?.inputSchema)); + const [payload, setPayload] = useState(getTemplate(tool?.inputSchema)); const [result, setResult] = useState(null); const historyVisible = useMcpStore((state) => state.historyVisible); const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible); const client = useMcpClient(); const jsonViewerTabsRef = useRef(null); const hasInputSchema = - content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0; + tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0; const [isPending, startTransition] = useTransition(); useEffect(() => { - setPayload(getTemplate(content?.inputSchema)); + setPayload(getTemplate(tool?.inputSchema)); setResult(null); - }, [content]); + }, [toolName]); const handleSubmit = useCallback(async () => { - if (!content?.name) return; + if (!tool?.name) return; const request = { - name: content.name, + name: tool.name, arguments: payload, }; startTransition(async () => { @@ -131,7 +139,7 @@ export function Content() { }); }, [payload]); - if (!content) return null; + if (!tool) return null; let readableResult = result; try { @@ -144,11 +152,11 @@ export function Content() { return (
setHistoryVisible(!historyVisible)} /> + + ))} + + t.id).join()} + action={async (formData: FormData) => { + const title = formData.get("title") as string; + await $q.create({ title }); + }} + > + + + + + + +
+ Go to Admin. ➝ +
+ {auth.user ? ( +

+ Authenticated as {auth.user.email} +

+ ) : ( + Login + )} +
+
+ + ); +} diff --git a/examples/cloudflare-vite-code/src/app/vite-env.d.ts b/examples/cloudflare-vite-code/src/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cloudflare-vite-code/src/worker/index.ts b/examples/cloudflare-vite-code/src/worker/index.ts new file mode 100644 index 0000000..7f22fcf --- /dev/null +++ b/examples/cloudflare-vite-code/src/worker/index.ts @@ -0,0 +1,7 @@ +import { serve } from "bknd/adapter/cloudflare"; +import config from "../../config.ts"; + +export default serve(config, () => ({ + // since bknd is running code-only, we can use a pre-initialized app instance if available + warm: true, +})); diff --git a/examples/cloudflare-vite-code/tsconfig.app.json b/examples/cloudflare-vite-code/tsconfig.app.json new file mode 100644 index 0000000..643d6aa --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/app", "./config.ts"] +} diff --git a/examples/cloudflare-vite-code/tsconfig.json b/examples/cloudflare-vite-code/tsconfig.json new file mode 100644 index 0000000..c7155af --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ], + "compilerOptions": { + "types": ["./worker-configuration.d.ts", "node"] + } +} diff --git a/examples/cloudflare-vite-code/tsconfig.node.json b/examples/cloudflare-vite-code/tsconfig.node.json new file mode 100644 index 0000000..50130b3 --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite-code/tsconfig.worker.json b/examples/cloudflare-vite-code/tsconfig.worker.json new file mode 100644 index 0000000..8a7758a --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["vite/client", "./worker-configuration.d.ts"] + }, + "include": ["src/worker", "src/config.ts"] +} diff --git a/examples/cloudflare-vite-code/vite.config.ts b/examples/cloudflare-vite-code/vite.config.ts new file mode 100644 index 0000000..7704b9f --- /dev/null +++ b/examples/cloudflare-vite-code/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss(), cloudflare()], + build: { + minify: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + }, +}); diff --git a/examples/cloudflare-vite-code/wrangler.json b/examples/cloudflare-vite-code/wrangler.json new file mode 100644 index 0000000..d47fedd --- /dev/null +++ b/examples/cloudflare-vite-code/wrangler.json @@ -0,0 +1,47 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-fullstack-bknd", + "main": "./src/worker/index.ts", + "compatibility_date": "2025-10-08", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "assets": { + "binding": "ASSETS", + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api*", "!/assets/*"] + }, + "vars": { + "ENVIRONMENT": "development" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ], + "env": { + "production": { + "vars": { + "ENVIRONMENT": "production" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ] + } + } +} diff --git a/examples/cloudflare-vite-hybrid/.env.example b/examples/cloudflare-vite-hybrid/.env.example new file mode 100644 index 0000000..ab058b1 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/.env.example @@ -0,0 +1 @@ +auth.jwt.secret=secret \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/README.md b/examples/cloudflare-vite-hybrid/README.md new file mode 100644 index 0000000..5f19c07 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/README.md @@ -0,0 +1,249 @@ +# bknd starter: Cloudflare Vite Hybrid +A fullstack React + Vite application with bknd integration, showcasing **hybrid mode** and Cloudflare Workers deployment. + +## Key Features + +This example demonstrates several advanced bknd features: + +### 🔄 Hybrid Mode +Configure your backend **visually in development** using the Admin UI, then automatically switch to **code-only mode in production** for maximum performance. Changes made in the Admin UI are automatically synced to `bknd-config.json` and type definitions are generated in `bknd-types.d.ts`. + +### 📁 Filesystem Access with Vite Plugin +Cloudflare's Vite plugin uses `unenv` which disables Node.js APIs like `fs`. This example uses bknd's `devFsVitePlugin` and `devFsWrite` to provide filesystem access during development, enabling automatic syncing of types and configuration. + +### ⚡ Split Configuration Pattern +- **`config.ts`**: Shared configuration that can be safely imported in your worker +- **`bknd.config.ts`**: Wraps the configuration with `withPlatformProxy` for CLI usage with Cloudflare bindings (should NOT be imported in your worker) + +This pattern prevents bundling `wrangler` into your worker while still allowing CLI access to Cloudflare resources. + +## Project Structure + +Inside of your project, you'll see the following folders and files: + +```text +/ +├── src/ +│ ├── app/ # React frontend application +│ │ ├── App.tsx +│ │ ├── routes/ +│ │ │ ├── admin.tsx # bknd Admin UI route +│ │ │ └── home.tsx # Example frontend route +│ │ └── main.tsx +│ └── worker/ +│ └── index.ts # Cloudflare Worker entry +├── config.ts # Shared bknd configuration (hybrid mode) +├── bknd.config.ts # CLI configuration with platform proxy +├── bknd-config.json # Auto-generated production config +├── bknd-types.d.ts # Auto-generated TypeScript types +├── .env.example # Auto-generated secrets template +├── vite.config.ts # Includes devFsVitePlugin +├── package.json +└── wrangler.json # Cloudflare Workers configuration +``` + +## Cloudflare resources + +- **D1:** `wrangler.json` declares a `DB` binding. In production, replace `database_id` with your own (`wrangler d1 create `). +- **R2:** Optional `BUCKET` binding is pre-configured to show how to add additional services. +- **Environment awareness:** `ENVIRONMENT` switch toggles hybrid behavior: production makes the database read-only, while development keeps `mode: "db"` and auto-syncs schema. +- **Static assets:** The Assets binding points to `dist/client`. Run `npm run build` before `wrangler deploy` to upload the client bundle alongside the worker. + +## Admin UI & frontend + +- `/admin` mounts `` from `bknd/ui` with `withProvider={{ user }}` so it respects the authenticated user returned by `useAuth`. +- `/` showcases `useEntityQuery("todos")`, mutation helpers, and authentication state — demonstrating how the generated client types (`bknd-types.d.ts`) flow into the React code. + + +## Configuration Files + +### `config.ts` +The main configuration file that uses the `hybrid()` mode helper: + + - Loads the generated config via an ESM `reader` (importing `./bknd-config.json`). + - Uses `devFsWrite` as the `writer` so the CLI/plugin can persist files even though Node's `fs` API is unavailable in Miniflare. + - Sets `typesFilePath`, `configFilePath`, and `syncSecrets` (writes `.env.example`) so config, types, and secret placeholders stay aligned. + - Seeds example data/users in `options.seed` when the database is empty. + - Disables the built-in admin controller because the React app renders `/admin` via `bknd/ui`. + + +```typescript +import { hybrid } from "bknd/modes"; +import { devFsWrite, type CloudflareBkndConfig } from "bknd/adapter/cloudflare"; + +export default hybrid({ + // Special reader for Cloudflare Workers (no Node.js fs) + reader: async () => (await import("./bknd-config.json")).default, + // devFsWrite enables file writing via Vite plugin + writer: devFsWrite, + // Auto-sync these files in development + typesFilePath: "./bknd-types.d.ts", + configFilePath: "./bknd-config.json", + syncSecrets: { + enabled: true, + outFile: ".env.example", + format: "env", + }, + app: (env) => ({ + adminOptions: false, // Disabled - we render React app instead + isProduction: env.ENVIRONMENT === "production", + secrets: env, + // ... your configuration + }), +}); +``` + +### `bknd.config.ts` +Wraps the configuration for CLI usage with Cloudflare bindings: + +```typescript +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config); +``` + +### `vite.config.ts` +Includes the `devFsVitePlugin` for filesystem access: + +```typescript +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + // ... + devFsVitePlugin({ configFile: "config.ts" }), + cloudflare(), + ], +}); +``` + +## Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +|:-------------------|:----------------------------------------------------------| +| `npm install` | Installs dependencies and generates wrangler types | +| `npm run dev` | Starts local dev server with Vite at `localhost:5173` | +| `npm run build` | Builds the application for production | +| `npm run preview` | Builds and previews the production build locally | +| `npm run deploy` | Builds, syncs the schema and deploys to Cloudflare Workers| +| `npm run bknd` | Runs bknd CLI commands | +| `npm run bknd:types` | Generates TypeScript types from your schema | +| `npm run cf:types` | Generates Cloudflare Worker types from `wrangler.json` | +| `npm run check` | Type checks and does a dry-run deployment | + +## Development Workflow + +1. **Install dependencies:** + ```sh + npm install + ``` + +2. **Start development server:** + ```sh + npm run dev + ``` + +3. **Visit the Admin UI** at `http://localhost:5173/admin` to configure your backend visually: + - Create entities and fields + - Configure authentication + - Set up relationships + - Define permissions + +4. **Watch for auto-generated files:** + - `bknd-config.json` - Production configuration + - `bknd-types.d.ts` - TypeScript types + - `.env.example` - Required secrets + +5. **Use the CLI** for manual operations: + ```sh + # Generate types manually + npm run bknd:types + + # Sync the production database schema (only safe operations are applied) + CLOUDFLARE_ENV=production npm run bknd -- sync --force + ``` + +## Before you deploy + +If you're using a D1 database, make sure to create a database in your Cloudflare account and replace the `database_id` accordingly in `wrangler.json`: + +```sh +npx wrangler d1 create my-database +``` + +Update `wrangler.json`: +```json +{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-database", + "database_id": "your-database-id-here" + } + ] +} +``` + +## Deployment + +Deploy to Cloudflare Workers: + +```sh +npm run deploy +``` + +This will: +1. Set `ENVIRONMENT=production` to activate code-only mode +2. Build the Vite application +3. Deploy to Cloudflare Workers using Wrangler + +In production, bknd will: +- Use the configuration from `bknd-config.json` (read-only) +- Skip config validation for better performance +- Expect secrets to be provided via environment variables + +## Environment Variables + +Make sure to set your secrets in the Cloudflare Workers dashboard or via Wrangler: + +```sh +# Example: Set JWT secret +npx wrangler secret put auth.jwt.secret +``` + +Check `.env.example` for all required secrets after running the app in development mode. + +## How Hybrid Mode Works + +```mermaid +graph LR + A[Development] -->|Visual Config| B[Admin UI] + B -->|Auto-sync| C[bknd-config.json] + B -->|Auto-sync| D[bknd-types.d.ts] + C -->|Deploy| E[Production] + E -->|Read-only| F[Code-only Mode] +``` + +1. **In Development:** `mode: "db"` - Configuration stored in database, editable via Admin UI +2. **Auto-sync:** Changes automatically written to `bknd-config.json` and types to `bknd-types.d.ts` +3. **In Production:** `mode: "code"` - Configuration read from `bknd-config.json`, no database overhead + +## Why devFsVitePlugin? + +Cloudflare's Vite plugin removes Node.js APIs for Workers compatibility. This breaks filesystem operations needed for: +- Auto-syncing TypeScript types (`syncTypes` plugin) +- Auto-syncing configuration (`syncConfig` plugin) +- Auto-syncing secrets (`syncSecrets` plugin) + +The `devFsVitePlugin` + `devFsWrite` combination provides a workaround by using Vite's module system to enable file writes during development. + +## Want to learn more? + +- [Cloudflare Integration Documentation](https://docs.bknd.io/integration/cloudflare) +- [Hybrid Mode Guide](https://docs.bknd.io/usage/introduction#hybrid-mode) +- [Mode Helpers Documentation](https://docs.bknd.io/usage/introduction#mode-helpers) +- [Discord Community](https://discord.gg/952SFk8Tb8) + diff --git a/examples/cloudflare-vite-hybrid/bknd-config.json b/examples/cloudflare-vite-hybrid/bknd-config.json new file mode 100644 index 0000000..fb703aa --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd-config.json @@ -0,0 +1,204 @@ +{ + "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", + "logLevel": "emergency" + } + }, + "data": { + "basepath": "/api/data", + "default_primary_format": "integer", + "entities": { + "todos": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false + } + }, + "done": { + "type": "boolean", + "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": "objects", + "values": [] + }, + "required": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": {}, + "indices": { + "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 + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "", + "alg": "HS256", + "expires": 0, + "issuer": "bknd-cloudflare-example", + "fields": [ + "id", + "email", + "role" + ] + }, + "cookie": { + "domain": "", + "path": "/", + "sameSite": "strict", + "secure": true, + "httpOnly": true, + "expires": 604800, + "partitioned": false, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { + "hashing": "sha256" + } + } + }, + "guard": { + "enabled": false + }, + "roles": {} + }, + "media": { + "enabled": false, + "basepath": "/api/media", + "entity_name": "media", + "storage": {} + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/bknd-types.d.ts b/examples/cloudflare-vite-hybrid/bknd-types.d.ts new file mode 100644 index 0000000..db7bae6 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd-types.d.ts @@ -0,0 +1,22 @@ +import type { DB } from "bknd"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; + +declare global { + type BkndEntity = Selectable; + type BkndEntityCreate = Insertable; + type BkndEntityUpdate = Updateable; +} + +export interface Todos { + id: Generated; + title?: string; + done?: boolean; +} + +interface Database { + todos: Todos; +} + +declare module "bknd" { + interface DB extends Database {} +} \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/bknd.config.ts b/examples/cloudflare-vite-hybrid/bknd.config.ts new file mode 100644 index 0000000..8129c0e --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd.config.ts @@ -0,0 +1,15 @@ +/** + * This file gets automatically picked up by the bknd CLI. Since we're using cloudflare, + * we want to use cloudflare bindings (such as the database). To do this, we need to wrap + * the configuration with the `withPlatformProxy` helper function. + * + * Don't import this file directly in your app, otherwise "wrangler" will be bundled with your worker. + * That's why we split the configuration into two files: `bknd.config.ts` and `config.ts`. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); diff --git a/examples/cloudflare-vite-hybrid/config.ts b/examples/cloudflare-vite-hybrid/config.ts new file mode 100644 index 0000000..2177649 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/config.ts @@ -0,0 +1,47 @@ +/// + +import { devFsWrite, type CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { hybrid } from "bknd/modes"; + +export default hybrid({ + // normally you would use e.g. `readFile` from `node:fs/promises`, however, cloudflare using vite plugin removes all Node APIs, therefore we need to use the module system to import the config file + reader: async () => { + return (await import("./bknd-config.json").then((module) => module.default)) as any; + }, + // a writer is required to sync the types and config. We're using a vite plugin that proxies writing files (since Node APIs are not available) + writer: devFsWrite, + // the generated types are loaded using our tsconfig, and is automatically available in all bknd APIs + typesFilePath: "./bknd-types.d.ts", + // on every change, this config file is updated. When it's time to deploy, this will be inlined into your worker + configFilePath: "./bknd-config.json", + // secrets will always be extracted from the configuration, we're writing an example env file to know which secrets we need to provide prior to deploying + syncSecrets: { + enabled: true, + outFile: ".env.example", + format: "env", + } as const, + app: (env) => ({ + // we need to disable the admin controller using the vite plugin, since we want to render our own app + adminOptions: false, + // this is important to determine whether configuration should be read-only, or if the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + // we need to inject the secrets that gets merged into the configuration + secrets: env, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, + }), +}); diff --git a/examples/cloudflare-vite-hybrid/index.html b/examples/cloudflare-vite-hybrid/index.html new file mode 100644 index 0000000..5b7fd70 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/index.html @@ -0,0 +1,14 @@ + + + + + + + bknd + Vite + Cloudflare + React + TS + + + +
+ + + diff --git a/examples/cloudflare-vite-hybrid/package.json b/examples/cloudflare-vite-hybrid/package.json new file mode 100644 index 0000000..71e3ae9 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/package.json @@ -0,0 +1,36 @@ +{ + "name": "cloudflare-vite-fullstack-bknd", + "description": "A template for building a React application with Vite, Cloudflare Workers, and bknd", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "cf:types": "wrangler types", + "bknd": "node --experimental-strip-types node_modules/.bin/bknd", + "bknd:types": "bknd -- types", + "check": "tsc && vite build && wrangler deploy --dry-run", + "deploy": "CLOUDFLARE_ENV=production vite build && CLOUDFLARE_ENV=production npm run bknd -- sync --force && wrangler deploy", + "dev": "vite", + "preview": "npm run build && vite preview", + "postinstall": "npm run cf:types" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.17", + "bknd": "file:../../app", + "hono": "4.10.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.17", + "wouter": "^3.7.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "typescript": "5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } +} diff --git a/examples/cloudflare-vite-hybrid/public/vite.svg b/examples/cloudflare-vite-hybrid/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-hybrid/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/App.tsx b/examples/cloudflare-vite-hybrid/src/app/App.tsx new file mode 100644 index 0000000..9aa6a5f --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/App.tsx @@ -0,0 +1,39 @@ +import { Router, Switch, Route } from "wouter"; +import Home from "./routes/home.tsx"; +import { lazy, Suspense, useEffect, useState } from "react"; +const Admin = lazy(() => import("./routes/admin.tsx")); +import { useAuth } from "bknd/client"; + +export default function App() { + const auth = useAuth(); + const [verified, setVerified] = useState(false); + + useEffect(() => { + auth.verify().then(() => setVerified(true)); + }, []); + + if (!verified) return null; + + return ( + + + + + + + + + +
+ 404 +
+
+
+
+ ); +} diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg b/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg new file mode 100644 index 0000000..182ef92 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg b/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg new file mode 100644 index 0000000..3bb7ac0 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/react.svg b/examples/cloudflare-vite-hybrid/src/app/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg b/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/index.css b/examples/cloudflare-vite-hybrid/src/app/index.css new file mode 100644 index 0000000..4e0bdd8 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/index.css @@ -0,0 +1,28 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +@theme { + --color-background: var(--background); + --color-foreground: var(--foreground); +} + +body { + @apply bg-background text-foreground flex; + font-family: Arial, Helvetica, sans-serif; +} + +#root { + width: 100%; + min-height: 100dvh; +} diff --git a/examples/cloudflare-vite-hybrid/src/app/main.tsx b/examples/cloudflare-vite-hybrid/src/app/main.tsx new file mode 100644 index 0000000..8d55eb2 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { ClientProvider } from "bknd/client"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx b/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx new file mode 100644 index 0000000..93a4d9b --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx @@ -0,0 +1,8 @@ +import { Admin, type BkndAdminProps } from "bknd/ui"; +import "bknd/dist/styles.css"; +import { useAuth } from "bknd/client"; + +export default function AdminPage(props: BkndAdminProps) { + const auth = useAuth(); + return ; +} diff --git a/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx b/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx new file mode 100644 index 0000000..91cb1f2 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx @@ -0,0 +1,99 @@ +import { useAuth, useEntityQuery } from "bknd/client"; +import bkndLogo from "../assets/bknd.svg"; +import cloudflareLogo from "../assets/cloudflare.svg"; +import viteLogo from "../assets/vite.svg"; + +export default function Home() { + const auth = useAuth(); + + const limit = 5; + const { data: todos, ...$q } = useEntityQuery("todos", undefined, { + limit, + sort: "-id", + }); + + return ( +
+
+ bknd +
&
+
+ cloudflare +
+
+ vite +
+
+ +
+

+ What's next? +

+
+
+ {todos && + [...todos].reverse().map((todo) => ( +
+
+ { + await $q.update( + { done: !todo.done }, + todo.id + ); + }} + /> +
+ {todo.title} +
+
+ +
+ ))} +
+
t.id).join()} + action={async (formData: FormData) => { + const title = formData.get("title") as string; + await $q.create({ title }); + }} + > + + +
+
+
+ +
+ Go to Admin. ➝ +
+ {auth.user ? ( +

+ Authenticated as {auth.user.email} +

+ ) : ( + Login + )} +
+
+
+ ); +} diff --git a/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts b/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cloudflare-vite-hybrid/src/worker/index.ts b/examples/cloudflare-vite-hybrid/src/worker/index.ts new file mode 100644 index 0000000..7bf59ff --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/worker/index.ts @@ -0,0 +1,4 @@ +import { serve } from "bknd/adapter/cloudflare"; +import config from "../../config.ts"; + +export default serve(config); diff --git a/examples/cloudflare-vite-hybrid/tsconfig.app.json b/examples/cloudflare-vite-hybrid/tsconfig.app.json new file mode 100644 index 0000000..23edca7 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/app"] +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.json b/examples/cloudflare-vite-hybrid/tsconfig.json new file mode 100644 index 0000000..b3e17e0 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ], + "compilerOptions": { + "types": ["./worker-configuration.d.ts", "./bknd-types.d.ts", "node"] + } +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.node.json b/examples/cloudflare-vite-hybrid/tsconfig.node.json new file mode 100644 index 0000000..50130b3 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.worker.json b/examples/cloudflare-vite-hybrid/tsconfig.worker.json new file mode 100644 index 0000000..8a7758a --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["vite/client", "./worker-configuration.d.ts"] + }, + "include": ["src/worker", "src/config.ts"] +} diff --git a/examples/cloudflare-vite-hybrid/vite.config.ts b/examples/cloudflare-vite-hybrid/vite.config.ts new file mode 100644 index 0000000..0a83dae --- /dev/null +++ b/examples/cloudflare-vite-hybrid/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + react(), + // this plugin provides filesystem access during development + devFsVitePlugin({ configFile: "config.ts" }) as any, + tailwindcss(), + cloudflare(), + ], + build: { + minify: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + }, +}); diff --git a/examples/cloudflare-vite-hybrid/wrangler.json b/examples/cloudflare-vite-hybrid/wrangler.json new file mode 100644 index 0000000..d47fedd --- /dev/null +++ b/examples/cloudflare-vite-hybrid/wrangler.json @@ -0,0 +1,47 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-fullstack-bknd", + "main": "./src/worker/index.ts", + "compatibility_date": "2025-10-08", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "assets": { + "binding": "ASSETS", + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api*", "!/assets/*"] + }, + "vars": { + "ENVIRONMENT": "development" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ], + "env": { + "production": { + "vars": { + "ENVIRONMENT": "production" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ] + } + } +} diff --git a/packages/postgres/examples/neon.ts b/examples/postgres/neon.ts similarity index 72% rename from packages/postgres/examples/neon.ts rename to examples/postgres/neon.ts index f4efd02..0261f90 100644 --- a/packages/postgres/examples/neon.ts +++ b/examples/postgres/neon.ts @@ -1,8 +1,8 @@ import { serve } from "bknd/adapter/bun"; -import { createCustomPostgresConnection } from "../src"; +import { createCustomPostgresConnection } from "bknd"; import { NeonDialect } from "kysely-neon"; -const neon = createCustomPostgresConnection(NeonDialect); +const neon = createCustomPostgresConnection("neon", NeonDialect); export default serve({ connection: neon({ diff --git a/examples/postgres/package.json b/examples/postgres/package.json new file mode 100644 index 0000000..ea1ad6b --- /dev/null +++ b/examples/postgres/package.json @@ -0,0 +1,20 @@ +{ + "name": "postgres", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "pg": "^8.14.0", + "postgres": "^3.4.7", + "@xata.io/client": "^0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", + "@xata.io/kysely": "^0.2.1", + "kysely-neon": "^1.3.0", + "bknd": "file:../app", + "kysely": "0.27.6" + }, + "devDependencies": { + "@types/bun": "^1.2.5", + "@types/node": "^22.13.10", + "@types/pg": "^8.11.11" + } +} diff --git a/packages/postgres/tsconfig.json b/examples/postgres/tsconfig.json similarity index 92% rename from packages/postgres/tsconfig.json rename to examples/postgres/tsconfig.json index 5bb10f6..7fb1ec5 100644 --- a/packages/postgres/tsconfig.json +++ b/examples/postgres/tsconfig.json @@ -25,7 +25,8 @@ "skipLibCheck": true, "baseUrl": ".", "paths": { - "$bknd/*": ["../../app/src/*"] + "bknd": ["../app/src/index.ts"], + "bknd/*": ["../app/src/*"] } }, "include": ["./src/**/*.ts"], diff --git a/packages/postgres/examples/xata.ts b/examples/postgres/xata.ts similarity index 72% rename from packages/postgres/examples/xata.ts rename to examples/postgres/xata.ts index 75e2a32..d6b5c07 100644 --- a/packages/postgres/examples/xata.ts +++ b/examples/postgres/xata.ts @@ -1,23 +1,23 @@ import { serve } from "bknd/adapter/bun"; -import { createCustomPostgresConnection } from "../src"; +import { createCustomPostgresConnection } from "bknd"; import { XataDialect } from "@xata.io/kysely"; import { buildClient } from "@xata.io/client"; const client = buildClient(); -const xata = new client({ +const xataClient = new client({ databaseURL: process.env.XATA_URL, apiKey: process.env.XATA_API_KEY, branch: process.env.XATA_BRANCH, }); -const connection = createCustomPostgresConnection(XataDialect, { +const xata = createCustomPostgresConnection("xata", XataDialect, { supports: { batching: false, }, -})({ xata }); +}); export default serve({ - connection, + connection: xata(xataClient), // ignore this, it's only required within this repository // because bknd is installed via "workspace:*" distPath: "../../../app/dist", diff --git a/examples/react/package.json b/examples/react/package.json index 568b72d..06ea8de 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -10,11 +10,10 @@ "preview": "vite preview" }, "dependencies": { - "@bknd/sqlocal": "file:../../packages/sqlocal", "bknd": "file:../../app", "react": "^19.0.0", "react-dom": "^19.0.0", - "sqlocal": "^0.14.0", + "sqlocal": "^0.16.0", "wouter": "^3.6.0" }, "devDependencies": { @@ -26,7 +25,7 @@ "tailwindcss": "^4.0.14", "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", - "vite": "^6.2.0", + "vite": "^7.2.4", "vite-tsconfig-paths": "^5.1.4" } } diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index b933893..8a281e0 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,62 +1,8 @@ -import { lazy, Suspense, useEffect, useState } from "react"; -import { checksum } from "bknd/utils"; -import { App, boolean, em, entity, text } from "bknd"; -import { SQLocalConnection } from "@bknd/sqlocal"; -import { Route, Router, Switch } from "wouter"; +import { boolean, em, entity, text } from "bknd"; +import { Route } from "wouter"; import IndexPage from "~/routes/_index"; -import { Center } from "~/components/Center"; -import { ClientProvider } from "bknd/client"; - -const Admin = lazy(() => import("~/routes/admin")); - -export default function () { - const [app, setApp] = useState(undefined); - const [hash, setHash] = useState(""); - - async function onBuilt(app: App) { - document.startViewTransition(async () => { - setApp(app); - setHash(await checksum(app.toJSON())); - }); - } - - useEffect(() => { - setup({ onBuilt }) - .then((app) => console.log("setup", app?.version())) - .catch(console.error); - }, []); - - if (!app) - return ( -
- Loading... -
- ); - - return ( - - - ( - - - - )} - /> - - - - - - - -
404
-
-
-
- ); -} +import { BkndBrowserApp, type BrowserBkndConfig, useApp } from "bknd/adapter/browser"; +import { type ReactNode, useEffect } from "react"; const schema = em({ todos: entity("todos", { @@ -71,54 +17,50 @@ declare module "bknd" { interface DB extends Database {} } -let initialized = false; -async function setup(opts?: { - beforeBuild?: (app: App) => Promise; - onBuilt?: (app: App) => Promise; -}) { - if (initialized) return; - initialized = true; - - const connection = new SQLocalConnection({ - databasePath: ":localStorage:", - verbose: true, - }); - - const app = App.create({ - connection, - // an initial config is only applied if the database is empty - config: { - data: schema.toJSON(), - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - - // @todo: auth is currently not working due to POST request - /*await ctx.app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - });*/ +const config = { + config: { + data: schema.toJSON(), + auth: { + enabled: true, + jwt: { + secret: "secret", }, }, - }); + }, + adminConfig: { + basepath: "/admin", + logo_return_path: "/../", + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); - if (opts?.onBuilt) { - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async () => { - await opts.onBuilt?.(app); - }, - "sync", - ); - } + // @todo: auth is currently not working due to POST request + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} satisfies BrowserBkndConfig; - await opts?.beforeBuild?.(app); - await app.build({ sync: true }); - - return app; +export default function App() { + return ( + }> + + + ); } + +const Debug = () => { + const { app } = useApp(); + useEffect(() => { + // @ts-ignore + window.app = app; + }, [app]); + return null; +}; diff --git a/examples/react/src/routes/_index.tsx b/examples/react/src/routes/_index.tsx index ae905ec..79cd2e5 100644 --- a/examples/react/src/routes/_index.tsx +++ b/examples/react/src/routes/_index.tsx @@ -1,10 +1,11 @@ import { Center } from "~/components/Center"; -import type { App } from "bknd"; import { useEntityQuery } from "bknd/client"; -import type { SQLocalConnection } from "@bknd/sqlocal/src"; +import type { SQLocalConnection } from "bknd"; +import { useApp } from "bknd/adapter/browser"; import { useEffect, useState } from "react"; +import { Link } from "wouter"; -export default function IndexPage({ app }: { app: App }) { +export default function IndexPage() { //const user = app.getApi().getUser(); const limit = 5; const { data: todos, ...$q } = useEntityQuery("todos", undefined, { @@ -80,7 +81,7 @@ export default function IndexPage({ app }: { app: App }) {
- Go to Admin ➝ + Go to Admin ➝ {/*
{user ? (

@@ -91,12 +92,13 @@ export default function IndexPage({ app }: { app: App }) { )}

*/}
- + ); } -function Debug({ app }: { app: App }) { +function Debug() { + const { app } = useApp(); const [info, setInfo] = useState(); const connection = app.em.connection as SQLocalConnection; @@ -128,6 +130,7 @@ function Debug({ app }: { app: App }) { return (