>,
+ ) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
@@ -117,56 +126,216 @@ export class Guard {
return this.config?.enabled === true;
}
- hasPermission(permission: Permission, user?: GuardUserContext): boolean;
- hasPermission(name: string, user?: GuardUserContext): boolean;
- hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
- if (!this.isEnabled()) {
- return true;
- }
-
- const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
- $console.debug("guard: checking permission", {
- name,
- user: { id: user?.id, role: user?.role },
- });
- const exists = this.permissionExists(name);
- if (!exists) {
- throw new Error(`Permission ${name} does not exist`);
- }
-
- const role = this.getUserRole(user);
-
- if (!role) {
- $console.debug("guard: user has no role, denying");
- return false;
- } else if (role.implicit_allow === true) {
- $console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
- return true;
- }
-
- const rolePermission = role.permissions.find(
- (rolePermission) => rolePermission.permission.name === name,
- );
-
- $console.debug("guard: rolePermission, allowing?", {
- permission: name,
- role: role.name,
- allowing: !!rolePermission,
- });
- return !!rolePermission;
- }
-
- granted(permission: Permission | string, c?: GuardContext): boolean {
+ private collect(permission: Permission, c: GuardContext | undefined, context: any) {
const user = c && "get" in c ? c.get("auth")?.user : c;
- return this.hasPermission(permission as any, user);
+ const ctx = {
+ ...((context ?? {}) as any),
+ ...this.config?.context,
+ user,
+ };
+ const exists = this.permissionExists(permission.name);
+ const role = this.getUserRole(user);
+ const rolePermission = role?.permissions.find(
+ (rolePermission) => rolePermission.permission.name === permission.name,
+ );
+ return {
+ ctx,
+ user,
+ exists,
+ role,
+ rolePermission,
+ };
}
- throwUnlessGranted(permission: Permission | string, c: GuardContext) {
- if (!this.granted(permission, c)) {
- throw new Exception(
- `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
- 403,
+ granted>(
+ permission: P,
+ c: GuardContext,
+ context: PermissionContext
,
+ ): void;
+ granted
>(permission: P, c: GuardContext): void;
+ granted
>(
+ permission: P,
+ c: GuardContext,
+ context?: PermissionContext
,
+ ): void {
+ if (!this.isEnabled()) {
+ return;
+ }
+ const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
+
+ // validate context
+ let ctx = Object.assign({}, _ctx);
+ if (permission.context) {
+ ctx = permission.parseContext(ctx);
+ }
+
+ $console.debug("guard: checking permission", {
+ name: permission.name,
+ context: ctx,
+ });
+ if (!exists) {
+ throw new GuardPermissionsException(
+ permission,
+ undefined,
+ `Permission ${permission.name} does not exist`,
);
}
+
+ if (!role) {
+ throw new GuardPermissionsException(permission, undefined, "User has no role");
+ }
+
+ if (!rolePermission) {
+ if (role.implicit_allow === true) {
+ $console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
+ return;
+ }
+
+ throw new GuardPermissionsException(
+ permission,
+ undefined,
+ `Role "${role.name}" does not have required permission`,
+ );
+ }
+
+ if (rolePermission?.policies.length > 0) {
+ $console.debug("guard: rolePermission has policies, checking");
+
+ // set the default effect of the role permission
+ let allowed = rolePermission.effect === "allow";
+ for (const policy of rolePermission.policies) {
+ $console.debug("guard: checking policy", { policy: policy.toJSON(), ctx });
+ // skip filter policies
+ if (policy.content.effect === "filter") continue;
+
+ // if condition is met, check the effect
+ const meets = policy.meetsCondition(ctx);
+ if (meets) {
+ $console.debug("guard: policy meets condition");
+ // if deny, then break early
+ if (policy.content.effect === "deny") {
+ $console.debug("guard: policy is deny, setting allowed to false");
+ allowed = false;
+ break;
+
+ // if allow, set allow but continue checking
+ } else if (policy.content.effect === "allow") {
+ allowed = true;
+ }
+ } else {
+ $console.debug("guard: policy does not meet condition");
+ }
+ }
+
+ if (!allowed) {
+ throw new GuardPermissionsException(permission, undefined, "Policy condition unmet");
+ }
+ }
+
+ $console.debug("guard allowing", {
+ permission: permission.name,
+ role: role.name,
+ });
+ }
+
+ filters
>(
+ permission: P,
+ c: GuardContext,
+ context: PermissionContext
,
+ );
+ filters
>(permission: P, c: GuardContext);
+ filters
>(
+ permission: P,
+ c: GuardContext,
+ context?: PermissionContext
,
+ ) {
+ if (!permission.isFilterable()) {
+ throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
+ }
+
+ const {
+ ctx: _ctx,
+ exists,
+ role,
+ user,
+ rolePermission,
+ } = this.collect(permission, c, context);
+
+ // validate context
+ let ctx = Object.assign(
+ {
+ user,
+ },
+ _ctx,
+ );
+
+ if (permission.context) {
+ ctx = permission.parseContext(ctx, {
+ coerceDropUnknown: false,
+ });
+ }
+
+ const filters: PolicySchema["filter"][] = [];
+ const policies: Policy[] = [];
+ if (exists && role && rolePermission && rolePermission.policies.length > 0) {
+ for (const policy of rolePermission.policies) {
+ if (policy.content.effect === "filter") {
+ const meets = policy.meetsCondition(ctx);
+ if (meets) {
+ policies.push(policy);
+ filters.push(policy.getReplacedFilter(ctx));
+ }
+ }
+ }
+ }
+
+ const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined;
+ return {
+ filters,
+ filter,
+ policies,
+ merge: (givenFilter: object | undefined) => {
+ return mergeFilters(givenFilter ?? {}, filter ?? {});
+ },
+ matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => {
+ const subjects = Array.isArray(subject) ? subject : [subject];
+ if (policies.length > 0) {
+ for (const policy of policies) {
+ for (const subject of subjects) {
+ if (!policy.meetsFilter(subject, ctx)) {
+ if (opts?.throwOnError) {
+ throw new GuardPermissionsException(
+ permission,
+ policy,
+ "Policy filter not met",
+ );
+ }
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+ },
+ };
}
}
+
+export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) {
+ const base_converted = convert(base);
+ const priority_converted = convert(priority);
+ const merged = mergeObject(base_converted, priority_converted);
+
+ // in case priority filter is also contained in base's $and, merge priority in
+ if ("$or" in base_converted && base_converted.$or) {
+ const $ors = base_converted.$or as ObjectQuery;
+ const priority_keys = Object.keys(priority_converted);
+ for (const key of priority_keys) {
+ if (key in $ors) {
+ merged.$or[key] = mergeObject($ors[key], priority_converted[key]);
+ }
+ }
+ }
+
+ return merged;
+}
diff --git a/app/src/auth/authorize/Permission.ts b/app/src/auth/authorize/Permission.ts
new file mode 100644
index 00000000..cfd59638
--- /dev/null
+++ b/app/src/auth/authorize/Permission.ts
@@ -0,0 +1,77 @@
+import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils";
+
+export const permissionOptionsSchema = s
+ .strictObject({
+ description: s.string(),
+ filterable: s.boolean(),
+ })
+ .partial();
+
+export type TPermission = {
+ name: string;
+ description?: string;
+ filterable?: boolean;
+ context?: any;
+};
+
+export type PermissionOptions = s.Static;
+export type PermissionContext> = P extends Permission<
+ any,
+ any,
+ infer Context,
+ any
+>
+ ? Context extends s.ObjectSchema
+ ? s.Static
+ : never
+ : never;
+
+export class InvalidPermissionContextError extends InvalidSchemaError {
+ override name = "InvalidPermissionContextError";
+
+ // changing to internal server error because it's an unexpected behavior
+ override code = HttpStatus.INTERNAL_SERVER_ERROR;
+
+ static from(e: InvalidSchemaError) {
+ return new InvalidPermissionContextError(e.schema, e.value, e.errors);
+ }
+}
+
+export class Permission<
+ Name extends string = string,
+ Options extends PermissionOptions = {},
+ Context extends s.ObjectSchema | undefined = undefined,
+ ContextValue = Context extends s.ObjectSchema ? s.Static : undefined,
+> {
+ constructor(
+ public name: Name,
+ public options: Options = {} as Options,
+ public context: Context = undefined as Context,
+ ) {}
+
+ isFilterable() {
+ return this.options.filterable === true;
+ }
+
+ parseContext(ctx: ContextValue, opts?: ParseOptions) {
+ // @todo: allow additional properties
+ if (!this.context) return ctx;
+ try {
+ return this.context ? parse(this.context!, ctx, opts) : undefined;
+ } catch (e) {
+ if (e instanceof InvalidSchemaError) {
+ throw InvalidPermissionContextError.from(e);
+ }
+
+ throw e;
+ }
+ }
+
+ toJSON() {
+ return {
+ name: this.name,
+ ...this.options,
+ context: this.context,
+ };
+ }
+}
diff --git a/app/src/auth/authorize/Policy.ts b/app/src/auth/authorize/Policy.ts
new file mode 100644
index 00000000..06357f17
--- /dev/null
+++ b/app/src/auth/authorize/Policy.ts
@@ -0,0 +1,52 @@
+import { s, parse, recursivelyReplacePlaceholders } from "bknd/utils";
+import * as query from "core/object/query/object-query";
+
+export const policySchema = s
+ .strictObject({
+ description: s.string(),
+ condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
+ // @todo: potentially remove this, and invert from rolePermission.effect
+ effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }),
+ filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
+ })
+ .partial();
+export type PolicySchema = s.Static;
+
+export class Policy {
+ public content: Schema;
+
+ constructor(content?: Schema) {
+ this.content = parse(policySchema, content ?? {}, {
+ withDefaults: true,
+ }) as Schema;
+ }
+
+ replace(context: object, vars?: Record, fallback?: any) {
+ return vars
+ ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars, fallback)
+ : context;
+ }
+
+ getReplacedFilter(context: object, fallback?: any) {
+ if (!this.content.filter) return context;
+ return this.replace(this.content.filter!, context, fallback);
+ }
+
+ meetsCondition(context: object, vars?: Record) {
+ if (!this.content.condition) return true;
+ return query.validate(this.replace(this.content.condition!, vars), context);
+ }
+
+ meetsFilter(subject: object, vars?: Record) {
+ if (!this.content.filter) return true;
+ return query.validate(this.replace(this.content.filter!, vars), subject);
+ }
+
+ getFiltered(given: Given): Given {
+ return given.filter((item) => this.meetsFilter(item)) as Given;
+ }
+
+ toJSON() {
+ return this.content;
+ }
+}
diff --git a/app/src/auth/authorize/Role.ts b/app/src/auth/authorize/Role.ts
index 54efaf12..7506fc78 100644
--- a/app/src/auth/authorize/Role.ts
+++ b/app/src/auth/authorize/Role.ts
@@ -1,10 +1,39 @@
-import { Permission } from "core/security/Permission";
+import { s } from "bknd/utils";
+import { Permission } from "./Permission";
+import { Policy, policySchema } from "./Policy";
+
+// default effect is allow for backward compatibility
+const defaultEffect = "allow";
+
+export const rolePermissionSchema = s.strictObject({
+ permission: s.string(),
+ effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(),
+ policies: s.array(policySchema).optional(),
+});
+export type RolePermissionSchema = s.Static;
+
+export const roleSchema = s.strictObject({
+ // @todo: remove anyOf, add migration
+ permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(),
+ is_default: s.boolean().optional(),
+ implicit_allow: s.boolean().optional(),
+});
+export type RoleSchema = s.Static;
export class RolePermission {
constructor(
- public permission: Permission,
- public config?: any,
+ public permission: Permission,
+ public policies: Policy[] = [],
+ public effect: "allow" | "deny" = defaultEffect,
) {}
+
+ toJSON() {
+ return {
+ permission: this.permission.name,
+ policies: this.policies.map((p) => p.toJSON()),
+ effect: this.effect,
+ };
+ }
}
export class Role {
@@ -15,31 +44,23 @@ export class Role {
public implicit_allow: boolean = false,
) {}
- static createWithPermissionNames(
- name: string,
- permissionNames: string[],
- is_default: boolean = false,
- implicit_allow: boolean = false,
- ) {
- return new Role(
- name,
- permissionNames.map((name) => new RolePermission(new Permission(name))),
- is_default,
- implicit_allow,
- );
+ static create(name: string, config: RoleSchema) {
+ const permissions =
+ config.permissions?.map((p: string | RolePermissionSchema) => {
+ if (typeof p === "string") {
+ return new RolePermission(new Permission(p), []);
+ }
+ const policies = p.policies?.map((policy) => new Policy(policy));
+ return new RolePermission(new Permission(p.permission), policies, p.effect);
+ }) ?? [];
+ return new Role(name, permissions, config.is_default, config.implicit_allow);
}
- static create(config: {
- name: string;
- permissions?: string[];
- is_default?: boolean;
- implicit_allow?: boolean;
- }) {
- return new Role(
- config.name,
- config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
- config.is_default,
- config.implicit_allow,
- );
+ toJSON() {
+ return {
+ permissions: this.permissions.map((p) => p.toJSON()),
+ is_default: this.is_default,
+ implicit_allow: this.implicit_allow,
+ };
}
}
diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares/auth.middleware.ts
similarity index 53%
rename from app/src/auth/middlewares.ts
rename to app/src/auth/middlewares/auth.middleware.ts
index 702023b9..eeebe454 100644
--- a/app/src/auth/middlewares.ts
+++ b/app/src/auth/middlewares/auth.middleware.ts
@@ -1,4 +1,3 @@
-import type { Permission } from "core/security/Permission";
import { $console, patternMatch } from "bknd/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
@@ -49,7 +48,7 @@ export const auth = (options?: {
// make sure to only register once
if (authCtx.registered) {
skipped = true;
- $console.warn(`auth middleware already registered for ${getPath(c)}`);
+ $console.debug(`auth middleware already registered for ${getPath(c)}`);
} else {
authCtx.registered = true;
@@ -67,48 +66,3 @@ export const auth = (options?: {
authCtx.resolved = false;
authCtx.user = undefined;
});
-
-export const permission = (
- permission: Permission | Permission[],
- options?: {
- onGranted?: (c: Context) => Promise;
- onDenied?: (c: Context) => Promise;
- },
-) =>
- // @ts-ignore
- createMiddleware(async (c, next) => {
- const app = c.get("app");
- const authCtx = c.get("auth");
- if (!authCtx) {
- throw new Error("auth ctx not found");
- }
-
- // in tests, app is not defined
- if (!authCtx.registered || !app) {
- const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
- if (app?.module.auth.enabled) {
- throw new Error(msg);
- } else {
- $console.warn(msg);
- }
- } else if (!authCtx.skip) {
- const guard = app.modules.ctx().guard;
- const permissions = Array.isArray(permission) ? permission : [permission];
-
- if (options?.onGranted || options?.onDenied) {
- let returned: undefined | void | Response;
- if (permissions.every((p) => guard.granted(p, c))) {
- returned = await options?.onGranted?.(c);
- } else {
- returned = await options?.onDenied?.(c);
- }
- if (returned instanceof Response) {
- return returned;
- }
- } else {
- permissions.some((p) => guard.throwUnlessGranted(p, c));
- }
- }
-
- await next();
- });
diff --git a/app/src/auth/middlewares/permission.middleware.ts b/app/src/auth/middlewares/permission.middleware.ts
new file mode 100644
index 00000000..c6e53f4e
--- /dev/null
+++ b/app/src/auth/middlewares/permission.middleware.ts
@@ -0,0 +1,94 @@
+import type { Permission, PermissionContext } from "auth/authorize/Permission";
+import { $console, threw } from "bknd/utils";
+import type { Context, Hono } from "hono";
+import type { RouterRoute } from "hono/types";
+import { createMiddleware } from "hono/factory";
+import type { ServerEnv } from "modules/Controller";
+import type { MaybePromise } from "core/types";
+import { GuardPermissionsException } from "auth/authorize/Guard";
+
+function getPath(reqOrCtx: Request | Context) {
+ const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
+ return new URL(req.url).pathname;
+}
+
+const permissionSymbol = Symbol.for("permission");
+
+type PermissionMiddlewareOptions> = {
+ onGranted?: (c: Context) => MaybePromise;
+ onDenied?: (c: Context) => MaybePromise;
+} & (P extends Permission
+ ? PC extends undefined
+ ? {
+ context?: never;
+ }
+ : {
+ context: (c: Context) => MaybePromise>;
+ }
+ : {
+ context?: never;
+ });
+
+export function permission>(
+ permission: P,
+ options: PermissionMiddlewareOptions
,
+) {
+ // @ts-ignore (middlewares do not always return)
+ const handler = createMiddleware(async (c, next) => {
+ const app = c.get("app");
+ const authCtx = c.get("auth");
+ if (!authCtx) {
+ throw new Error("auth ctx not found");
+ }
+
+ // in tests, app is not defined
+ if (!authCtx.registered || !app) {
+ const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
+ if (app?.module.auth.enabled) {
+ throw new Error(msg);
+ } else {
+ $console.warn(msg);
+ }
+ } else if (!authCtx.skip) {
+ const guard = app.modules.ctx().guard;
+ const context = (await options?.context?.(c)) ?? ({} as any);
+
+ if (options?.onGranted || options?.onDenied) {
+ let returned: undefined | void | Response;
+ if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) {
+ returned = await options?.onDenied?.(c);
+ } else {
+ returned = await options?.onGranted?.(c);
+ }
+ if (returned instanceof Response) {
+ return returned;
+ }
+ } else {
+ guard.granted(permission, c, context);
+ }
+ }
+
+ await next();
+ });
+
+ return Object.assign(handler, {
+ [permissionSymbol]: { permission, options },
+ });
+}
+
+export function getPermissionRoutes(hono: Hono) {
+ const routes: {
+ route: RouterRoute;
+ permission: Permission;
+ options: PermissionMiddlewareOptions;
+ }[] = [];
+ for (const route of hono.routes) {
+ if (permissionSymbol in route.handler) {
+ routes.push({
+ route,
+ ...(route.handler[permissionSymbol] as any),
+ });
+ }
+ }
+ return routes;
+}
diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts
index 217b07dc..3fca3b2b 100644
--- a/app/src/cli/commands/create/create.ts
+++ b/app/src/cli/commands/create/create.ts
@@ -10,6 +10,7 @@ import color from "picocolors";
import { overridePackageJson, updateBkndPackages } from "./npm";
import { type Template, templates, type TemplateSetupCtx } from "./templates";
import { createScoped, flush } from "cli/utils/telemetry";
+import path from "node:path";
const config = {
types: {
@@ -20,6 +21,7 @@ const config = {
node: "Node.js",
bun: "Bun",
cloudflare: "Cloudflare",
+ deno: "Deno",
aws: "AWS Lambda",
},
framework: {
@@ -259,17 +261,19 @@ async function action(options: {
}
}
- // update package name
- await overridePackageJson(
- (pkg) => ({
- ...pkg,
- name: ctx.name,
- }),
- { dir: ctx.dir },
- );
- $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
+ // update package name if there is a package.json
+ if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) {
+ await overridePackageJson(
+ (pkg) => ({
+ ...pkg,
+ name: ctx.name,
+ }),
+ { dir: ctx.dir },
+ );
+ $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
+ }
- {
+ if (template.installDeps !== false) {
const install =
options.yes ??
(await $p.confirm({
diff --git a/app/src/cli/commands/create/npm.ts b/app/src/cli/commands/create/npm.ts
index 7722e1c9..964ee47f 100644
--- a/app/src/cli/commands/create/npm.ts
+++ b/app/src/cli/commands/create/npm.ts
@@ -93,17 +93,19 @@ export async function replacePackageJsonVersions(
}
export async function updateBkndPackages(dir?: string, map?: Record) {
- const versions = {
- bknd: await sysGetVersion(),
- ...(map ?? {}),
- };
- await replacePackageJsonVersions(
- async (pkg) => {
- if (pkg in versions) {
- return versions[pkg];
- }
- return;
- },
- { dir },
- );
+ try {
+ const versions = {
+ bknd: await sysGetVersion(),
+ ...(map ?? {}),
+ };
+ await replacePackageJsonVersions(
+ async (pkg) => {
+ if (pkg in versions) {
+ return versions[pkg];
+ }
+ return;
+ },
+ { dir },
+ );
+ } catch (e) {}
}
diff --git a/app/src/cli/commands/create/templates/deno.ts b/app/src/cli/commands/create/templates/deno.ts
new file mode 100644
index 00000000..eb17269a
--- /dev/null
+++ b/app/src/cli/commands/create/templates/deno.ts
@@ -0,0 +1,21 @@
+import { overrideJson } from "cli/commands/create/npm";
+import type { Template } from "cli/commands/create/templates";
+import { getVersion } from "cli/utils/sys";
+
+export const deno = {
+ key: "deno",
+ title: "Deno Basic",
+ integration: "deno",
+ description: "A basic bknd Deno server with static assets",
+ path: "gh:bknd-io/bknd/examples/deno",
+ installDeps: false,
+ ref: true,
+ setup: async (ctx) => {
+ const version = await getVersion();
+ await overrideJson(
+ "deno.json",
+ (json) => ({ ...json, links: undefined, imports: { bknd: `npm:bknd@${version}` } }),
+ { dir: ctx.dir },
+ );
+ },
+} satisfies Template;
diff --git a/app/src/cli/commands/create/templates/index.ts b/app/src/cli/commands/create/templates/index.ts
index ed0f9e18..7aab8d51 100644
--- a/app/src/cli/commands/create/templates/index.ts
+++ b/app/src/cli/commands/create/templates/index.ts
@@ -1,3 +1,4 @@
+import { deno } from "cli/commands/create/templates/deno";
import { cloudflare } from "./cloudflare";
export type TemplateSetupCtx = {
@@ -15,6 +16,7 @@ export type Integration =
| "react-router"
| "astro"
| "aws"
+ | "deno"
| "custom";
type TemplateScripts = "install" | "dev" | "build" | "start";
@@ -34,6 +36,11 @@ export type Template = {
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
*/
ref?: true | string;
+ /**
+ * control whether to install dependencies automatically
+ * e.g. on deno, this is not needed
+ */
+ installDeps?: boolean;
scripts?: Partial>;
preinstall?: (ctx: TemplateSetupCtx) => Promise;
postinstall?: (ctx: TemplateSetupCtx) => Promise;
@@ -90,4 +97,5 @@ export const templates: Template[] = [
path: "gh:bknd-io/bknd/examples/aws-lambda",
ref: true,
},
+ deno,
];
diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts
index 154d50c1..dbae9f96 100644
--- a/app/src/cli/commands/run/run.ts
+++ b/app/src/cli/commands/run/run.ts
@@ -110,7 +110,10 @@ export async function makeAppFromEnv(options: Partial = {}) {
// try to use an in-memory connection
} else if (options.memory) {
console.info("Using", c.cyan("in-memory"), "connection");
- app = await makeApp({ server: { platform: options.server } });
+ app = await makeApp({
+ server: { platform: options.server },
+ connection: { url: ":memory:" },
+ });
// finally try to use env variables
} else {
diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts
index 3721a2bc..726748ba 100644
--- a/app/src/cli/commands/user.ts
+++ b/app/src/cli/commands/user.ts
@@ -3,6 +3,7 @@ import {
log as $log,
password as $password,
text as $text,
+ select as $select,
} from "@clack/prompts";
import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies";
@@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig
server: "node",
});
+ if (!app.module.auth.enabled) {
+ $log.error("Auth is not enabled");
+ process.exit(1);
+ }
+
switch (action) {
case "create":
await create(app, options);
@@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig
}
async function create(app: App, options: any) {
- const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
+ const auth = app.module.auth;
+ let role: string | null = null;
+ const roles = Object.keys(auth.config.roles ?? {});
+
+ const strategy = auth.authenticator.strategy("password") as PasswordStrategy;
+ if (roles.length > 0) {
+ role = (await $select({
+ message: "Select role",
+ options: [
+ {
+ value: null,
+ label: "",
+ hint: "No role will be assigned to the user",
+ },
+ ...roles.map((role) => ({
+ value: role,
+ label: role,
+ })),
+ ],
+ })) as any;
+ if ($isCancel(role)) process.exit(1);
+ }
if (!strategy) {
$log.error("Password strategy not configured");
@@ -76,6 +103,7 @@ async function create(app: App, options: any) {
const created = await app.createUser({
email,
password: await strategy.hash(password as string),
+ role,
});
$log.success(`Created user: ${c.cyan(created.email)}`);
process.exit(0);
diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts
index 8370f2bd..1ac8bc41 100644
--- a/app/src/core/events/EventManager.ts
+++ b/app/src/core/events/EventManager.ts
@@ -205,7 +205,17 @@ export class EventManager<
if (listener.mode === "sync") {
syncs.push(listener);
} else {
- asyncs.push(async () => await listener.handler(event, listener.event.slug));
+ asyncs.push(async () => {
+ try {
+ await listener.handler(event, listener.event.slug);
+ } catch (e) {
+ if (this.options?.onError) {
+ this.options.onError(event, e);
+ } else {
+ $console.error("Error executing async listener", listener, e);
+ }
+ }
+ });
}
// Remove if `once` is true, otherwise keep
return !listener.once;
diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts
index e90921d2..110cce6c 100644
--- a/app/src/core/object/query/query.ts
+++ b/app/src/core/object/query/query.ts
@@ -1,4 +1,5 @@
import type { PrimaryFieldType } from "core/config";
+import { getPath, invariant, isPlainObject } from "bknd/utils";
export type Primitive = PrimaryFieldType | string | number | boolean;
export function isPrimitive(value: any): value is Primitive {
@@ -25,6 +26,10 @@ export function exp(
valid: (v: Expect) => boolean,
validate: (e: Expect, a: unknown, ctx: CTX) => any,
): Expression {
+ invariant(typeof key === "string", "key must be a string");
+ invariant(key[0] === "$", "key must start with '$'");
+ invariant(typeof valid === "function", "valid must be a function");
+ invariant(typeof validate === "function", "validate must be a function");
return new Expression(key, valid, validate);
}
@@ -50,7 +55,7 @@ function getExpression(
}
type LiteralExpressionCondition = {
- [key: string]: Primitive | ExpressionCondition;
+ [key: string]: undefined | Primitive | ExpressionCondition;
};
const OperandOr = "$or" as const;
@@ -67,8 +72,9 @@ function _convert(
expressions: Exps,
path: string[] = [],
): FilterQuery {
+ invariant(typeof $query === "object", "$query must be an object");
const ExpressionConditionKeys = expressions.map((e) => e.key);
- const keys = Object.keys($query);
+ const keys = Object.keys($query ?? {});
const operands = [OperandOr] as const;
const newQuery: FilterQuery = {};
@@ -83,13 +89,21 @@ function _convert(
function validate(key: string, value: any, path: string[] = []) {
const exp = getExpression(expressions, key as any);
if (exp.valid(value) === false) {
- throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
+ throw new Error(
+ `Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`,
+ );
}
}
for (const [key, value] of Object.entries($query)) {
+ // skip undefined values
+ if (value === undefined) {
+ continue;
+ }
+
// if $or, convert each value
if (key === "$or") {
+ invariant(isPlainObject(value), "$or must be an object");
newQuery.$or = _convert(value, expressions, [...path, key]);
// if primitive, assume $eq
@@ -98,7 +112,7 @@ function _convert(
newQuery[key] = { $eq: value };
// if object, check for expressions
- } else if (typeof value === "object") {
+ } else if (isPlainObject(value)) {
// when object is given, check if all keys are expressions
const invalid = Object.keys(value).filter(
(f) => !ExpressionConditionKeys.includes(f as any),
@@ -112,9 +126,13 @@ function _convert(
}
} else {
throw new Error(
- `Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`,
+ `Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expression key: ${ExpressionConditionKeys.join(", ")}.`,
);
}
+ } else {
+ throw new Error(
+ `Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`,
+ );
}
}
@@ -149,15 +167,19 @@ function _build(
throw new Error(`Expression does not exist: "${$op}"`);
}
if (!exp.valid(expected)) {
- throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
+ throw new Error(
+ `Invalid value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`,
+ );
}
return exp.validate(expected, actual, options.exp_ctx);
}
// check $and
for (const [key, value] of Object.entries($and)) {
+ if (value === undefined) continue;
+
for (const [$op, $v] of Object.entries(value)) {
- const objValue = options.value_is_kv ? key : options.object[key];
+ const objValue = options.value_is_kv ? key : getPath(options.object, key);
result.$and.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
@@ -165,7 +187,7 @@ function _build(
// check $or
for (const [key, value] of Object.entries($or ?? {})) {
- const objValue = options.value_is_kv ? key : options.object[key];
+ const objValue = options.value_is_kv ? key : getPath(options.object, key);
for (const [$op, $v] of Object.entries(value)) {
result.$or.push(__validate($op, $v, objValue, [key]));
@@ -189,6 +211,10 @@ function _validate(results: ValidationResults): boolean {
}
export function makeValidator(expressions: Exps) {
+ if (!expressions.some((e) => e.key === "$eq")) {
+ throw new Error("'$eq' expression is required");
+ }
+
return {
convert: (query: FilterQuery) => _convert(query, expressions),
build: (query: FilterQuery, options: BuildOptions) =>
diff --git a/app/src/core/security/Permission.ts b/app/src/core/security/Permission.ts
deleted file mode 100644
index 86cf46b1..00000000
--- a/app/src/core/security/Permission.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export class Permission {
- constructor(public name: Name) {
- this.name = name;
- }
-
- toJSON() {
- return {
- name: this.name,
- };
- }
-}
diff --git a/app/src/core/types.ts b/app/src/core/types.ts
index 03beae5d..c0550dbe 100644
--- a/app/src/core/types.ts
+++ b/app/src/core/types.ts
@@ -6,3 +6,7 @@ export interface Serializable {
export type MaybePromise = T | Promise;
export type PartialRec = { [P in keyof T]?: PartialRec };
+
+export type Merge = {
+ [K in keyof T]: T[K];
+};
diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts
index 8e812cf9..a2093c01 100644
--- a/app/src/core/utils/file.ts
+++ b/app/src/core/utils/file.ts
@@ -240,3 +240,46 @@ export async function blobToFile(
lastModified: Date.now(),
});
}
+
+export function isFileAccepted(file: File | unknown, _accept: string | string[]): boolean {
+ const accept = Array.isArray(_accept) ? _accept.join(",") : _accept;
+ if (!accept || !accept.trim()) return true; // no restrictions
+ if (!isFile(file)) {
+ throw new Error("Given file is not a File instance");
+ }
+
+ const name = file.name.toLowerCase();
+ const type = (file.type || "").trim().toLowerCase();
+
+ // split on commas, trim whitespace
+ const tokens = accept
+ .split(",")
+ .map((t) => t.trim().toLowerCase())
+ .filter(Boolean);
+
+ // try each token until one matches
+ return tokens.some((token) => {
+ if (token.startsWith(".")) {
+ // extension match, e.g. ".png" or ".tar.gz"
+ return name.endsWith(token);
+ }
+
+ const slashIdx = token.indexOf("/");
+ if (slashIdx !== -1) {
+ const [major, minor] = token.split("/");
+ if (minor === "*") {
+ // wildcard like "image/*"
+ if (!type) return false;
+ const [fMajor] = type.split("/");
+ return fMajor === major;
+ } else {
+ // exact MIME like "image/svg+xml" or "application/pdf"
+ // because of "text/plain;charset=utf-8"
+ return type.startsWith(token);
+ }
+ }
+
+ // unknown token shape, ignore
+ return false;
+ });
+}
diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts
index 41902a92..33c6a436 100644
--- a/app/src/core/utils/objects.ts
+++ b/app/src/core/utils/objects.ts
@@ -372,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean {
export function getPath(
object: object,
_path: string | (string | number)[],
- defaultValue = undefined,
+ defaultValue: any = undefined,
): any {
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
@@ -512,3 +512,43 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
}
return obj;
}
+
+export function recursivelyReplacePlaceholders(
+ obj: any,
+ pattern: RegExp,
+ variables: Record,
+ fallback?: any,
+) {
+ if (typeof obj === "string") {
+ // check if the entire string matches the pattern
+ const match = obj.match(pattern);
+ if (match && match[0] === obj && match[1]) {
+ // full string match - replace with the actual value (preserving type)
+ const key = match[1];
+ const value = getPath(variables, key, null);
+ return value !== null ? value : fallback !== undefined ? fallback : obj;
+ }
+ // partial match - use string replacement
+ if (pattern.test(obj)) {
+ return obj.replace(pattern, (match, key) => {
+ const value = getPath(variables, key, null);
+ // convert to string for partial replacements
+ return value !== null
+ ? String(value)
+ : fallback !== undefined
+ ? String(fallback)
+ : match;
+ });
+ }
+ }
+ if (Array.isArray(obj)) {
+ return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback));
+ }
+ if (obj && typeof obj === "object") {
+ return Object.entries(obj).reduce((acc, [key, value]) => {
+ acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
+ return acc;
+ }, {} as object);
+ }
+ return obj;
+}
diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts
index 0772abd2..5b943ff4 100644
--- a/app/src/core/utils/runtime.ts
+++ b/app/src/core/utils/runtime.ts
@@ -61,3 +61,19 @@ export function invariant(condition: boolean | any, message: string) {
throw new Error(message);
}
}
+
+export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
+ try {
+ fn();
+ return false;
+ } catch (e) {
+ if (instance) {
+ if (e instanceof instance) {
+ return true;
+ }
+ // if instance given but not what expected, throw
+ throw e;
+ }
+ return true;
+ }
+}
diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts
index 3d3692c7..ff8190c0 100644
--- a/app/src/core/utils/schema/index.ts
+++ b/app/src/core/utils/schema/index.ts
@@ -1,3 +1,5 @@
+import { Exception } from "core/errors";
+import { HttpStatus } from "bknd/utils";
import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono";
@@ -58,7 +60,10 @@ export const stringIdentifier = s.string({
maxLength: 150,
});
-export class InvalidSchemaError extends Error {
+export class InvalidSchemaError extends Exception {
+ override name = "InvalidSchemaError";
+ override code = HttpStatus.UNPROCESSABLE_ENTITY;
+
constructor(
public schema: s.Schema,
public value: unknown,
diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts
index 163f0aff..082ae0c9 100644
--- a/app/src/data/api/DataController.ts
+++ b/app/src/data/api/DataController.ts
@@ -15,6 +15,7 @@ import type { AppDataConfig } from "../data-schema";
import type { EntityManager, EntityData } from "data/entities";
import * as DataPermissions from "data/permissions";
import { repoQuery, type RepoQuery } from "data/server/query";
+import { EntityTypescript } from "data/entities/EntityTypescript";
export class DataController extends Controller {
constructor(
@@ -42,7 +43,7 @@ export class DataController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
- const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
+ const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {}));
const entitiesEnum = this.getEntitiesEnum(this.em);
// info
@@ -58,7 +59,7 @@ export class DataController extends Controller {
// sync endpoint
hono.get(
"/sync",
- permission(DataPermissions.databaseSync),
+ permission(DataPermissions.databaseSync, {}),
mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: {
@@ -95,7 +96,9 @@ export class DataController extends Controller {
// read entity schema
hono.get(
"/schema.json",
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
@@ -121,7 +124,9 @@ export class DataController extends Controller {
// read schema
hono.get(
"/schemas/:entity/:context?",
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
@@ -153,6 +158,22 @@ export class DataController extends Controller {
},
);
+ hono.get(
+ "/types",
+ permission(SystemPermissions.schemaRead, {
+ context: (c) => ({ module: "data" }),
+ }),
+ describeRoute({
+ summary: "Retrieve data typescript definitions",
+ tags: ["data"],
+ }),
+ mcpTool("data_types"),
+ async (c) => {
+ const et = new EntityTypescript(this.em);
+ return c.text(et.toString());
+ },
+ );
+
// entity endpoints
hono.route("/entity", this.getEntityRoutes());
@@ -161,7 +182,9 @@ export class DataController extends Controller {
*/
hono.get(
"/info/:entity",
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
@@ -213,7 +236,9 @@ export class DataController extends Controller {
// fn: count
hono.post(
"/:entity/fn/count",
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
describeRoute({
summary: "Count entities",
tags: ["data"],
@@ -236,7 +261,9 @@ export class DataController extends Controller {
// fn: exists
hono.post(
"/:entity/fn/exists",
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
describeRoute({
summary: "Check if entity exists",
tags: ["data"],
@@ -285,16 +312,26 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
- permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
+
+ const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
+ entity,
+ });
+
const options = c.req.valid("query") as RepoQuery;
- const result = await this.em.repository(entity).findMany(options);
+ const result = await this.em.repository(entity).findMany({
+ ...options,
+ where: merge(options.where),
+ });
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -308,7 +345,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
mcpTool("data_entity_read_one", {
inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }),
@@ -326,11 +365,19 @@ export class DataController extends Controller {
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id } = c.req.valid("param");
- if (!this.entityExists(entity)) {
+ if (!this.entityExists(entity) || !id) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
- const result = await this.em.repository(entity).findId(id, options);
+ const { merge } = this.ctx.guard.filters(
+ DataPermissions.entityRead,
+ c,
+ c.req.valid("param"),
+ );
+ const id_name = this.em.entity(entity).getPrimaryField().name;
+ const result = await this.em
+ .repository(entity)
+ .findOne(merge({ [id_name]: id }), options);
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -344,7 +391,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
jsc(
"param",
s.object({
@@ -361,9 +410,20 @@ export class DataController extends Controller {
}
const options = c.req.valid("query") as RepoQuery;
- const result = await this.em
+ const { entity: newEntity } = this.em
.repository(entity)
- .findManyByReference(id, reference, options);
+ .getEntityByReference(reference);
+
+ const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
+ entity: newEntity.name,
+ id,
+ reference,
+ });
+
+ const result = await this.em.repository(entity).findManyByReference(id, reference, {
+ ...options,
+ where: merge(options.where),
+ });
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -390,7 +450,9 @@ export class DataController extends Controller {
},
tags: ["data"],
}),
- permission(DataPermissions.entityRead),
+ permission(DataPermissions.entityRead, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
mcpTool("data_entity_read_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
@@ -405,7 +467,13 @@ export class DataController extends Controller {
return this.notFound(c);
}
const options = c.req.valid("json") as RepoQuery;
- const result = await this.em.repository(entity).findMany(options);
+ const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
+ entity,
+ });
+ const result = await this.em.repository(entity).findMany({
+ ...options,
+ where: merge(options.where),
+ });
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -421,7 +489,9 @@ export class DataController extends Controller {
summary: "Insert one or many",
tags: ["data"],
}),
- permission(DataPermissions.entityCreate),
+ permission(DataPermissions.entityCreate, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
@@ -438,6 +508,12 @@ export class DataController extends Controller {
// to transform all validation targets into a single object
const body = convertNumberedObjectToArray(_body);
+ this.ctx.guard
+ .filters(DataPermissions.entityCreate, c, {
+ entity,
+ })
+ .matches(body, { throwOnError: true });
+
if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body);
return c.json(result, 201);
@@ -455,7 +531,9 @@ export class DataController extends Controller {
summary: "Update many",
tags: ["data"],
}),
- permission(DataPermissions.entityUpdate),
+ permission(DataPermissions.entityUpdate, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
mcpTool("data_entity_update_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
@@ -482,7 +560,10 @@ export class DataController extends Controller {
update: EntityData;
where: RepoQuery["where"];
};
- const result = await this.em.mutator(entity).updateWhere(update, where);
+ const { merge } = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
+ entity,
+ });
+ const result = await this.em.mutator(entity).updateWhere(update, merge(where));
return c.json(result);
},
@@ -495,7 +576,9 @@ export class DataController extends Controller {
summary: "Update one",
tags: ["data"],
}),
- permission(DataPermissions.entityUpdate),
+ permission(DataPermissions.entityUpdate, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
@@ -505,6 +588,17 @@ export class DataController extends Controller {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData;
+ const fns = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
+ entity,
+ id,
+ });
+
+ // if it has filters attached, fetch entry and make the check
+ if (fns.filters.length > 0) {
+ const { data } = await this.em.repository(entity).findId(id);
+ fns.matches(data, { throwOnError: true });
+ }
+
const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(result);
@@ -518,7 +612,9 @@ export class DataController extends Controller {
summary: "Delete one",
tags: ["data"],
}),
- permission(DataPermissions.entityDelete),
+ permission(DataPermissions.entityDelete, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => {
@@ -526,6 +622,18 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) {
return this.notFound(c);
}
+
+ const fns = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
+ entity,
+ id,
+ });
+
+ // if it has filters attached, fetch entry and make the check
+ if (fns.filters.length > 0) {
+ const { data } = await this.em.repository(entity).findId(id);
+ fns.matches(data, { throwOnError: true });
+ }
+
const result = await this.em.mutator(entity).deleteOne(id);
return c.json(result);
@@ -539,7 +647,9 @@ export class DataController extends Controller {
summary: "Delete many",
tags: ["data"],
}),
- permission(DataPermissions.entityDelete),
+ permission(DataPermissions.entityDelete, {
+ context: (c) => ({ ...c.req.param() }) as any,
+ }),
mcpTool("data_entity_delete_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
@@ -554,7 +664,10 @@ export class DataController extends Controller {
return this.notFound(c);
}
const where = (await c.req.json()) as RepoQuery["where"];
- const result = await this.em.mutator(entity).deleteWhere(where);
+ const { merge } = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
+ entity,
+ });
+ const result = await this.em.mutator(entity).deleteWhere(merge(where));
return c.json(result);
},
diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts
index 36168f8b..033d51a5 100644
--- a/app/src/data/entities/EntityManager.ts
+++ b/app/src/data/entities/EntityManager.ts
@@ -34,7 +34,6 @@ export class EntityManager {
private _entities: Entity[] = [];
private _relations: EntityRelation[] = [];
private _indices: EntityIndex[] = [];
- private _schema?: SchemaManager;
readonly emgr: EventManager;
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
@@ -249,11 +248,7 @@ export class EntityManager {
}
schema() {
- if (!this._schema) {
- this._schema = new SchemaManager(this);
- }
-
- return this._schema;
+ return new SchemaManager(this);
}
// @todo: centralize and add tests
diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts
index 13554a6f..3d8f432c 100644
--- a/app/src/data/entities/query/Repository.ts
+++ b/app/src/data/entities/query/Repository.ts
@@ -1,4 +1,4 @@
-import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
+import type { DB as DefaultDB, EntityRelation, PrimaryFieldType } from "bknd";
import { $console } from "bknd/utils";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
@@ -280,16 +280,11 @@ export class Repository>,
): Promise> {
- const { qb, options } = this.buildQuery(
- {
- ..._options,
- where: { [this.entity.getPrimaryField().name]: id },
- limit: 1,
- },
- ["offset", "sort"],
- );
+ if (typeof id === "undefined" || id === null) {
+ throw new InvalidSearchParamsException("id is required");
+ }
- return this.single(qb, options) as any;
+ return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options);
}
async findOne(
@@ -315,23 +310,27 @@ export class Repository r.ref(reference).reference === reference);
+ if (!relation) {
+ throw new Error(
+ `Relation "${reference}" not found or not listable on entity "${this.entity.name}"`,
+ );
+ }
+ return {
+ entity: relation.other(this.entity).entity,
+ relation,
+ };
+ }
+
// @todo: add unit tests, specially for many to many
async findManyByReference(
id: PrimaryFieldType,
reference: string,
_options?: Partial>,
): Promise> {
- const entity = this.entity;
- const listable_relations = this.em.relations.listableRelationsOf(entity);
- const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
-
- if (!relation) {
- throw new Error(
- `Relation "${reference}" not found or not listable on entity "${entity.name}"`,
- );
- }
-
- const newEntity = relation.other(entity).entity;
+ const { entity: newEntity, relation } = this.getEntityByReference(reference);
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
throw new Error(
diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts
index c54854b9..8ed4802d 100644
--- a/app/src/data/fields/JsonField.ts
+++ b/app/src/data/fields/JsonField.ts
@@ -64,20 +64,27 @@ export class JsonField }[] = [];
for (const table of diff) {
- const qbs: { compile(): CompiledQuery; execute(): Promise }[] = [];
- let local_updates: number = 0;
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
const dropFields = table.columns.drop;
const dropIndices = table.indices.drop;
if (table.isDrop) {
- updates++;
- local_updates++;
if (config.drop) {
qbs.push(schema.dropTable(table.name));
}
@@ -269,8 +265,6 @@ export class SchemaManager {
let createQb = schema.createTable(table.name);
// add fields
for (const fieldSchema of addFieldSchemas) {
- updates++;
- local_updates++;
// @ts-ignore
createQb = createQb.addColumn(...fieldSchema);
}
@@ -281,8 +275,6 @@ export class SchemaManager {
if (addFieldSchemas.length > 0) {
// add fields
for (const fieldSchema of addFieldSchemas) {
- updates++;
- local_updates++;
// @ts-ignore
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
}
@@ -292,8 +284,6 @@ export class SchemaManager {
if (config.drop && dropFields.length > 0) {
// drop fields
for (const column of dropFields) {
- updates++;
- local_updates++;
qbs.push(schema.alterTable(table.name).dropColumn(column));
}
}
@@ -311,35 +301,33 @@ export class SchemaManager {
qb = qb.unique();
}
qbs.push(qb);
- local_updates++;
- updates++;
}
// drop indices
if (config.drop) {
for (const index of dropIndices) {
qbs.push(schema.dropIndex(index));
- local_updates++;
- updates++;
}
}
+ }
- if (local_updates === 0) continue;
+ if (qbs.length > 0) {
+ statements.push(
+ ...qbs.map((qb) => {
+ const { sql, parameters } = qb.compile();
+ return { sql, parameters };
+ }),
+ );
- // iterate through built qbs
- // @todo: run in batches
- for (const qb of qbs) {
- const { sql, parameters } = qb.compile();
- statements.push({ sql, parameters });
+ $console.debug(
+ "[SchemaManager]",
+ `${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
+ );
- if (config.force) {
- try {
- $console.debug("[SchemaManager]", sql);
- await qb.execute();
- } catch (e) {
- throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
- }
- }
+ try {
+ await this.em.connection.executeQueries(...qbs);
+ } catch (e) {
+ throw new Error(`Failed to execute batch: ${String(e)}`);
}
}
diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts
index 89585a34..eb2eb2b1 100644
--- a/app/src/data/server/query.spec.ts
+++ b/app/src/data/server/query.spec.ts
@@ -1,6 +1,8 @@
import { test, describe, expect } from "bun:test";
import * as q from "./query";
import { parse as $parse, type ParseOptions } from "bknd/utils";
+import type { PrimaryFieldType } from "modules";
+import type { Generated } from "kysely";
const parse = (v: unknown, o: ParseOptions = {}) =>
$parse(q.repoQuery, v, {
@@ -186,4 +188,35 @@ describe("server/query", () => {
decode({ with: { images: {}, comments: {} } }, output);
}
});
+
+ test("types", () => {
+ const id = 1 as PrimaryFieldType;
+ const id2 = "1" as unknown as Generated;
+
+ const c: q.RepoQueryIn = {
+ where: {
+ // @ts-expect-error only primitives are allowed for $eq
+ something: [],
+ // this gets ignored
+ another: undefined,
+ // @ts-expect-error null is not a valid value
+ null_is_okay: null,
+ some_id: id,
+ another_id: id2,
+ },
+ };
+
+ const d: q.RepoQuery = {
+ where: {
+ // @ts-expect-error only primitives are allowed for $eq
+ something: [],
+ // this gets ignored
+ another: undefined,
+ // @ts-expect-error null is not a valid value
+ null_is_okay: null,
+ some_id: id,
+ another_id: id2,
+ },
+ };
+ });
});
diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts
index cb4defeb..9a01e2a8 100644
--- a/app/src/data/server/query.ts
+++ b/app/src/data/server/query.ts
@@ -84,8 +84,6 @@ const where = s.anyOf([s.string(), s.object({})], {
return WhereBuilder.convert(q);
},
});
-//type WhereSchemaIn = s.Static;
-//type WhereSchema = s.StaticCoerced;
// ------
// with
@@ -128,7 +126,7 @@ const withSchema = (self: s.Schema): s.Schema<{}, Type, Type> =>
}
}
- return value as unknown as any;
+ return value as any;
},
}) as any;
@@ -167,15 +165,3 @@ export type RepoQueryIn = {
export type RepoQuery = s.StaticCoerced & {
sort: SortSchema;
};
-
-//export type RepoQuery = s.StaticCoerced;
-// @todo: CURRENT WORKAROUND
-/* export type RepoQuery = {
- limit?: number;
- offset?: number;
- sort?: { by: string; dir: "asc" | "desc" };
- select?: string[];
- with?: Record;
- join?: string[];
- where?: WhereQuery;
-}; */
diff --git a/app/src/index.ts b/app/src/index.ts
index ae011517..e30af8a9 100644
--- a/app/src/index.ts
+++ b/app/src/index.ts
@@ -41,15 +41,16 @@ export { getSystemMcp } from "modules/mcp/system-mcp";
/**
* Core
*/
-export type { MaybePromise } from "core/types";
+export type { MaybePromise, Merge } from "core/types";
export { Exception, BkndError } from "core/errors";
export { isDebug, env } from "core/env";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";
-export { Permission } from "core/security/Permission";
+export { Permission } from "auth/authorize/Permission";
export { getFlashMessage } from "core/server/flash";
export * from "core/drivers";
export { Event, InvalidEventReturn } from "core/events/Event";
export type {
+ EventListener,
ListenerMode,
ListenerHandler,
} from "core/events/EventListener";
diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts
index 2c1b6b24..ff2caddb 100644
--- a/app/src/media/AppMedia.ts
+++ b/app/src/media/AppMedia.ts
@@ -22,6 +22,9 @@ declare module "bknd" {
// @todo: current workaround to make it all required
export class AppMedia extends Module> {
private _storage?: Storage;
+ options = {
+ body_max_size: null as number | null,
+ };
override async build() {
if (!this.config.enabled) {
diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts
index 6a720485..0523b6ac 100644
--- a/app/src/media/api/MediaController.ts
+++ b/app/src/media/api/MediaController.ts
@@ -36,7 +36,7 @@ export class MediaController extends Controller {
summary: "Get the list of files",
tags: ["media"],
}),
- permission(MediaPermissions.listFiles),
+ permission(MediaPermissions.listFiles, {}),
async (c) => {
const files = await this.getStorageAdapter().listObjects();
return c.json(files);
@@ -51,7 +51,7 @@ export class MediaController extends Controller {
summary: "Get a file by name",
tags: ["media"],
}),
- permission(MediaPermissions.readFile),
+ permission(MediaPermissions.readFile, {}),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
@@ -81,7 +81,7 @@ export class MediaController extends Controller {
summary: "Delete a file by name",
tags: ["media"],
}),
- permission(MediaPermissions.deleteFile),
+ permission(MediaPermissions.deleteFile, {}),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
@@ -93,7 +93,10 @@ export class MediaController extends Controller {
},
);
- const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
+ const maxSize =
+ this.media.options.body_max_size ??
+ this.getStorage().getConfig().body_max_size ??
+ Number.POSITIVE_INFINITY;
if (isDebug()) {
hono.post(
@@ -146,7 +149,7 @@ export class MediaController extends Controller {
requestBody,
}),
jsc("param", s.object({ filename: s.string().optional() })),
- permission(MediaPermissions.uploadFile),
+ permission(MediaPermissions.uploadFile, {}),
async (c) => {
const reqname = c.req.param("filename");
@@ -186,7 +189,10 @@ export class MediaController extends Controller {
}),
),
jsc("query", s.object({ overwrite: s.boolean().optional() })),
- permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
+ permission(DataPermissions.entityCreate, {
+ context: (c) => ({ entity: c.req.param("entity") }),
+ }),
+ permission(MediaPermissions.uploadFile, {}),
async (c) => {
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts
index 527ce28c..0ae00178 100644
--- a/app/src/media/media-permissions.ts
+++ b/app/src/media/media-permissions.ts
@@ -1,4 +1,4 @@
-import { Permission } from "core/security/Permission";
+import { Permission } from "auth/authorize/Permission";
export const readFile = new Permission("media.file.read");
export const listFiles = new Permission("media.file.list");
diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts
index 4e71d83a..eaa2b8d5 100644
--- a/app/src/media/media-schema.ts
+++ b/app/src/media/media-schema.ts
@@ -48,7 +48,7 @@ export function buildMediaSchema() {
{
default: {},
},
- );
+ ).strict();
}
export const mediaConfigSchema = buildMediaSchema();
diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts
new file mode 100644
index 00000000..30e4dc32
--- /dev/null
+++ b/app/src/modes/code.ts
@@ -0,0 +1,49 @@
+import type { BkndConfig } from "bknd/adapter";
+import { makeModeConfig, type BkndModeConfig } from "./shared";
+import { $console } from "bknd/utils";
+
+export type BkndCodeModeConfig = BkndModeConfig;
+
+export type CodeMode = AdapterConfig extends BkndConfig<
+ infer Args
+>
+ ? BkndModeConfig
+ : never;
+
+export function code(config: BkndCodeModeConfig): BkndConfig {
+ return {
+ ...config,
+ app: async (args) => {
+ const {
+ config: appConfig,
+ plugins,
+ isProd,
+ syncSchemaOptions,
+ } = await makeModeConfig(config, args);
+
+ if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
+ $console.warn("You should not set a different mode than `db` when using code mode");
+ }
+
+ return {
+ ...appConfig,
+ options: {
+ ...appConfig?.options,
+ mode: "code",
+ plugins,
+ manager: {
+ // skip validation in prod for a speed boost
+ skipValidation: isProd,
+ onModulesBuilt: async (ctx) => {
+ if (!isProd && syncSchemaOptions.force) {
+ $console.log("[code] syncing schema");
+ await ctx.em.schema().sync(syncSchemaOptions);
+ }
+ },
+ ...appConfig?.options?.manager,
+ },
+ },
+ };
+ },
+ };
+}
diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts
new file mode 100644
index 00000000..7a8022b2
--- /dev/null
+++ b/app/src/modes/hybrid.ts
@@ -0,0 +1,89 @@
+import type { BkndConfig } from "bknd/adapter";
+import { makeModeConfig, type BkndModeConfig } from "./shared";
+import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd";
+import type { DbModuleManager } from "modules/db/DbModuleManager";
+import { invariant, $console } from "bknd/utils";
+
+export type BkndHybridModeOptions = {
+ /**
+ * Reader function to read the configuration from the file system.
+ * This is required for hybrid mode to work.
+ */
+ reader?: (path: string) => MaybePromise;
+ /**
+ * Provided secrets to be merged into the configuration
+ */
+ secrets?: Record;
+};
+
+export type HybridBkndConfig = BkndModeConfig;
+export type HybridMode = AdapterConfig extends BkndConfig<
+ infer Args
+>
+ ? BkndModeConfig>
+ : never;
+
+export function hybrid({
+ configFilePath = "bknd-config.json",
+ ...rest
+}: HybridBkndConfig): BkndConfig {
+ return {
+ ...rest,
+ config: undefined,
+ app: async (args) => {
+ const {
+ config: appConfig,
+ isProd,
+ plugins,
+ syncSchemaOptions,
+ } = await makeModeConfig(
+ {
+ ...rest,
+ configFilePath,
+ },
+ args,
+ );
+
+ if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") {
+ $console.warn("You should not set a different mode than `db` when using hybrid mode");
+ }
+ invariant(
+ typeof appConfig.reader === "function",
+ "You must set the `reader` option when using hybrid mode",
+ );
+
+ let fileConfig: ModuleConfigs;
+ try {
+ fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs;
+ } catch (e) {
+ const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs;
+ await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2));
+ fileConfig = defaultConfig;
+ }
+
+ return {
+ ...(appConfig as any),
+ beforeBuild: async (app) => {
+ if (app && !isProd) {
+ const mm = app.modules as DbModuleManager;
+ mm.buildSyncConfig = syncSchemaOptions;
+ }
+ await appConfig.beforeBuild?.(app);
+ },
+ config: fileConfig,
+ options: {
+ ...appConfig?.options,
+ mode: isProd ? "code" : "db",
+ plugins,
+ manager: {
+ // skip validation in prod for a speed boost
+ skipValidation: isProd,
+ // secrets are required for hybrid mode
+ secrets: appConfig.secrets,
+ ...appConfig?.options?.manager,
+ },
+ },
+ };
+ },
+ };
+}
diff --git a/app/src/modes/index.ts b/app/src/modes/index.ts
new file mode 100644
index 00000000..b0536711
--- /dev/null
+++ b/app/src/modes/index.ts
@@ -0,0 +1,3 @@
+export * from "./code";
+export * from "./hybrid";
+export * from "./shared";
diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts
new file mode 100644
index 00000000..f1bc4ff7
--- /dev/null
+++ b/app/src/modes/shared.ts
@@ -0,0 +1,183 @@
+import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
+import { syncTypes, syncConfig } from "bknd/plugins";
+import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
+import { invariant, $console } from "bknd/utils";
+
+export type BkndModeOptions = {
+ /**
+ * Whether the application is running in production.
+ */
+ isProduction?: boolean;
+ /**
+ * Writer function to write the configuration to the file system
+ */
+ writer?: (path: string, content: string) => MaybePromise;
+ /**
+ * Configuration file path
+ */
+ configFilePath?: string;
+ /**
+ * Types file path
+ * @default "bknd-types.d.ts"
+ */
+ typesFilePath?: string;
+ /**
+ * Syncing secrets options
+ */
+ syncSecrets?: {
+ /**
+ * Whether to enable syncing secrets
+ */
+ enabled?: boolean;
+ /**
+ * Output file path
+ */
+ outFile?: string;
+ /**
+ * Format of the output file
+ * @default "env"
+ */
+ format?: "json" | "env";
+ /**
+ * Whether to include secrets in the output file
+ * @default false
+ */
+ includeSecrets?: boolean;
+ };
+ /**
+ * Determines whether to automatically sync the schema if not in production.
+ * @default true
+ */
+ syncSchema?: boolean | { force?: boolean; drop?: boolean };
+};
+
+export type BkndModeConfig = BkndConfig<
+ Args,
+ Merge
+>;
+
+export async function makeModeConfig<
+ Args = any,
+ Config extends BkndModeConfig = BkndModeConfig,
+>({ app, ..._config }: Config, args: Args) {
+ const appConfig = typeof app === "function" ? await app(args) : app;
+
+ const config = {
+ ..._config,
+ ...appConfig,
+ } as Omit;
+
+ if (typeof config.isProduction !== "boolean") {
+ $console.warn(
+ "You should set `isProduction` option when using managed modes to prevent accidental issues",
+ );
+ }
+
+ invariant(
+ typeof config.writer === "function",
+ "You must set the `writer` option when using managed modes",
+ );
+
+ const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
+
+ const isProd = config.isProduction;
+ const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
+ const syncSchemaOptions =
+ typeof config.syncSchema === "object"
+ ? config.syncSchema
+ : {
+ force: config.syncSchema !== false,
+ drop: true,
+ };
+
+ if (!isProd) {
+ if (typesFilePath) {
+ if (plugins.some((p) => p.name === "bknd-sync-types")) {
+ throw new Error("You have to unregister the `syncTypes` plugin");
+ }
+ plugins.push(
+ syncTypes({
+ enabled: true,
+ includeFirstBoot: true,
+ write: async (et) => {
+ try {
+ await config.writer?.(typesFilePath, et.toString());
+ } catch (e) {
+ console.error(`Error writing types to"${typesFilePath}"`, e);
+ }
+ },
+ }) as any,
+ );
+ }
+
+ if (configFilePath) {
+ if (plugins.some((p) => p.name === "bknd-sync-config")) {
+ throw new Error("You have to unregister the `syncConfig` plugin");
+ }
+ plugins.push(
+ syncConfig({
+ enabled: true,
+ includeFirstBoot: true,
+ write: async (config) => {
+ try {
+ await writer?.(configFilePath, JSON.stringify(config, null, 2));
+ } catch (e) {
+ console.error(`Error writing config to "${configFilePath}"`, e);
+ }
+ },
+ }) as any,
+ );
+ }
+
+ if (syncSecretsOptions && syncSecretsOptions.enabled !== false) {
+ if (plugins.some((p) => p.name === "bknd-sync-secrets")) {
+ throw new Error("You have to unregister the `syncSecrets` plugin");
+ }
+
+ let outFile = syncSecretsOptions.outFile;
+ const format = syncSecretsOptions.format ?? "env";
+ if (!outFile) {
+ outFile = ["env", !syncSecretsOptions.includeSecrets && "example", format]
+ .filter(Boolean)
+ .join(".");
+ }
+
+ plugins.push(
+ syncSecrets({
+ enabled: true,
+ includeFirstBoot: true,
+ write: async (secrets) => {
+ const values = Object.fromEntries(
+ Object.entries(secrets).map(([key, value]) => [
+ key,
+ syncSecretsOptions.includeSecrets ? value : "",
+ ]),
+ );
+
+ try {
+ if (format === "env") {
+ await writer?.(
+ outFile,
+ Object.entries(values)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("\n"),
+ );
+ } else {
+ await writer?.(outFile, JSON.stringify(values, null, 2));
+ }
+ } catch (e) {
+ console.error(`Error writing secrets to "${outFile}"`, e);
+ }
+ },
+ }) as any,
+ );
+ }
+ }
+
+ return {
+ config,
+ isProd,
+ plugins,
+ syncSchemaOptions,
+ };
+}
diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts
index f89fb999..9b9ebb7c 100644
--- a/app/src/modules/ModuleApi.ts
+++ b/app/src/modules/ModuleApi.ts
@@ -8,6 +8,7 @@ export type BaseModuleApiOptions = {
host: string;
basepath?: string;
token?: string;
+ credentials?: RequestCredentials;
headers?: Headers;
token_transport?: "header" | "cookie" | "none";
verbose?: boolean;
@@ -106,6 +107,7 @@ export abstract class ModuleApi>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
- ) {
+ permission: P,
+ context: PermissionContext,
+ ): Promise;
+ async granted>(
+ c: { context: ModuleBuildContextMcpContext; raw?: unknown },
+ permission: P,
+ ): Promise;
+ async granted>(
+ c: { context: ModuleBuildContextMcpContext; raw?: unknown },
+ permission: P,
+ context?: PermissionContext
,
+ ): Promise {
invariant(c.context.app, "app is not available in mcp context");
const auth = c.context.app.module.auth;
if (!auth.enabled) return;
@@ -127,12 +137,6 @@ export class ModuleHelper {
}
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
-
- if (!this.ctx.guard.granted(permission, user)) {
- throw new Exception(
- `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
- 403,
- );
- }
+ this.ctx.guard.granted(permission, user as any, context as any);
}
}
diff --git a/app/src/modules/SystemApi.ts b/app/src/modules/SystemApi.ts
index dc2e5c64..ab26baeb 100644
--- a/app/src/modules/SystemApi.ts
+++ b/app/src/modules/SystemApi.ts
@@ -1,6 +1,7 @@
import type { ConfigUpdateResponse } from "modules/server/SystemController";
import { ModuleApi } from "./ModuleApi";
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
+import type { TPermission } from "auth/authorize/Permission";
export type ApiSchemaResponse = {
version: number;
@@ -54,4 +55,8 @@ export class SystemApi extends ModuleApi {
removeConfig(module: Module, path: string) {
return this.delete(["config", "remove", module, path]);
}
+
+ permissions() {
+ return this.get<{ permissions: TPermission[]; context: object }>("permissions");
+ }
}
diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts
index a7bc903d..8af95e82 100644
--- a/app/src/modules/db/DbModuleManager.ts
+++ b/app/src/modules/db/DbModuleManager.ts
@@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager {
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
+ // config used when syncing database
+ public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true };
+
constructor(connection: Connection, options?: Partial) {
let initial = {} as InitialModuleConfigs;
let booted_with = "partial" as any;
@@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager {
const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.configs.json, {
- db: this.db
+ db: this.db,
});
this._version = _version;
@@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager {
this.logger.log("db sync requested");
// sync db
- await ctx.em.schema().sync({ force: true });
+ await ctx.em.schema().sync(this.buildSyncConfig);
state.synced = true;
// save
diff --git a/app/src/modules/middlewares/index.ts b/app/src/modules/middlewares/index.ts
index be1ad591..213eb7e9 100644
--- a/app/src/modules/middlewares/index.ts
+++ b/app/src/modules/middlewares/index.ts
@@ -1 +1,2 @@
-export { auth, permission } from "auth/middlewares";
+export { auth } from "auth/middlewares/auth.middleware";
+export { permission } from "auth/middlewares/permission.middleware";
diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts
index b6fbead8..152072d4 100644
--- a/app/src/modules/permissions/index.ts
+++ b/app/src/modules/permissions/index.ts
@@ -1,10 +1,35 @@
-import { Permission } from "core/security/Permission";
+import { Permission } from "auth/authorize/Permission";
+import { s } from "bknd/utils";
export const accessAdmin = new Permission("system.access.admin");
export const accessApi = new Permission("system.access.api");
-export const configRead = new Permission("system.config.read");
-export const configReadSecrets = new Permission("system.config.read.secrets");
-export const configWrite = new Permission("system.config.write");
-export const schemaRead = new Permission("system.schema.read");
+export const configRead = new Permission(
+ "system.config.read",
+ {},
+ s.object({
+ module: s.string().optional(),
+ }),
+);
+export const configReadSecrets = new Permission(
+ "system.config.read.secrets",
+ {},
+ s.object({
+ module: s.string().optional(),
+ }),
+);
+export const configWrite = new Permission(
+ "system.config.write",
+ {},
+ s.object({
+ module: s.string().optional(),
+ }),
+);
+export const schemaRead = new Permission(
+ "system.schema.read",
+ {},
+ s.object({
+ module: s.string().optional(),
+ }),
+);
export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp");
diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx
index 28007813..e0981016 100644
--- a/app/src/modules/server/AdminController.tsx
+++ b/app/src/modules/server/AdminController.tsx
@@ -114,8 +114,9 @@ export class AdminController extends Controller {
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
- addFlashMessage(c, "You not allowed to read the schema", "warning");
+ addFlashMessage(c, "You are not allowed to read the schema", "warning");
},
+ context: (c) => ({}),
}),
async (c) => {
const obj: AdminBkndWindowContext = {
@@ -139,17 +140,19 @@ export class AdminController extends Controller {
}
if (auth_enabled) {
+ const options = {
+ onGranted: async (c) => {
+ // @todo: add strict test to permissions middleware?
+ if (c.get("auth")?.user) {
+ $console.log("redirecting to success");
+ return c.redirect(authRoutes.success);
+ }
+ },
+ context: (c) => ({}),
+ };
const redirectRouteParams = [
- permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
- // @ts-ignore
- onGranted: async (c) => {
- // @todo: add strict test to permissions middleware?
- if (c.get("auth")?.user) {
- $console.log("redirecting to success");
- return c.redirect(authRoutes.success);
- }
- },
- }),
+ permission(SystemPermissions.accessAdmin, options as any),
+ permission(SystemPermissions.schemaRead, options),
async (c) => {
return c.html(c.get("html")!);
},
diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts
index b9fb531c..94343094 100644
--- a/app/src/modules/server/AppServer.ts
+++ b/app/src/modules/server/AppServer.ts
@@ -52,11 +52,16 @@ export class AppServer extends Module {
}
override async build() {
- const origin = this.config.cors.origin ?? "";
+ const origin = this.config.cors.origin ?? "*";
+ const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin];
+ const all_origins = origins.includes("*");
this.client.use(
"*",
cors({
- origin: origin.includes(",") ? origin.split(",").map((o) => o.trim()) : origin,
+ origin: (origin: string) => {
+ if (all_origins) return origin;
+ return origins.includes(origin) ? origin : undefined;
+ },
allowMethods: this.config.cors.allow_methods,
allowHeaders: this.config.cors.allow_headers,
credentials: this.config.cors.allow_credentials,
@@ -87,6 +92,10 @@ export class AppServer extends Module {
}
if (err instanceof AuthException) {
+ if (isDebug()) {
+ return c.json(err.toJSON(), err.code);
+ }
+
return c.json(err.toJSON(), err.getSafeErrorAndCode().code);
}
diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts
index 93533a29..3ae6cd27 100644
--- a/app/src/modules/server/SystemController.ts
+++ b/app/src/modules/server/SystemController.ts
@@ -17,6 +17,7 @@ import {
mcp as mcpMiddleware,
isNode,
type McpServer,
+ threw,
} from "bknd/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
@@ -32,6 +33,7 @@ import { getVersion } from "core/env";
import type { Module } from "modules/Module";
import { getSystemMcp } from "modules/mcp/system-mcp";
import type { DbModuleManager } from "modules/db/DbModuleManager";
+import type { TPermission } from "auth/authorize/Permission";
export type ConfigUpdate = {
success: true;
@@ -46,7 +48,8 @@ export type SchemaResponse = {
schema: ModuleSchemas;
readonly: boolean;
config: ModuleConfigs;
- permissions: string[];
+ //permissions: string[];
+ permissions: TPermission[];
};
export class SystemController extends Controller {
@@ -67,10 +70,14 @@ export class SystemController extends Controller {
if (!config.mcp.enabled) {
return;
}
+ const { permission, auth } = this.middlewares;
this.registerMcp();
- app.server.use(
+ app.server.all(
+ config.mcp.path,
+ auth(),
+ permission(SystemPermissions.mcp, {}),
mcpMiddleware({
setup: async () => {
if (!this._mcpServer) {
@@ -108,7 +115,6 @@ export class SystemController extends Controller {
explainEndpoint: true,
},
endpoint: {
- path: config.mcp.path as any,
// @ts-ignore
_init: isNode() ? { duplex: "half" } : {},
},
@@ -119,7 +125,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
- const hono = this.create().use(permission(SystemPermissions.configRead));
+ const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager;
@@ -130,7 +136,11 @@ export class SystemController extends Controller {
summary: "Get the raw config",
tags: ["system"],
}),
- permission([SystemPermissions.configReadSecrets]),
+ permission(SystemPermissions.configReadSecrets, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch().then((r) => r?.configs));
@@ -165,7 +175,11 @@ export class SystemController extends Controller {
hono.post(
"/set/:module",
- permission(SystemPermissions.configWrite),
+ permission(SystemPermissions.configWrite, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => {
const module = c.req.param("module") as any;
@@ -194,32 +208,44 @@ export class SystemController extends Controller {
},
);
- hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
- // @todo: require auth (admin)
- const module = c.req.param("module") as any;
- const value = await c.req.json();
- const path = c.req.param("path") as string;
+ hono.post(
+ "/add/:module/:path",
+ permission(SystemPermissions.configWrite, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
+ async (c) => {
+ // @todo: require auth (admin)
+ const module = c.req.param("module") as any;
+ const value = await c.req.json();
+ const path = c.req.param("path") as string;
- if (this.app.modules.get(module).schema().has(path)) {
- return c.json(
- { success: false, path, error: "Path already exists" },
- { status: 400 },
- );
- }
+ if (this.app.modules.get(module).schema().has(path)) {
+ return c.json(
+ { success: false, path, error: "Path already exists" },
+ { status: 400 },
+ );
+ }
- return await handleConfigUpdateResponse(c, async () => {
- await manager.mutateConfigSafe(module).patch(path, value);
- return {
- success: true,
- module,
- config: this.app.module[module].config,
- };
- });
- });
+ return await handleConfigUpdateResponse(c, async () => {
+ await manager.mutateConfigSafe(module).patch(path, value);
+ return {
+ success: true,
+ module,
+ config: this.app.module[module].config,
+ };
+ });
+ },
+ );
hono.patch(
"/patch/:module/:path",
- permission(SystemPermissions.configWrite),
+ permission(SystemPermissions.configWrite, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
@@ -239,7 +265,11 @@ export class SystemController extends Controller {
hono.put(
"/overwrite/:module/:path",
- permission(SystemPermissions.configWrite),
+ permission(SystemPermissions.configWrite, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
@@ -259,7 +289,11 @@ export class SystemController extends Controller {
hono.delete(
"/remove/:module/:path",
- permission(SystemPermissions.configWrite),
+ permission(SystemPermissions.configWrite, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
@@ -295,7 +329,11 @@ export class SystemController extends Controller {
const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param");
- secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
+ if (secrets) {
+ this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
+ module,
+ });
+ }
const config = this.app.toJSON(secrets);
@@ -326,7 +364,11 @@ export class SystemController extends Controller {
summary: "Get the schema for a module",
tags: ["system"],
}),
- permission(SystemPermissions.schemaRead),
+ permission(SystemPermissions.schemaRead, {
+ context: (c) => ({
+ module: c.req.param("module"),
+ }),
+ }),
jsc(
"query",
s
@@ -340,10 +382,22 @@ export class SystemController extends Controller {
async (c) => {
const module = c.req.param("module") as ModuleKey | undefined;
const { config, secrets, fresh } = c.req.valid("query");
- const readonly = this.app.isReadOnly();
+ const readonly =
+ // either if app is read only in general
+ this.app.isReadOnly() ||
+ // or if user is not allowed to modify the config
+ threw(() => this.ctx.guard.granted(SystemPermissions.configWrite, c, { module }));
- config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
- secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
+ if (config) {
+ this.ctx.guard.granted(SystemPermissions.configRead, c, {
+ module,
+ });
+ }
+ if (secrets) {
+ this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
+ module,
+ });
+ }
const { version, ...schema } = this.app.getSchema();
@@ -368,11 +422,23 @@ export class SystemController extends Controller {
readonly,
schema,
config: config ? this.app.toJSON(secrets) : undefined,
- permissions: this.app.modules.ctx().guard.getPermissionNames(),
+ permissions: this.app.modules.ctx().guard.getPermissions(),
});
},
);
+ hono.get(
+ "/permissions",
+ describeRoute({
+ summary: "Get the permissions",
+ tags: ["system"],
+ }),
+ (c) => {
+ const permissions = this.app.modules.ctx().guard.getPermissions();
+ return c.json({ permissions, context: this.app.module.auth.getGuardContextSchema() });
+ },
+ );
+
hono.post(
"/build",
describeRoute({
@@ -383,7 +449,7 @@ export class SystemController extends Controller {
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record;
- this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
+ this.ctx.guard.granted(SystemPermissions.build, c);
await this.app.build(options);
return c.json({
@@ -455,7 +521,7 @@ export class SystemController extends Controller {
const { version, ...appConfig } = this.app.toJSON();
mcp.resource("system_config", "bknd://system/config", async (c) => {
- await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c);
+ await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {});
return c.json(this.app.toJSON(), {
title: "System Config",
@@ -465,7 +531,9 @@ export class SystemController extends Controller {
"system_config_module",
"bknd://system/config/{module}",
async (c, { module }) => {
- await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c);
+ await this.ctx.helper.granted(c, SystemPermissions.configRead, {
+ module,
+ });
const m = this.app.modules.get(module as any) as Module;
return c.json(m.toJSON(), {
@@ -477,7 +545,7 @@ export class SystemController extends Controller {
},
)
.resource("system_schema", "bknd://system/schema", async (c) => {
- await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
+ await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {});
return c.json(this.app.getSchema(), {
title: "System Schema",
@@ -487,7 +555,9 @@ export class SystemController extends Controller {
"system_schema_module",
"bknd://system/schema/{module}",
async (c, { module }) => {
- await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
+ await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {
+ module,
+ });
const m = this.app.modules.get(module as any);
return c.json(m.getSchema().toJSON(), {
diff --git a/app/src/plugins/data/timestamp.plugin.spec.ts b/app/src/plugins/data/timestamp.plugin.spec.ts
new file mode 100644
index 00000000..bdf8811d
--- /dev/null
+++ b/app/src/plugins/data/timestamp.plugin.spec.ts
@@ -0,0 +1,74 @@
+import { describe, test, expect, beforeAll, afterAll } from "bun:test";
+import { timestamps } from "./timestamps.plugin";
+import { em, entity, text } from "bknd";
+import { createApp } from "core/test/utils";
+import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
+
+beforeAll(() => disableConsoleLog());
+afterAll(enableConsoleLog);
+
+describe("timestamps plugin", () => {
+ test("should ignore if no or invalid entities are provided", async () => {
+ const app = createApp({
+ options: {
+ plugins: [timestamps({ entities: [] })],
+ },
+ });
+ await app.build();
+ expect(app.em.entities.map((e) => e.name)).toEqual([]);
+
+ {
+ const app = createApp({
+ options: {
+ plugins: [timestamps({ entities: ["posts"] })],
+ },
+ });
+ await app.build();
+ expect(app.em.entities.map((e) => e.name)).toEqual([]);
+ }
+ });
+
+ test("should add timestamps to the specified entities", async () => {
+ const app = createApp({
+ config: {
+ data: em({
+ posts: entity("posts", {
+ title: text(),
+ }),
+ }).toJSON(),
+ },
+ options: {
+ plugins: [timestamps({ entities: ["posts", "invalid"] })],
+ },
+ });
+ await app.build();
+ expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]);
+ expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([
+ "id",
+ "title",
+ "created_at",
+ "updated_at",
+ ]);
+
+ // insert
+ const mutator = app.em.mutator(app.em.entity("posts"));
+ const { data } = await mutator.insertOne({ title: "Hello" });
+ expect(data.created_at).toBeDefined();
+ expect(data.updated_at).toBeDefined();
+ expect(data.created_at).toBeInstanceOf(Date);
+ expect(data.updated_at).toBeInstanceOf(Date);
+ const diff = data.created_at.getTime() - data.updated_at.getTime();
+ expect(diff).toBeLessThan(10);
+ expect(diff).toBeGreaterThan(-1);
+
+ // update (set updated_at to null, otherwise it's too fast to test)
+ await app.em.connection.kysely
+ .updateTable("posts")
+ .set({ updated_at: null })
+ .where("id", "=", data.id)
+ .execute();
+ const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" });
+ expect(updatedData.updated_at).toBeDefined();
+ expect(updatedData.updated_at).toBeInstanceOf(Date);
+ });
+});
diff --git a/app/src/plugins/data/timestamps.plugin.ts b/app/src/plugins/data/timestamps.plugin.ts
new file mode 100644
index 00000000..0de5a94e
--- /dev/null
+++ b/app/src/plugins/data/timestamps.plugin.ts
@@ -0,0 +1,86 @@
+import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd";
+import { $console } from "bknd/utils";
+
+export type TimestampsPluginOptions = {
+ entities: string[];
+ setUpdatedOnCreate?: boolean;
+};
+
+/**
+ * This plugin adds `created_at` and `updated_at` fields to the specified entities.
+ * Add it to your plugins in `bknd.config.ts` like this:
+ *
+ * ```ts
+ * export default {
+ * plugins: [timestamps({ entities: ["posts"] })],
+ * }
+ * ```
+ */
+export function timestamps({
+ entities = [],
+ setUpdatedOnCreate = true,
+}: TimestampsPluginOptions): AppPlugin {
+ return (app: App) => ({
+ name: "timestamps",
+ schema: () => {
+ if (entities.length === 0) {
+ $console.warn("No entities specified for timestamps plugin");
+ return;
+ }
+
+ const appEntities = app.em.entities.map((e) => e.name);
+
+ return em(
+ Object.fromEntries(
+ entities
+ .filter((e) => appEntities.includes(e))
+ .map((e) => [
+ e,
+ entity(e, {
+ created_at: datetime(),
+ updated_at: datetime(),
+ }),
+ ]),
+ ),
+ );
+ },
+ onBuilt: async () => {
+ app.emgr.onEvent(
+ DatabaseEvents.MutatorInsertBefore,
+ (event) => {
+ const { entity, data } = event.params;
+ if (entities.includes(entity.name)) {
+ return {
+ ...data,
+ created_at: new Date(),
+ updated_at: setUpdatedOnCreate ? new Date() : null,
+ };
+ }
+ return data;
+ },
+ {
+ mode: "sync",
+ id: "bknd-timestamps",
+ },
+ );
+
+ app.emgr.onEvent(
+ DatabaseEvents.MutatorUpdateBefore,
+ async (event) => {
+ const { entity, data } = event.params;
+ if (entities.includes(entity.name)) {
+ return {
+ ...data,
+ updated_at: new Date(),
+ };
+ }
+ return data;
+ },
+ {
+ mode: "sync",
+ id: "bknd-timestamps",
+ },
+ );
+ },
+ });
+}
diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts
index 45db2d59..b0090ffc 100644
--- a/app/src/plugins/index.ts
+++ b/app/src/plugins/index.ts
@@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
+export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx
index abb0020a..3ac5d5df 100644
--- a/app/src/ui/client/BkndProvider.tsx
+++ b/app/src/ui/client/BkndProvider.tsx
@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes";
import type { BkndAdminProps } from "ui/Admin";
+import type { TPermission } from "auth/authorize/Permission";
export type BkndContext = {
version: number;
readonly: boolean;
schema: ModuleSchemas;
config: ModuleConfigs;
- permissions: string[];
+ permissions: TPermission[];
hasSecrets: boolean;
requireSecrets: () => Promise;
actions: ReturnType;
@@ -122,11 +123,14 @@ export function BkndProvider({
fetching.current = Fetching.None;
};
- if ("startViewTransition" in document) {
+ // disable view transitions for now
+ // because it causes browser crash on heavy pages (e.g. schema)
+ commit();
+ /* if ("startViewTransition" in document) {
document.startViewTransition(commit);
} else {
commit();
- }
+ } */
});
}
diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx
index 13352d14..88a54c1e 100644
--- a/app/src/ui/client/ClientProvider.tsx
+++ b/app/src/ui/client/ClientProvider.tsx
@@ -53,9 +53,7 @@ export const ClientProvider = ({
[JSON.stringify(apiProps)],
);
- const [authState, setAuthState] = useState | undefined>(
- apiProps.user ? api.getAuthState() : undefined,
- );
+ const [authState, setAuthState] = useState | undefined>(api.getAuthState());
return (
diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts
index 6b6d5467..573b9900 100644
--- a/app/src/ui/client/api/use-api.ts
+++ b/app/src/ui/client/api/use-api.ts
@@ -1,6 +1,6 @@
import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
-import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
+import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite";
import { useApi } from "ui/client";
import { useState } from "react";
@@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => {
return mutate((k) => typeof k === "string" && k.startsWith(key));
};
};
+
+const mountOnceCache = new Map();
+
+/**
+ * Simple middleware to only load on first mount.
+ */
+export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
+ if (typeof key === "string") {
+ if (mountOnceCache.has(key)) {
+ return useSWRNext(key, fetcher, {
+ ...config,
+ revalidateOnMount: false,
+ });
+ }
+ const swr = useSWRNext(key, fetcher, config);
+ if (swr.data) {
+ mountOnceCache.set(key, true);
+ }
+ return swr;
+ }
+ return useSWRNext(key, fetcher, config);
+};
diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts
index e3fb4a6f..291c9636 100644
--- a/app/src/ui/client/schema/auth/use-auth.ts
+++ b/app/src/ui/client/schema/auth/use-auth.ts
@@ -16,8 +16,8 @@ type UseAuth = {
verified: boolean;
login: (data: LoginData) => Promise;
register: (data: LoginData) => Promise;
- logout: () => void;
- verify: () => void;
+ logout: () => Promise;
+ verify: () => Promise;
setToken: (token: string) => void;
};
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
}
async function logout() {
- api.updateToken(undefined);
- invalidate();
+ await api.auth.logout();
+ await invalidate();
}
async function verify() {
await api.verifyAuth();
+ await invalidate();
}
return {
diff --git a/app/src/ui/client/schema/auth/use-bknd-auth.ts b/app/src/ui/client/schema/auth/use-bknd-auth.ts
index b48d1e1d..7f83358f 100644
--- a/app/src/ui/client/schema/auth/use-bknd-auth.ts
+++ b/app/src/ui/client/schema/auth/use-bknd-auth.ts
@@ -49,7 +49,7 @@ export function useBkndAuth() {
has_admin: Object.entries(config.auth.roles ?? {}).some(
([name, role]) =>
role.implicit_allow ||
- minimum_permissions.every((p) => role.permissions?.includes(p)),
+ minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)),
),
},
routes: {
diff --git a/app/src/ui/components/buttons/Button.tsx b/app/src/ui/components/buttons/Button.tsx
index b80f0060..79ee3cc4 100644
--- a/app/src/ui/components/buttons/Button.tsx
+++ b/app/src/ui/components/buttons/Button.tsx
@@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link";
const sizes = {
+ smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs",
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
default: "px-3 py-2.5 rounded-md gap-1.5",
large: "px-4 py-3 rounded-md gap-2.5 text-lg",
};
const iconSizes = {
- small: 12,
+ smaller: 12,
+ small: 14,
default: 16,
large: 20,
};
diff --git a/app/src/ui/components/code/CodePreview.tsx b/app/src/ui/components/code/CodePreview.tsx
new file mode 100644
index 00000000..d79fd3a6
--- /dev/null
+++ b/app/src/ui/components/code/CodePreview.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react";
+import { useTheme } from "ui/client/use-theme";
+import { cn, importDynamicBrowserModule } from "ui/lib/utils";
+
+export type CodePreviewProps = {
+ code: string;
+ className?: string;
+ lang?: string;
+ theme?: string;
+ enabled?: boolean;
+};
+
+export const CodePreview = ({
+ code,
+ className,
+ lang = "typescript",
+ theme: _theme,
+ enabled = true,
+}: CodePreviewProps) => {
+ const [highlightedHtml, setHighlightedHtml] = useState(null);
+ const $theme = useTheme();
+ const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light";
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ let cancelled = false;
+ setHighlightedHtml(null);
+
+ async function highlightCode() {
+ try {
+ // Dynamically import Shiki from CDN
+ const { codeToHtml } = await importDynamicBrowserModule(
+ "shiki",
+ "https://esm.sh/shiki@3.13.0",
+ );
+
+ if (cancelled) return;
+
+ const html = await codeToHtml(code, {
+ lang,
+ theme,
+ structure: "inline",
+ });
+
+ if (cancelled) return;
+
+ setHighlightedHtml(html);
+ } catch (error) {
+ console.error("Failed to load Shiki:", error);
+ // Fallback to plain text if Shiki fails to load
+ if (!cancelled) {
+ setHighlightedHtml(code);
+ }
+ }
+ }
+
+ highlightCode();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [code, enabled]);
+
+ if (!highlightedHtml) {
+ return {code};
+ }
+
+ return (
+
+ );
+};
diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx
index ec96811e..d12bf77c 100644
--- a/app/src/ui/components/code/JsonEditor.tsx
+++ b/app/src/ui/components/code/JsonEditor.tsx
@@ -1,19 +1,68 @@
-import { Suspense, lazy } from "react";
+import { Suspense, lazy, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import type { CodeEditorProps } from "./CodeEditor";
+import { useDebouncedCallback } from "@mantine/hooks";
const CodeEditor = lazy(() => import("./CodeEditor"));
-export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
+export type JsonEditorProps = Omit & {
+ value?: object;
+ onChange?: (value: object) => void;
+ emptyAs?: any;
+ onInvalid?: (error: Error) => void;
+};
+
+export function JsonEditor({
+ editable,
+ className,
+ value,
+ onChange,
+ onBlur,
+ emptyAs = undefined,
+ onInvalid,
+ ...props
+}: JsonEditorProps) {
+ const [editorValue, setEditorValue] = useState(
+ value ? JSON.stringify(value, null, 2) : emptyAs,
+ );
+ const [error, setError] = useState(false);
+ const handleChange = useDebouncedCallback((given: string) => {
+ try {
+ setError(false);
+ onChange?.(given ? JSON.parse(given) : emptyAs);
+ } catch (e) {
+ onInvalid?.(e as Error);
+ setError(true);
+ }
+ }, 250);
+ const handleBlur = (e) => {
+ try {
+ const formatted = JSON.stringify(value, null, 2);
+ setEditorValue(formatted);
+ } catch (e) {}
+
+ onBlur?.(e);
+ };
+
+ useEffect(() => {
+ if (!editorValue) {
+ setEditorValue(value ? JSON.stringify(value, null, 2) : emptyAs);
+ }
+ }, [value]);
+
return (
diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx
index cd85aa43..c75bfcf5 100644
--- a/app/src/ui/components/form/Formy/components.tsx
+++ b/app/src/ui/components/form/Formy/components.tsx
@@ -28,8 +28,9 @@ export const Group = ({
return (
{
+export type ArrayFieldProps = {
+ path?: string;
+ labelAdd?: string;
+ wrapperProps?: Omit;
+};
+
+export const ArrayField = ({
+ path = "",
+ labelAdd = "Add",
+ wrapperProps = { wrapper: "fieldset" },
+}: ArrayFieldProps) => {
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
return (
-
+
{
}
return (
-
+
{({ value }) =>
value?.map((v, index: number) => (
@@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
}
);
};
const ArrayItem = memo(({ path, index, schema }: any) => {
- const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
+ const {
+ value,
+ path: absolutePath,
+ ...ctx
+ } = useDerivedFieldContext(path, (ctx) => {
return ctx.value?.[index];
});
- const itemPath = suffixPath(path, index);
+ const itemPath = suffixPath(absolutePath, index);
let subschema = schema.items;
const itemsMultiSchema = getMultiSchema(schema.items);
if (itemsMultiSchema) {
@@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
subschema = _subschema;
}
- const handleUpdate = useEvent((pointer: string, value: any) => {
- ctx.setValue(pointer, value);
- });
-
const handleDelete = useEvent((pointer: string) => {
ctx.deleteValue(pointer);
});
@@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
);
return (
-
- {
- handleUpdate(itemPath, coerce(e.target.value, subschema!));
- }}
- className="w-full"
- />
- {DeleteButton}
-
+
+
+ {/* another wrap is required for primitive schemas */}
+
+ {DeleteButton}
+
+
);
}, isEqual);
+const AnotherField = (props: Partial) => {
+ const { value } = useFormValue("");
+
+ const inputProps = {
+ // @todo: check, potentially just provide value
+ value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
+ };
+ return ;
+};
+
const ArrayIterator = memo(
({ name, children }: any) => {
return children(useFormValue(name));
@@ -98,19 +113,25 @@ const ArrayIterator = memo(
(prev, next) => prev.value?.length === next.value?.length,
);
-const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
+const ArrayAdd = ({
+ schema,
+ path: _path,
+ label = "Add",
+}: { schema: JsonSchema; path: string; label?: string }) => {
const {
setValue,
value: { currentIndex },
+ path,
...ctx
- } = useDerivedFieldContext(path, (ctx) => {
+ } = useDerivedFieldContext(_path, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 };
});
const itemsMultiSchema = getMultiSchema(schema.items);
+ const options = { addOptionalProps: true };
function handleAdd(template?: any) {
const newPath = suffixPath(path, currentIndex);
- setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
+ setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options));
}
if (itemsMultiSchema) {
@@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
}}
items={itemsMultiSchema.map((s, i) => ({
label: s!.title ?? `Option ${i + 1}`,
- onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)),
+ onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)),
}))}
onClickItem={console.log}
>
-
+
);
}
- return ;
+ return ;
};
diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx
index 60351ca8..4669022a 100644
--- a/app/src/ui/components/form/json-schema-form/Field.tsx
+++ b/app/src/ui/components/form/json-schema-form/Field.tsx
@@ -72,7 +72,7 @@ const FieldImpl = ({
);
if (isType(schema.type, "object")) {
- return ;
+ return ;
}
if (isType(schema.type, "array")) {
@@ -217,14 +217,14 @@ export type CustomFieldProps = {
) => React.ReactNode;
};
-export const CustomField = ({
+export function CustomField({
path: _path,
valueStrict = true,
deriveFn,
children,
-}: CustomFieldProps) => {
+}: CustomFieldProps) {
const ctx = useDerivedFieldContext(_path, deriveFn);
- const $value = useFormValue(ctx.path, { strict: valueStrict });
+ const $value = useFormValue(_path, { strict: valueStrict });
const setValue = (value: any) => ctx.setValue(ctx.path, value);
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
-};
+}
diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
index 784db35a..334dfe5b 100644
--- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
+++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
@@ -1,4 +1,4 @@
-import { IconBug } from "@tabler/icons-react";
+import { IconBug, IconInfoCircle } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library";
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
import { IconButton } from "ui/components/buttons/IconButton";
@@ -11,6 +11,8 @@ import {
} from "ui/components/form/json-schema-form/Form";
import { Popover } from "ui/components/overlay/Popover";
import { getLabel } from "./utils";
+import { twMerge } from "tailwind-merge";
+import { Tooltip } from "@mantine/core";
export type FieldwrapperProps = {
name: string;
@@ -23,8 +25,9 @@ export type FieldwrapperProps = {
children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom";
description?: string;
- descriptionPlacement?: "top" | "bottom";
+ descriptionPlacement?: "top" | "bottom" | "label";
fieldId?: string;
+ className?: string;
};
export function FieldWrapper({
@@ -38,6 +41,7 @@ export function FieldWrapper({
descriptionPlacement = "bottom",
children,
fieldId,
+ className,
...props
}: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
@@ -50,17 +54,23 @@ export function FieldWrapper({
{errors.map((e) => e.message).join(", ")}
);
- const Description = description && (
-
- {description}
-
- );
+ const Description = description ? (
+ ["top", "bottom"].includes(descriptionPlacement) ? (
+
+ {description}
+
+ ) : (
+
+
+
+ )
+ ) : null;
return (
0}
as={wrapper === "fieldset" ? "fieldset" : "div"}
- className={hidden ? "hidden" : "relative"}
+ className={twMerge(hidden ? "hidden" : "relative", className)}
>
{errorPlacement === "top" && Errors}
@@ -69,14 +79,15 @@ export function FieldWrapper({
{label} {required && *}
+ {descriptionPlacement === "label" && Description}
)}
{descriptionPlacement === "top" && Description}
-
+
{Children.count(children) === 1 && isValidElement(children)
? cloneElement(children, {
diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx
index 274c1624..56087960 100644
--- a/app/src/ui/components/form/json-schema-form/Form.tsx
+++ b/app/src/ui/components/form/json-schema-form/Form.tsx
@@ -80,6 +80,7 @@ export function Form<
onInvalidSubmit,
validateOn = "submit",
hiddenSubmit = true,
+ beforeSubmit,
ignoreKeys = [],
options = {},
readOnly = false,
@@ -90,6 +91,7 @@ export function Form<
initialOpts?: LibTemplateOptions;
ignoreKeys?: string[];
onChange?: (data: Partial, name: string, value: any, context: FormContext) => void;
+ beforeSubmit?: (data: Data) => Data;
onSubmit?: (data: Data) => void | Promise;
onInvalidSubmit?: (errors: JsonError[], data: Partial