diff --git a/app/index.html b/app/index.html index 4807db2..2efd6e2 100644 --- a/app/index.html +++ b/app/index.html @@ -6,7 +6,6 @@ BKND -
diff --git a/app/package.json b/app/package.json index a324e8d..39c65cc 100644 --- a/app/package.json +++ b/app/package.json @@ -92,7 +92,7 @@ "entry": ["src/index.ts", "src/ui/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], "minify": true, "outDir": "dist", - "external": ["bun:test"], + "external": ["bun:test", "bknd/dist/manifest.json"], "sourcemap": true, "metafile": true, "platform": "browser", @@ -109,6 +109,9 @@ "react": ">=18", "react-dom": ">=18" }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", @@ -165,9 +168,15 @@ "import": "./dist/adapter/bun/index.js", "require": "./dist/adapter/bun/index.cjs" }, + "./adapter/node": { + "types": "./dist/adapter/node/index.d.ts", + "import": "./dist/adapter/node/index.js", + "require": "./dist/adapter/node/index.cjs" + }, "./dist/static/manifest.json": "./dist/static/.vite/manifest.json", "./dist/styles.css": "./dist/styles.css", - "./dist/index.html": "./dist/static/index.html" + "./dist/index.html": "./dist/static/index.html", + "./dist/manifest.json": "./dist/static/.vite/manifest.json" }, "files": [ "dist", diff --git a/app/src/App.ts b/app/src/App.ts index 4d7a432..a617a0a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -7,6 +7,7 @@ import { type Modules } from "modules/ModuleManager"; import * as SystemPermissions from "modules/permissions"; +import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; export type AppPlugin = (app: App) => void; @@ -58,7 +59,7 @@ export class App { static create(config: CreateAppConfig) { let connection: Connection | undefined = undefined; - if (config.connection instanceof Connection) { + if (Connection.isConnection(config.connection)) { connection = config.connection; } else if (typeof config.connection === "object") { switch (config.connection.type) { @@ -66,6 +67,8 @@ export class App { connection = new LibsqlConnection(config.connection.config); break; } + } else { + throw new Error(`Unknown connection of type ${typeof config.connection} given.`); } if (!connection) { throw new Error("Invalid connection"); @@ -79,7 +82,6 @@ export class App { } async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) { - //console.log("building"); await this.modules.build(); if (options?.sync) { @@ -136,6 +138,12 @@ export class App { return this.modules.version(); } + registerAdminController(config?: AdminControllerOptions) { + // register admin + this.modules.server.route("/", new AdminController(this, config).getController()); + return this; + } + toJSON(secrets?: boolean) { return this.modules.toJSON(secrets); } diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 9c276ce..fefe6ef 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,15 +1,37 @@ -import { readFile } from "node:fs/promises"; import path from "node:path"; import { App, type CreateAppConfig } from "bknd"; +import { LibsqlConnection } from "bknd/data"; import { serveStatic } from "hono/bun"; -let app: App; -export function serve(config: CreateAppConfig, distPath?: string) { +async function getConnection(conn?: CreateAppConfig["connection"]) { + if (conn) { + if (LibsqlConnection.isConnection(conn)) { + return conn; + } + + return new LibsqlConnection(conn.config); + } + + const createClient = await import("@libsql/client/node").then((m) => m.createClient); + if (!createClient) { + throw new Error('libsql client not found, you need to install "@libsql/client/node"'); + } + + console.log("Using in-memory database"); + return new LibsqlConnection(createClient({ url: ":memory:" })); +} + +export function serve(_config: Partial = {}, distPath?: string) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); + let app: App; return async (req: Request) => { if (!app) { - app = App.create(config); + const connection = await getConnection(_config.connection); + app = App.create({ + ..._config, + connection + }); app.emgr.on( "app-built", @@ -20,7 +42,7 @@ export function serve(config: CreateAppConfig, distPath?: string) { root }) ); - app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8")); + app.registerAdminController(); }, "sync" ); @@ -28,6 +50,6 @@ export function serve(config: CreateAppConfig, distPath?: string) { await app.build(); } - return app.modules.server.fetch(req); + return app.fetch(req); }; } diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 8798bbe..a7cb1a4 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -113,11 +113,10 @@ async function getFresh(config: BkndConfig, { env, html }: Context) { "sync" ); } - await app.build(); - if (config?.setAdminHtml !== false) { - app.module.server.setAdminHtml(html); + if (config.setAdminHtml) { + app.registerAdminController({ html }); } return app; @@ -147,6 +146,7 @@ async function getCached( await cache.delete(key); return c.json({ message: "Cache cleared" }); }); + app.registerAdminController({ html }); config.onBuilt!(app); }, @@ -163,13 +163,13 @@ async function getCached( ); await app.build(); - if (!cachedConfig) { - saveConfig(app.toJSON(true)); + + if (config.setAdminHtml) { + app.registerAdminController({ html }); } - //addAssetsRoute(app, manifest); - if (config?.setAdminHtml !== false) { - app.module.server.setAdminHtml(html); + if (!cachedConfig) { + saveConfig(app.toJSON(true)); } return app; @@ -212,10 +212,6 @@ export class DurableBkndApp extends DurableObject { colo: context.colo }); }); - - if (options?.setAdminHtml !== false) { - app.module.server.setAdminHtml(options.html); - } }, "sync" ); diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts new file mode 100644 index 0000000..ef19c09 --- /dev/null +++ b/app/src/adapter/node/index.ts @@ -0,0 +1,82 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { serve as honoServe } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { App, type CreateAppConfig } from "bknd"; +import { LibsqlConnection } from "bknd/data"; +import type { Manifest } from "vite"; + +async function getConnection(conn?: CreateAppConfig["connection"]) { + if (conn) { + if (LibsqlConnection.isConnection(conn)) { + return conn; + } + + return new LibsqlConnection(conn.config); + } + + const createClient = await import("@libsql/client/node").then((m) => m.createClient); + if (!createClient) { + throw new Error('libsql client not found, you need to install "@libsql/client/node"'); + } + + console.log("Using in-memory database"); + return new LibsqlConnection(createClient({ url: ":memory:" })); +} + +export type NodeAdapterOptions = { + relativeDistPath?: string; + viteManifest?: Manifest; + port?: number; + hostname?: string; + listener?: Parameters[1]; +}; + +export function serve(_config: Partial = {}, options: NodeAdapterOptions = {}) { + const root = path.relative( + process.cwd(), + path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static") + ); + let app: App; + + honoServe( + { + port: options.port ?? 1337, + hostname: options.hostname, + fetch: async (req: Request) => { + if (!app) { + const connection = await getConnection(_config.connection); + app = App.create({ + ..._config, + connection + }); + + const viteManifest = + options.viteManifest ?? + JSON.parse(await readFile(path.resolve(root, ".vite/manifest.json"), "utf-8")); + + app.emgr.on( + "app-built", + async () => { + app.modules.server.get( + "/assets/*", + serveStatic({ + root + }) + ); + app.registerAdminController({ + viteManifest + }); + }, + "sync" + ); + + await app.build(); + } + + return app.fetch(req); + } + }, + options.listener + ); +} diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index 16b5393..6d7b3cf 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -31,7 +31,7 @@ function setAppBuildListener(app: App, config: BkndConfig, html: string) { "app-built", async () => { await config.onBuilt?.(app); - app.module.server.setAdminHtml(html); + app.registerAdminController(); app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); }, "sync" diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index 6b6ef0d..df7ffb0 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -34,7 +34,7 @@ export class AuthApi extends ModuleApi { } async strategies() { - return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]); + return this.get>(["strategies"]); } async logout() {} diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b9eb02f..a616bd0 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,31 +1,29 @@ import type { AppAuth } from "auth"; import type { ClassController } from "core"; import { Hono, type MiddlewareHandler } from "hono"; +import * as SystemPermissions from "modules/permissions"; export class AuthController implements ClassController { constructor(private auth: AppAuth) {} - getMiddleware: MiddlewareHandler = async (c, next) => { - // @todo: consider adding app name to the payload, because user is not refetched + get guard() { + return this.auth.ctx.guard; + } - //try { + getMiddleware: MiddlewareHandler = async (c, next) => { + let token: string | undefined; if (c.req.raw.headers.has("Authorization")) { const bearerHeader = String(c.req.header("Authorization")); - const token = bearerHeader.replace("Bearer ", ""); - const verified = await this.auth.authenticator.verify(token); + token = bearerHeader.replace("Bearer ", ""); + } + if (token) { // @todo: don't extract user from token, but from the database or cache + await this.auth.authenticator.verify(token); this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser()); - /*console.log("jwt verified?", { - verified, - auth: this.auth.authenticator.isUserLoggedIn() - });*/ } else { this.auth.authenticator.__setUserNull(); } - /* } catch (e) { - this.auth.authenticator.__setUserNull(); - }*/ await next(); }; @@ -49,7 +47,8 @@ export class AuthController implements ClassController { }); hono.get("/strategies", async (c) => { - return c.json({ strategies: this.auth.toJSON(false).strategies }); + const { strategies, basepath } = this.auth.toJSON(false); + return c.json({ strategies, basepath }); }); return hono; diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index caae555..f40348d 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -11,6 +11,8 @@ export type GuardConfig = { enabled?: boolean; }; +const debug = false; + export class Guard { permissions: Permission[]; user?: GuardUserContext; @@ -96,12 +98,12 @@ export class Guard { if (this.user && typeof this.user.role === "string") { const role = this.roles?.find((role) => role.name === this.user?.role); if (role) { - console.log("guard: role found", this.user.role); + debug && console.log("guard: role found", this.user.role); return role; } } - console.log("guard: role not found", this.user, this.user?.role); + debug && console.log("guard: role not found", this.user, this.user?.role); return this.getDefaultRole(); } @@ -109,10 +111,14 @@ export class Guard { return this.roles?.find((role) => role.is_default); } + isEnabled() { + return this.config?.enabled === true; + } + hasPermission(permission: Permission): boolean; hasPermission(name: string): boolean; hasPermission(permissionOrName: Permission | string): boolean { - if (this.config?.enabled !== true) { + if (!this.isEnabled()) { //console.log("guard not enabled, allowing"); return true; } @@ -126,10 +132,10 @@ export class Guard { const role = this.getUserRole(); if (!role) { - console.log("guard: role not found, denying"); + debug && console.log("guard: role not found, denying"); return false; } else if (role.implicit_allow === true) { - console.log("guard: role implicit allow, allowing"); + debug && console.log("guard: role implicit allow, allowing"); return true; } @@ -137,11 +143,12 @@ export class Guard { (rolePermission) => rolePermission.permission.name === name ); - console.log("guard: rolePermission, allowing?", { - permission: name, - role: role.name, - allowing: !!rolePermission - }); + debug && + console.log("guard: rolePermission, allowing?", { + permission: name, + role: role.name, + allowing: !!rolePermission + }); return !!rolePermission; } diff --git a/app/src/cli/commands/config.ts b/app/src/cli/commands/config.ts index 461ee78..3d853ab 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -7,6 +7,7 @@ export const config: CliCommand = (program) => { .description("get default config") .option("--pretty", "pretty print") .action((options) => { - console.log(getDefaultConfig(options.pretty)); + const config = getDefaultConfig(); + console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); }); }; diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 14730f0..dbb5cad 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -1,9 +1,9 @@ import type { Config } from "@libsql/client/node"; import { App } from "App"; import type { BkndConfig } from "adapter"; +import type { CliCommand } from "cli/types"; import { Option } from "commander"; import type { Connection } from "data"; -import type { CliCommand } from "../../types"; import { PLATFORMS, type Platform, @@ -48,14 +48,13 @@ type MakeAppConfig = { }; async function makeApp(config: MakeAppConfig) { - const html = await getHtml(); const app = new App(config.connection); app.emgr.on( "app-built", async () => { await attachServeStatic(app, config.server?.platform ?? "node"); - app.module.server.setAdminHtml(html); + app.registerAdminController({ html: await getHtml() }); if (config.onBuilt) { await config.onBuilt(app); @@ -70,14 +69,13 @@ async function makeApp(config: MakeAppConfig) { export async function makeConfigApp(config: BkndConfig, platform?: Platform) { const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app; - const html = await getHtml(); const app = App.create(appConfig); app.emgr.on( "app-built", async () => { await attachServeStatic(app, platform ?? "node"); - app.module.server.setAdminHtml(html); + app.registerAdminController({ html: await getHtml() }); if (config.onBuilt) { await config.onBuilt(app); diff --git a/app/src/cli/commands/schema.ts b/app/src/cli/commands/schema.ts index 8c59d7e..13c1c1e 100644 --- a/app/src/cli/commands/schema.ts +++ b/app/src/cli/commands/schema.ts @@ -7,6 +7,7 @@ export const schema: CliCommand = (program) => { .description("get schema") .option("--pretty", "pretty print") .action((options) => { - console.log(getDefaultSchema(options.pretty)); + const schema = getDefaultSchema(); + console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); }); }; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 418e8ae..3585b16 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -15,7 +15,7 @@ import { import { Hono } from "hono"; import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; -import { AppData } from "../AppData"; +import * as SystemPermissions from "modules/permissions"; import { type AppDataConfig, FIELDS } from "../data-schema"; export class DataController implements ClassController { @@ -89,12 +89,10 @@ export class DataController implements ClassController { return func; } - // add timing - /*hono.use("*", async (c, next) => { - startTime(c, "data"); + hono.use("*", async (c, next) => { + this.ctx.guard.throwUnlessGranted(SystemPermissions.api); await next(); - endTime(c, "data"); - });*/ + }); // info hono.get( diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index b3f7e10..bc97ff0 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -42,6 +42,7 @@ export type DbFunctions = { }; export abstract class Connection { + cls = "bknd:connection"; kysely: Kysely; constructor( @@ -52,6 +53,15 @@ export abstract class Connection { this.kysely = kysely; } + /** + * This is a helper function to manage Connection classes + * coming from different places + * @param conn + */ + static isConnection(conn: any): conn is Connection { + return conn?.cls === "bknd:connection"; + } + getIntrospector(): ConnectionIntrospector { return this.kysely.introspection as ConnectionIntrospector; } diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 353d3a9..674d7e2 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -36,7 +36,7 @@ export class EntityManager { relations.forEach((relation) => this.addRelation(relation)); indices.forEach((index) => this.addIndex(index)); - if (!(connection instanceof Connection)) { + if (!Connection.isConnection(connection)) { throw new UnableToConnectException(""); } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 85c6ea5..51a1768 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -425,19 +425,19 @@ export class ModuleManager { } } -export function getDefaultSchema(pretty = false) { +export function getDefaultSchema() { const schema = { type: "object", ...transformObject(MODULES, (module) => module.prototype.getSchema()) }; - return JSON.stringify(schema, null, pretty ? 2 : undefined); + return schema as any; } -export function getDefaultConfig(pretty = false): ModuleConfigs { +export function getDefaultConfig(): ModuleConfigs { const config = transformObject(MODULES, (module) => { return Default(module.prototype.getSchema(), {}); }); - return JSON.stringify(config, null, pretty ? 2 : undefined) as any; + return config as any; } diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 8b9cb9b..820b3ec 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,5 +1,7 @@ import { Permission } from "core"; +export const admin = new Permission("system.admin"); +export const api = new Permission("system.api"); export const configRead = new Permission("system.config.read"); export const configReadSecrets = new Permission("system.config.read.secrets"); export const configWrite = new Permission("system.config.write"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx new file mode 100644 index 0000000..e600d9d --- /dev/null +++ b/app/src/modules/server/AdminController.tsx @@ -0,0 +1,104 @@ +/** @jsxImportSource hono/jsx */ + +import type { App } from "App"; +import { type ClassController, isDebug } from "core"; +import { Hono } from "hono"; +import { html, raw } from "hono/html"; +import { Fragment } from "hono/jsx"; +import * as SystemPermissions from "modules/permissions"; +import type { Manifest } from "vite"; + +const viteInject = ` +import RefreshRuntime from "/@react-refresh" +RefreshRuntime.injectIntoGlobalHook(window) +window.$RefreshReg$ = () => {} +window.$RefreshSig$ = () => (type) => type +window.__vite_plugin_react_preamble_installed__ = true +`; + +export type AdminControllerOptions = { + html?: string; + viteManifest?: Manifest; +}; + +export class AdminController implements ClassController { + constructor( + private readonly app: App, + private options: AdminControllerOptions = {} + ) {} + + get ctx() { + return this.app.modules.ctx(); + } + + getController(): Hono { + const hono = new Hono(); + const configs = this.app.modules.configs(); + const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/"); + + this.ctx.server.get(basepath + "*", async (c) => { + if (this.options.html) { + return c.html(this.options.html); + } + + // @todo: implement guard redirect once cookie sessions arrive + + const isProd = !isDebug(); + let script: string | undefined; + let css: string[] = []; + + if (isProd) { + const manifest: Manifest = this.options.viteManifest + ? this.options.viteManifest + : isProd + ? // @ts-ignore cases issues when building types + await import("bknd/dist/manifest.json", { assert: { type: "json" } }).then( + (m) => m.default + ) + : {}; + //console.log("manifest", manifest, manifest["index.html"]); + const entry = Object.values(manifest).find((f: any) => f.isEntry === true); + if (!entry) { + // do something smart + return; + } + + script = "/" + entry.file; + css = entry.css?.map((c: string) => "/" + c) ?? []; + } + + return c.html( + + + + + BKND + {isProd ? ( + + - -` - ); -} - -function createApp(config: BkndConfig, env: any) { - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - return App.create(create_config as CreateAppConfig); -} - -function setAppBuildListener(app: App, config: BkndConfig, html: string) { - app.emgr.on( - "app-built", - async () => { - await config.onBuilt?.(app as any); - app.module.server.setAdminHtml(html); - app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); - }, - "sync" - ); -} - -export async function serveFresh(config: BkndConfig, _html?: string) { - let html = _html; - if (!html) { - html = await getHtml(); - } - - html = addViteScripts(html); - - return { - async fetch(request: Request, env: any) { - const app = createApp(config, env); - - setAppBuildListener(app, config, html); - await app.build(); - - return app.fetch(request, env); - } - }; -} - registries.media.add("local", { cls: StorageLocalAdapter, schema: StorageLocalAdapter.prototype.getSchema() @@ -72,11 +16,35 @@ const connection = new LibsqlConnection( }) ); -const app = await serveFresh({ +function createApp(config: BkndConfig, env: any) { + const create_config = typeof config.app === "function" ? config.app(env) : config.app; + return App.create(create_config as CreateAppConfig); +} + +export async function serveFresh(config: BkndConfig) { + return { + async fetch(request: Request, env: any) { + const app = createApp(config, env); + + app.emgr.on( + "app-built", + async () => { + await config.onBuilt?.(app as any); + app.registerAdminController(); + app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); + }, + "sync" + ); + await app.build(); + + return app.fetch(request, env); + } + }; +} + +export default await serveFresh({ app: { connection }, setAdminHtml: true }); - -export default app;