Merge branch 'main' into cp/216-fix-users-link

This commit is contained in:
cameronapak
2025-12-30 06:55:20 -06:00
402 changed files with 20979 additions and 3585 deletions

View File

@@ -1,4 +1,4 @@
import type { App, SafeUser } from "bknd";
import type { App, Permission, SafeUser } from "bknd";
import { type Context, type Env, Hono } from "hono";
import * as middlewares from "modules/middlewares";
import type { EntityManager } from "data/entities";
@@ -19,20 +19,6 @@ export interface ServerEnv extends Env {
[key: string]: any;
}
/* export type ServerEnv = Env & {
Variables: {
app: App;
// to prevent resolving auth multiple times
auth?: {
resolved: boolean;
registered: boolean;
skip: boolean;
user?: SafeUser;
};
html?: string;
};
}; */
export class Controller {
protected middlewares = middlewares;
@@ -65,7 +51,8 @@ export class Controller {
protected getEntitiesEnum(em: EntityManager<any>): s.StringSchema {
const entities = em.entities.map((e) => e.name);
// @todo: current workaround to allow strings (sometimes building is not fast enough to get the entities)
return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string();
}
registerMcp(): void {}
}

View File

@@ -1,3 +1,4 @@
import type { App } from "bknd";
import type { EventManager } from "core/events";
import type { Connection } from "data/connection";
import type { EntityManager } from "data/entities";
@@ -5,11 +6,15 @@ import type { Hono } from "hono";
import type { ServerEnv } from "modules/Controller";
import type { ModuleHelper } from "./ModuleHelper";
import { SchemaObject } from "core/object/SchemaObject";
import type { DebugLogger } from "core/utils/DebugLogger";
import type { Guard } from "auth/authorize/Guard";
import type { McpServer, DebugLogger } from "bknd/utils";
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type ModuleBuildContextMcpContext = {
app: App;
ctx: () => ModuleBuildContext;
};
export type ModuleBuildContext = {
connection: Connection;
server: Hono<ServerEnv>;
@@ -19,6 +24,7 @@ export type ModuleBuildContext = {
logger: DebugLogger;
flags: (typeof Module)["ctx_flags"];
helper: ModuleHelper;
mcp: McpServer<ModuleBuildContextMcpContext>;
};
export abstract class Module<Schema extends object = object> {

View File

@@ -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<Options extends BaseModuleApiOptions = BaseModul
const request = new Request(url, {
..._init,
credentials: this.options.credentials,
method,
body,
headers,

View File

@@ -3,8 +3,11 @@ import { Entity } from "data/entities";
import type { EntityIndex, Field } from "data/fields";
import { entityTypes } from "data/entities/Entity";
import { isEqual } from "lodash-es";
import type { ModuleBuildContext } from "./Module";
import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module";
import type { EntityRelation } from "data/relations";
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import { Exception } from "core/errors";
import { invariant, isPlainObject } from "bknd/utils";
export class ModuleHelper {
constructor(protected ctx: Omit<ModuleBuildContext, "helper">) {}
@@ -110,4 +113,30 @@ export class ModuleHelper {
entity.__replaceField(name, newField);
}
async granted<P extends Permission<any, any, any, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
context: PermissionContext<P>,
): Promise<void>;
async granted<P extends Permission<any, any, undefined, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
): Promise<void>;
async granted<P extends Permission<any, any, any, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
context?: PermissionContext<P>,
): Promise<void> {
invariant(c.context.app, "app is not available in mcp context");
const auth = c.context.app.module.auth;
if (!auth.enabled) return;
if (c.raw === undefined || c.raw === null) {
throw new Exception("Request/Headers/Context is not available in mcp context", 400);
}
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
this.ctx.guard.granted(permission, user as any, context as any);
}
}

View File

@@ -1,26 +1,30 @@
import { mark, stripMark, $console, s, objectEach, transformObject } from "bknd/utils";
import {
objectEach,
transformObject,
McpServer,
type s,
SecretSchema,
setPath,
mark,
$console,
} from "bknd/utils";
import { DebugLogger } from "core/utils/DebugLogger";
import { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
import { BkndError } from "core/errors";
import { DebugLogger } from "core/utils/DebugLogger";
import { EventManager, Event } from "core/events";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager";
import * as proto from "data/prototype";
import { TransformPersistFailedException } from "data/errors";
import { Hono } from "hono";
import type { Kysely } from "kysely";
import { mergeWith } from "lodash-es";
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
import { AppServer } from "modules/server/AppServer";
import { AppAuth } from "../auth/AppAuth";
import { AppData } from "../data/AppData";
import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia";
import type { ServerEnv } from "./Controller";
import { Module, type ModuleBuildContext } from "./Module";
import { ModuleHelper } from "./ModuleHelper";
import { AppServer } from "modules/server/AppServer";
import { AppAuth } from "auth/AppAuth";
import { AppData } from "data/AppData";
import { AppFlows } from "flows/AppFlows";
import { AppMedia } from "media/AppMedia";
import type { PartialRec } from "core/types";
import { mergeWith, pick } from "lodash-es";
export type { ModuleBuildContext };
@@ -47,13 +51,8 @@ export type ModuleSchemas = {
export type ModuleConfigs = {
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
};
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type InitialModuleConfigs =
| ({
version: number;
} & ModuleConfigs)
| PartialRec<ModuleConfigs>;
export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
@@ -75,47 +74,19 @@ export type ModuleManagerOptions = {
// callback after server was created
onServerInit?: (server: Hono<ServerEnv>) => void;
// doesn't perform validity checks for given/fetched config
trustFetched?: boolean;
skipValidation?: boolean;
// runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>;
// called right after modules are built, before finish
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
// whether to store secrets in the database
storeSecrets?: boolean;
// provided secrets
secrets?: Record<string, any>;
/** @deprecated */
verbosity?: Verbosity;
};
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
json: Json;
created_at?: Date;
updated_at?: Date;
};
const configJsonSchema = s.anyOf([
getDefaultSchema(),
s.array(
s.strictObject({
t: s.string({ enum: ["a", "r", "e"] }),
p: s.array(s.anyOf([s.string(), s.number()])),
o: s.any().optional(),
n: s.any().optional(),
}),
),
]);
export const __bknd = proto.entity(TABLE_NAME, {
version: proto.number().required(),
type: proto.enumm({ enum: ["config", "diff", "backup"] }).required(),
json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(),
created_at: proto.datetime(),
updated_at: proto.datetime(),
});
type ConfigTable2 = proto.Schema<typeof __bknd>;
interface T_INTERNAL_EM {
__bknd: ConfigTable2;
}
const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
@@ -127,8 +98,14 @@ export class ModuleManagerConfigUpdateEvent<
}> {
static override slug = "mm-config-update";
}
export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
secrets: Record<string, any>;
}> {
static override slug = "mm-secrets-extracted";
}
export const ModuleManagerEvents = {
ModuleManagerConfigUpdateEvent,
ModuleManagerSecretsExtractedEvent,
};
// @todo: cleanup old diffs on upgrade
@@ -137,50 +114,36 @@ export class ModuleManager {
static Events = ModuleManagerEvents;
protected modules: Modules;
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules
em!: EntityManager;
server!: Hono<ServerEnv>;
emgr!: EventManager;
guard!: Guard;
mcp!: ModuleBuildContext["mcp"];
private _version: number = 0;
private _built = false;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
protected _built = false;
private logger: DebugLogger;
protected logger: DebugLogger;
constructor(
private readonly connection: Connection,
private options?: Partial<ModuleManagerOptions>,
protected readonly connection: Connection,
public options?: Partial<ModuleManagerOptions>,
) {
this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules;
this.emgr = new EventManager({ ...ModuleManagerEvents });
this.logger = new DebugLogger(debug_modules);
let initial = {} as Partial<ModuleConfigs>;
if (options?.initial) {
if ("version" in options.initial) {
const { version, ...initialConfig } = options.initial;
this._version = version;
initial = stripMark(initialConfig);
this._booted_with = "provided";
} else {
initial = mergeWith(getDefaultConfig(), options.initial);
this._booted_with = "partial";
}
const config = options?.initial ?? {};
if (options?.skipValidation) {
mark(config, true);
}
this.logger.log("booted with", this._booted_with);
this.createModules(initial);
this.createModules(config);
}
private createModules(initial: Partial<ModuleConfigs>) {
protected onModuleConfigUpdated(key: string, config: any) {}
private createModules(initial: PartialRec<ModuleConfigs>) {
this.logger.context("createModules").log("creating modules");
try {
const context = this.ctx(true);
@@ -210,46 +173,7 @@ export class ModuleManager {
return this._built;
}
/**
* This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject
* Needs to rebuild modules and save to database
*/
private async onModuleConfigUpdated(key: string, config: any) {
if (this.options?.onUpdated) {
await this.options.onUpdated(key as any, config);
} else {
await this.buildModules();
}
}
private repo() {
return this.__em.repo(__bknd, {
// to prevent exceptions when table doesn't exist
silent: true,
// disable counts for performance and compatibility
includeCounts: false,
});
}
private mutator() {
return this.__em.mutator(__bknd);
}
private get db() {
// @todo: check why this is neccessary
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
}
// @todo: add indices for: version, type
async syncConfigTable() {
this.logger.context("sync").log("start");
const result = await this.__em.schema().sync({ force: true });
this.logger.log("done").clear();
return result;
}
private rebuildServer() {
protected rebuildServer() {
this.server = new Hono<ServerEnv>();
if (this.options?.basePath) {
this.server = this.server.basePath(this.options.basePath);
@@ -271,6 +195,14 @@ export class ModuleManager {
? this.em.clear()
: new EntityManager([], this.connection, [], [], this.emgr);
this.guard = new Guard();
this.mcp = new McpServer(undefined as any, {
app: new Proxy(this, {
get: () => {
throw new Error("app is not available in mcp context");
},
}) as any,
ctx: () => this.ctx(),
});
}
const ctx = {
@@ -281,6 +213,7 @@ export class ModuleManager {
guard: this.guard,
flags: Module.ctx_flags,
logger: this.logger,
mcp: this.mcp,
};
return {
@@ -289,252 +222,81 @@ export class ModuleManager {
};
}
private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
extractSecrets() {
const moduleConfigs = structuredClone(this.configs());
const secrets = { ...this.options?.secrets };
const extractedKeys: string[] = [];
// disabling console log, because the table might not exist yet
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
sort: { by: "version", dir: "desc" },
},
);
for (const [key, module] of Object.entries(this.modules)) {
const config = moduleConfigs[key];
const schema = module.getSchema();
if (!result) {
this.logger.log("error fetching").clear();
return undefined;
}
const extracted = [...schema.walk({ data: config })].filter(
(n) => n.schema instanceof SecretSchema,
);
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id,
})
.clear();
for (const n of extracted) {
const path = [key, ...n.instancePath].join(".");
return result as unknown as ConfigTable;
}
async save() {
this.logger.context("save").log("saving version", this.version());
const configs = this.configs();
const version = this.version();
try {
const state = await this.fetch();
if (!state) throw new BkndError("no config found");
this.logger.log("fetched version", state.version);
if (state.version !== version) {
// @todo: mark all others as "backup"
this.logger.log("version conflict, storing new version", state.version, version);
await this.mutator().insertOne({
version: state.version,
type: "backup",
json: configs,
});
await this.mutator().insertOne({
version: version,
type: "config",
json: configs,
});
} else {
this.logger.log("version matches", state.version);
// clean configs because of Diff() function
const diffs = $diff.diff(state.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]);
if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
const date = new Date();
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: $diff.clone(diffs),
created_at: date,
updated_at: date,
});
// store new version
await this.mutator().updateWhere(
{
version,
json: configs,
updated_at: date,
} as any,
{
type: "config",
version,
},
);
} else {
this.logger.log("no diff, not saving");
if (typeof n.data === "string") {
extractedKeys.push(path);
secrets[path] = n.data;
setPath(moduleConfigs, path, "");
}
}
} catch (e) {
if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
type: "config",
version,
json: configs,
created_at: new Date(),
updated_at: new Date(),
});
} else if (e instanceof TransformPersistFailedException) {
$console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e;
} else {
$console.error("ModuleManager: Aborting");
this.revertModules();
throw e;
}
}
// re-apply configs to all modules (important for system entities)
this.setConfigs(configs);
// @todo: cleanup old versions?
this.logger.clear();
return this;
return {
configs: moduleConfigs,
secrets: pick(secrets, extractedKeys),
extractedKeys,
};
}
private revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
}
}
}
private setConfigs(configs: ModuleConfigs): void {
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
this.logger.log("setting configs");
objectEach(configs, (config, key) => {
for await (const [key, config] of Object.entries(configs)) {
if (!(key in this.modules)) continue;
try {
// setting "noEmit" to true, to not force listeners to update
this.modules[key].schema().set(config as any, true);
const result = await this.modules[key].schema().set(config as any, true);
} catch (e) {
console.error(e);
throw new Error(
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
);
}
});
}
}
async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
async build(opts?: any) {
this.createModules(this.options?.initial ?? {});
await this.buildModules();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {
if (opts?.fetch) {
this.logger.log("force fetch");
}
// if secrets were provided, extract, merge and build again
const provided_secrets = this.options?.secrets ?? {};
if (Object.keys(provided_secrets).length > 0) {
const { configs, extractedKeys } = this.extractSecrets();
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.version);
// set version and config from fetched
this._version = result.version;
if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)");
mark(result.json);
}
// if version doesn't match, migrate before building
if (this.version() !== CURRENT_VERSION) {
this.logger.log("now migrating");
await this.syncConfigTable();
const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.json, {
db: this.db,
});
this._version = _version;
this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
this.createModules(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
this.createModules(result.json);
await this.buildModules();
for (const key of extractedKeys) {
if (key in provided_secrets) {
setPath(configs, key, provided_secrets[key]);
}
}
} else {
if (this.version() !== CURRENT_VERSION) {
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.setConfigs(configs);
await this.buildModules();
}
this.logger.log("done");
this.logger.clear();
return this;
}
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
protected async buildModules(options?: {
graceful?: boolean;
ignoreFlags?: boolean;
drop?: boolean;
}) {
const state = {
built: false,
modules: [] as ModuleKey[],
@@ -569,13 +331,8 @@ export class ModuleManager {
ctx.flags.sync_required = false;
this.logger.log("db sync requested");
// sync db
await ctx.em.schema().sync({ force: true });
state.synced = true;
// save
await this.save();
state.saved = true;
// ignore sync request on code mode since system tables
// are probably never fully in provided config
}
if (ctx.flags.ctx_reload_required) {
@@ -591,92 +348,12 @@ export class ModuleManager {
ctx.flags = Module.ctx_flags;
// storing last stable config version
this._stable_configs = $diff.clone(this.configs());
//this._stable_configs = $diff.clone(this.configs());
this.logger.clear();
return state;
}
protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
if (!state.saved) {
await this.save();
}
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork(),
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
this.logger.clear();
}
mutateConfigSafe<Module extends keyof Modules>(
name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name];
return new Proxy(module.schema(), {
get: (target, prop: string) => {
if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
throw new Error(`Method ${prop} is not allowed`);
}
return async (...args) => {
$console.log("[Safe Mutate]", name);
try {
// overwrite listener to run build inside this try/catch
module.setListener(async () => {
await this.emgr.emit(
new ModuleManagerConfigUpdateEvent({
ctx: this.ctx(),
module: name,
config: module.config as any,
}),
);
await this.buildModules();
});
const result = await target[prop](...args);
// revert to original listener
module.setListener(async (c) => {
await this.onModuleConfigUpdated(name, c);
});
// if there was an onUpdated listener, call it after success
// e.g. App uses it to register module routes
if (this.options?.onUpdated) {
await this.options.onUpdated(name, module.config as any);
}
return result;
} catch (e) {
$console.error(`[Safe Mutate] failed "${name}":`, e);
// revert to previous config & rebuild using original listener
this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any);
$console.warn(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error
throw e;
}
};
},
});
}
get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);
@@ -685,7 +362,7 @@ export class ModuleManager {
}
version() {
return this._version;
return 0;
}
built() {
@@ -702,7 +379,7 @@ export class ModuleManager {
return {
version: this.version(),
...schemas,
};
} as { version: number } & ModuleSchemas;
}
toJSON(secrets?: boolean): { version: number } & ModuleConfigs {

View File

@@ -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<any> {
removeConfig<Module extends ModuleKey>(module: Module, path: string) {
return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
}
permissions() {
return this.get<{ permissions: TPermission[]; context: object }>("permissions");
}
}

View File

@@ -0,0 +1,595 @@
import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils";
import { BkndError } from "core/errors";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection";
import type { EntityManager } from "data/entities/EntityManager";
import * as proto from "data/prototype";
import { TransformPersistFailedException } from "data/errors";
import type { Kysely } from "kysely";
import { mergeWith } from "lodash-es";
import { CURRENT_VERSION, TABLE_NAME, migrate } from "./migrations";
import { Module, type ModuleBuildContext } from "../Module";
import {
type InitialModuleConfigs,
type ModuleConfigs,
type Modules,
type ModuleKey,
getDefaultSchema,
getDefaultConfig,
ModuleManager,
ModuleManagerConfigUpdateEvent,
type ModuleManagerOptions,
ModuleManagerSecretsExtractedEvent,
} from "../ModuleManager";
export type { ModuleBuildContext };
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
json: Json;
created_at?: Date;
updated_at?: Date;
};
const configJsonSchema = s.anyOf([
getDefaultSchema(),
s.array(
s.strictObject({
t: s.string({ enum: ["a", "r", "e"] }),
p: s.array(s.anyOf([s.string(), s.number()])),
o: s.any().optional(),
n: s.any().optional(),
}),
),
]);
export const __bknd = proto.entity(TABLE_NAME, {
version: proto.number().required(),
type: proto.enumm({ enum: ["config", "diff", "backup", "secrets"] }).required(),
json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(),
created_at: proto.datetime(),
updated_at: proto.datetime(),
});
const __schema = proto.em({ __bknd }, ({ index }, { __bknd }) => {
index(__bknd).on(["version", "type"]);
});
type ConfigTable2 = proto.Schema<typeof __bknd>;
interface T_INTERNAL_EM {
__bknd: ConfigTable2;
}
// @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade
export class DbModuleManager extends ModuleManager {
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
private _version: number = 0;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
// config used when syncing database
public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true };
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
let initial = {} as InitialModuleConfigs;
let booted_with = "partial" as any;
let version = 0;
if (options?.initial) {
if ("version" in options.initial && options.initial.version) {
const { version: _v, ...config } = options.initial;
version = _v as number;
initial = stripMark(config) as any;
booted_with = "provided";
} else {
initial = mergeWith(getDefaultConfig(), options.initial);
booted_with = "partial";
}
}
super(connection, { ...options, initial });
this.__em = __schema.proto.withConnection(this.connection) as any;
//this.__em = new EntityManager(__schema.entities, this.connection);
this._version = version;
this._booted_with = booted_with;
this.logger.log("booted with", this._booted_with);
}
/**
* This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject
* Needs to rebuild modules and save to database
*/
protected override async onModuleConfigUpdated(key: string, config: any) {
if (this.options?.onUpdated) {
await this.options.onUpdated(key as any, config);
} else {
await this.buildModules();
}
}
private repo() {
return this.__em.repo(__bknd, {
// to prevent exceptions when table doesn't exist
silent: true,
// disable counts for performance and compatibility
includeCounts: false,
});
}
private mutator() {
return this.__em.mutator(__bknd);
}
private get db() {
// @todo: check why this is neccessary
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
}
// @todo: add indices for: version, type
async syncConfigTable() {
this.logger.context("sync").log("start");
const result = await this.__em.schema().sync({ force: true });
this.logger.log("done").clear();
return result;
}
private async fetch(): Promise<{ configs?: ConfigTable; secrets?: ConfigTable } | undefined> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
// disabling console log, because the table might not exist yet
const { data: result } = await this.repo().findMany({
where: { type: { $in: ["config", "secrets"] } },
sort: { by: "version", dir: "desc" },
});
if (!result.length) {
this.logger.log("error fetching").clear();
return undefined;
}
const configs = result.filter((r) => r.type === "config")[0];
const secrets = result.filter((r) => r.type === "secrets")[0];
this.logger
.log("took", performance.now() - startTime, "ms", {
version: configs?.version,
id: configs?.id,
})
.clear();
return { configs, secrets } as any;
}
async save() {
this.logger.context("save").log("saving version", this.version());
const { configs, secrets } = this.extractSecrets();
const version = this.version();
const store_secrets = this.options?.storeSecrets !== false;
await this.emgr.emit(
new ModuleManagerSecretsExtractedEvent({
ctx: this.ctx(),
secrets,
}),
);
try {
const state = await this.fetch();
if (!state || !state.configs) throw new BkndError("no config found");
this.logger.log("fetched version", state.configs.version);
if (state.configs.version !== version) {
// @todo: mark all others as "backup"
this.logger.log(
"version conflict, storing new version",
state.configs.version,
version,
);
const updates = [
{
version: state.configs.version,
type: "backup",
json: state.configs.json,
},
{
version: version,
type: "config",
json: configs,
},
];
if (store_secrets) {
updates.push({
version: version,
type: "secrets",
json: secrets as any,
});
}
await this.mutator().insertMany(updates);
} else {
this.logger.log("version matches", state.configs.version);
// clean configs because of Diff() function
const diffs = $diff.diff(state.configs.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]);
const date = new Date();
if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: $diff.clone(diffs),
created_at: date,
updated_at: date,
});
// store new version
await this.mutator().updateWhere(
{
version,
json: configs,
updated_at: date,
} as any,
{
type: "config",
version,
},
);
} else {
this.logger.log("no diff, not saving");
}
// store secrets
if (store_secrets) {
if (!state.secrets || state.secrets?.version !== version) {
await this.mutator().insertOne({
version,
type: "secrets",
json: secrets,
created_at: date,
updated_at: date,
});
} else {
await this.mutator().updateOne(state.secrets.id!, {
version,
json: secrets,
updated_at: date,
} as any);
}
}
}
} catch (e) {
if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
type: "config",
version,
json: configs,
created_at: new Date(),
updated_at: new Date(),
});
if (store_secrets) {
await this.mutator().insertOne({
type: "secrets",
version,
json: secrets,
created_at: new Date(),
updated_at: new Date(),
});
}
} else if (e instanceof TransformPersistFailedException) {
$console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e;
} else {
$console.error("ModuleManager: Aborting");
this.revertModules();
throw e;
}
}
// re-apply configs to all modules (important for system entities)
await this.setConfigs(this.configs());
// @todo: cleanup old versions?
this.logger.clear();
return this;
}
private async revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
await this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
}
}
}
override async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {
if (opts?.fetch) {
this.logger.log("force fetch");
}
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result?.configs) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.configs.version);
// set version and config from fetched
this._version = result.configs.version;
if (result?.configs && result?.secrets) {
for (const [key, value] of Object.entries(result.secrets.json)) {
setPath(result.configs.json, key, value);
}
}
if (this.options?.skipValidation === true) {
this.logger.log("skipping validation (mark)");
mark(result.configs.json);
}
// if version doesn't match, migrate before building
if (this.version() !== CURRENT_VERSION) {
this.logger.log("now migrating");
await this.syncConfigTable();
const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.configs.json, {
db: this.db,
});
this._version = _version;
this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
// @ts-expect-error
await this.setConfigs(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
await this.setConfigs(result.configs.json);
await this.buildModules();
}
}
} else {
if (this.version() !== CURRENT_VERSION) {
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.buildModules();
}
this.logger.log("done");
this.logger.clear();
return this;
}
protected override async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
const state = {
built: false,
modules: [] as ModuleKey[],
synced: false,
saved: false,
reloaded: false,
};
this.logger.context("buildModules").log("triggered", options, this._built);
if (options?.graceful && this._built) {
this.logger.log("skipping build (graceful)");
return state;
}
this.logger.log("building");
const ctx = this.ctx(true);
for (const key in this.modules) {
await this.modules[key].setContext(ctx).build();
this.logger.log("built", key);
state.modules.push(key as ModuleKey);
}
this._built = state.built = true;
this.logger.log("modules built", ctx.flags);
if (this.options?.onModulesBuilt) {
await this.options.onModulesBuilt(ctx);
}
if (options?.ignoreFlags !== true) {
if (ctx.flags.sync_required) {
ctx.flags.sync_required = false;
this.logger.log("db sync requested");
// sync db
await ctx.em.schema().sync(this.buildSyncConfig);
state.synced = true;
// save
await this.save();
state.saved = true;
}
if (ctx.flags.ctx_reload_required) {
ctx.flags.ctx_reload_required = false;
this.logger.log("ctx reload requested");
this.ctx(true);
state.reloaded = true;
}
}
// reset all falgs
this.logger.log("resetting flags");
ctx.flags = Module.ctx_flags;
// storing last stable config version
this._stable_configs = $diff.clone(this.configs());
this.logger.clear();
return state;
}
protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
// in case there are secrets provided, we need to extract the keys and merge them with the configs. Then another build is required.
if (this.options?.secrets) {
const { configs, extractedKeys } = this.extractSecrets();
for (const key of extractedKeys) {
if (key in this.options.secrets) {
setPath(configs, key, this.options.secrets[key]);
}
}
await this.setConfigs(configs);
await this.buildModules();
}
// generally only save if not already done
// unless secrets are provided, then we need to save again
if (!state.saved || this.options?.secrets) {
await this.save();
}
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork(),
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
this.logger.clear();
}
mutateConfigSafe<Module extends keyof Modules>(
name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name];
return new Proxy(module.schema(), {
get: (target, prop: string) => {
if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
throw new Error(`Method ${prop} is not allowed`);
}
return async (...args) => {
$console.log("[Safe Mutate]", name);
try {
// overwrite listener to run build inside this try/catch
module.setListener(async () => {
await this.emgr.emit(
new ModuleManagerConfigUpdateEvent({
ctx: this.ctx(),
module: name,
config: module.config as any,
}),
);
await this.buildModules();
});
const result = await target[prop](...args);
// revert to original listener
module.setListener(async (c) => {
await this.onModuleConfigUpdated(name, c);
});
// if there was an onUpdated listener, call it after success
// e.g. App uses it to register module routes
if (this.options?.onUpdated) {
await this.options.onUpdated(name, module.config as any);
}
return result;
} catch (e) {
$console.error(`[Safe Mutate] failed "${name}":`, e);
// revert to previous config & rebuild using original listener
this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any);
$console.warn(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error
throw e;
}
};
},
});
}
override version() {
return this._version;
}
}

View File

@@ -1,4 +1,4 @@
import { transformObject } from "core/utils";
import { transformObject } from "bknd/utils";
import type { Kysely } from "kysely";
import { set } from "lodash-es";
@@ -99,6 +99,14 @@ export const migrations: Migration[] = [
};
},
},
{
// remove secrets, automatic
// change media table `entity_id` from integer to text
version: 10,
up: async (config) => {
return config;
},
},
];
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;

View File

@@ -0,0 +1,138 @@
import { Tool, getPath, limitObjectDepth, s } from "bknd/utils";
import {
McpSchemaHelper,
mcpSchemaSymbol,
type AppToolHandlerCtx,
type McpSchema,
type SchemaWithMcpOptions,
} from "./McpSchemaHelper";
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
export class ObjectToolSchema<
const P extends s.TProperties = s.TProperties,
const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions,
>
extends s.ObjectSchema<P, O>
implements McpSchema
{
constructor(name: string, properties: P, options?: ObjectToolSchemaOptions) {
const { mcp, ...rest } = options || {};
super(properties, rest as any);
this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {});
}
get mcp(): McpSchemaHelper {
return this[mcpSchemaSymbol];
}
private toolGet(node: s.Node<ObjectToolSchema>) {
return new Tool(
[this.mcp.name, "get"].join("_"),
{
...this.mcp.getToolOptions("get"),
inputSchema: s.strictObject({
path: s
.string({
pattern: /^[a-zA-Z0-9_.]{0,}$/,
title: "Path",
description: "Path to the property to get, e.g. `key.subkey`",
})
.optional(),
depth: s
.number({
description: "Limit the depth of the response",
})
.optional(),
secrets: s
.boolean({
default: false,
description: "Include secrets in the response config",
})
.optional(),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
},
},
async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(params.secrets);
const config = getPath(configs, node.instancePath);
let value = getPath(config, params.path ?? []);
if (params.depth) {
value = limitObjectDepth(value, params.depth);
}
return ctx.json({
path: params.path ?? "",
secrets: params.secrets ?? false,
partial: !!params.depth,
value: value ?? null,
});
},
);
}
private toolUpdate(node: s.Node<ObjectToolSchema>) {
const schema = this.mcp.cleanSchema;
return new Tool(
[this.mcp.name, "update"].join("_"),
{
...this.mcp.getToolOptions("update"),
inputSchema: s.strictObject({
full: s.boolean({ default: false }).optional(),
return_config: s
.boolean({
default: false,
description: "If the new configuration should be returned",
})
.optional(),
value: s.strictObject(schema.properties as {}).partial(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
const { full, value, return_config } = params;
const [module_name] = node.instancePath;
const manager = this.mcp.getManager(ctx);
if (full) {
await manager.mutateConfigSafe(module_name as any).set(value);
} else {
await manager.mutateConfigSafe(module_name as any).patch("", value);
}
let config: any = undefined;
if (return_config) {
const configs = ctx.context.app.toJSON();
config = getPath(configs, node.instancePath);
}
return ctx.json({
success: true,
module: module_name,
config,
});
},
);
}
getTools(node: s.Node<ObjectToolSchema>): Tool<any, any, any>[] {
const { tools = [] } = this.mcp.options;
return [this.toolGet(node), this.toolUpdate(node), ...tools];
}
}
export const $object = <
const P extends s.TProperties = s.TProperties,
const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions,
>(
name: string,
properties: P,
options?: s.StrictOptions<ObjectToolSchemaOptions, O>,
): ObjectToolSchema<P, O> & O => {
return new ObjectToolSchema(name, properties, options) as any;
};

View File

@@ -0,0 +1,268 @@
import { getPath, s, Tool } from "bknd/utils";
import {
McpSchemaHelper,
mcpSchemaSymbol,
type AppToolHandlerCtx,
type McpSchema,
type SchemaWithMcpOptions,
} from "./McpSchemaHelper";
type RecordToolAdditionalOptions = {
get?: boolean;
add?: boolean;
update?: boolean;
remove?: boolean;
};
export interface RecordToolSchemaOptions
extends s.IRecordOptions,
SchemaWithMcpOptions<RecordToolAdditionalOptions> {}
const opts = Symbol.for("bknd-mcp-record-opts");
export class RecordToolSchema<
AP extends s.Schema,
O extends RecordToolSchemaOptions = RecordToolSchemaOptions,
>
extends s.RecordSchema<AP, O>
implements McpSchema
{
constructor(name: string, ap: AP, options?: RecordToolSchemaOptions, new_schema?: s.Schema) {
const { mcp, ...rest } = options || {};
super(ap, rest as any);
this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {});
this[opts] = {
new_schema,
};
}
get mcp(): McpSchemaHelper<RecordToolAdditionalOptions> {
return this[mcpSchemaSymbol];
}
private getNewSchema(fallback: s.Schema = this.additionalProperties) {
return this[opts].new_schema ?? this.additionalProperties ?? fallback;
}
private toolGet(node: s.Node<RecordToolSchema<AP, O>>) {
return new Tool(
[this.mcp.name, "get"].join("_"),
{
...this.mcp.getToolOptions("get"),
inputSchema: s.strictObject({
key: s
.string({
description: "key to get",
})
.optional(),
secrets: s
.boolean({
default: false,
description: "(optional) include secrets in the response config",
})
.optional(),
schema: s
.boolean({
default: false,
description: "(optional) include the schema in the response",
})
.optional(),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
},
},
async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(params.secrets);
const config = getPath(configs, node.instancePath);
const [module_name] = node.instancePath;
// @todo: add schema to response
const schema = params.schema ? this.getNewSchema().toJSON() : undefined;
if (params.key) {
if (!(params.key in config)) {
throw new Error(`Key "${params.key}" not found in config`);
}
const value = getPath(config, params.key);
return ctx.json({
secrets: params.secrets ?? false,
module: module_name,
key: params.key,
value: value ?? null,
schema,
});
}
return ctx.json({
secrets: params.secrets ?? false,
module: module_name,
key: null,
value: config ?? null,
schema,
});
},
);
}
private toolAdd(node: s.Node<RecordToolSchema<AP, O>>) {
return new Tool(
[this.mcp.name, "add"].join("_"),
{
...this.mcp.getToolOptions("add"),
inputSchema: s.strictObject({
key: s.string({
description: "key to add",
}),
value: this.getNewSchema(),
return_config: s
.boolean({
default: false,
description: "If the new configuration should be returned",
})
.optional(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(true);
const config = getPath(configs, node.instancePath);
const [module_name, ...rest] = node.instancePath;
const manager = this.mcp.getManager(ctx);
if (params.key in config) {
throw new Error(`Key "${params.key}" already exists in config`);
}
await manager
.mutateConfigSafe(module_name as any)
.patch([...rest, params.key], params.value);
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
return ctx.json({
success: true,
module: module_name,
action: {
type: "add",
key: params.key,
},
config: params.return_config ? newConfig : undefined,
});
},
);
}
private toolUpdate(node: s.Node<RecordToolSchema<AP, O>>) {
return new Tool(
[this.mcp.name, "update"].join("_"),
{
...this.mcp.getToolOptions("update"),
inputSchema: s.strictObject({
key: s.string({
description: "key to update",
}),
value: this.mcp.getCleanSchema(this.getNewSchema(s.object({}))),
return_config: s
.boolean({
default: false,
description: "If the new configuration should be returned",
})
.optional(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(true);
const config = getPath(configs, node.instancePath);
const [module_name, ...rest] = node.instancePath;
const manager = this.mcp.getManager(ctx);
if (!(params.key in config)) {
throw new Error(`Key "${params.key}" not found in config`);
}
await manager
.mutateConfigSafe(module_name as any)
.patch([...rest, params.key], params.value);
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
return ctx.json({
success: true,
module: module_name,
action: {
type: "update",
key: params.key,
},
config: params.return_config ? newConfig : undefined,
});
},
);
}
private toolRemove(node: s.Node<RecordToolSchema<AP, O>>) {
return new Tool(
[this.mcp.name, "remove"].join("_"),
{
...this.mcp.getToolOptions("get"),
inputSchema: s.strictObject({
key: s.string({
description: "key to remove",
}),
return_config: s
.boolean({
default: false,
description: "If the new configuration should be returned",
})
.optional(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(true);
const config = getPath(configs, node.instancePath);
const [module_name, ...rest] = node.instancePath;
const manager = this.mcp.getManager(ctx);
if (!(params.key in config)) {
throw new Error(`Key "${params.key}" not found in config`);
}
await manager
.mutateConfigSafe(module_name as any)
.remove([...rest, params.key].join("."));
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
return ctx.json({
success: true,
module: module_name,
action: {
type: "remove",
key: params.key,
},
config: params.return_config ? newConfig : undefined,
});
},
);
}
getTools(node: s.Node<RecordToolSchema<AP, O>>): Tool<any, any, any>[] {
const { tools = [], get = true, add = true, update = true, remove = true } = this.mcp.options;
return [
get && this.toolGet(node),
add && this.toolAdd(node),
update && this.toolUpdate(node),
remove && this.toolRemove(node),
...tools,
].filter(Boolean) as Tool<any, any, any>[];
}
}
export const $record = <const AP extends s.Schema, const O extends RecordToolSchemaOptions>(
name: string,
ap: AP,
options?: s.StrictOptions<RecordToolSchemaOptions, O>,
new_schema?: s.Schema,
): RecordToolSchema<AP, O> => new RecordToolSchema(name, ap, options, new_schema) as any;

View File

@@ -0,0 +1,89 @@
import { Tool, getPath, s } from "bknd/utils";
import {
McpSchemaHelper,
mcpSchemaSymbol,
type AppToolHandlerCtx,
type SchemaWithMcpOptions,
} from "./McpSchemaHelper";
export interface SchemaToolSchemaOptions extends s.ISchemaOptions, SchemaWithMcpOptions {}
export const $schema = <
const S extends s.Schema,
const O extends SchemaToolSchemaOptions = SchemaToolSchemaOptions,
>(
name: string,
schema: S,
options?: O,
): S => {
const mcp = new McpSchemaHelper(schema, name, options || {});
const toolGet = (node: s.Node<S>) => {
return new Tool(
[mcp.name, "get"].join("_"),
{
...mcp.getToolOptions("get"),
inputSchema: s.strictObject({
secrets: s
.boolean({
default: false,
description: "Include secrets in the response config",
})
.optional(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
const configs = ctx.context.app.toJSON(params.secrets);
const value = getPath(configs, node.instancePath);
return ctx.json({
secrets: params.secrets ?? false,
value: value ?? null,
});
},
);
};
const toolUpdate = (node: s.Node<S>) => {
return new Tool(
[mcp.name, "update"].join("_"),
{
...mcp.getToolOptions("update"),
inputSchema: s.strictObject({
value: schema as any,
return_config: s.boolean({ default: false }).optional(),
secrets: s.boolean({ default: false }).optional(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
const { value, return_config, secrets } = params;
const [module_name, ...rest] = node.instancePath;
const manager = mcp.getManager(ctx);
await manager.mutateConfigSafe(module_name as any).overwrite(rest, value);
let config: any = undefined;
if (return_config) {
const configs = ctx.context.app.toJSON(secrets);
config = getPath(configs, node.instancePath);
}
return ctx.json({
success: true,
module: module_name,
config,
});
},
);
};
const getTools = (node: s.Node<S>) => {
const { tools = [] } = mcp.options;
return [toolGet(node), toolUpdate(node), ...tools];
};
return Object.assign(schema, {
[mcpSchemaSymbol]: mcp,
getTools,
});
};

View File

@@ -0,0 +1,87 @@
import type { App } from "bknd";
import {
type Tool,
type ToolAnnotation,
type Resource,
type ToolHandlerCtx,
s,
isPlainObject,
autoFormatString,
} from "bknd/utils";
import type { ModuleBuildContext } from "modules";
import { excludePropertyTypes, rescursiveClean } from "./utils";
import type { DbModuleManager } from "modules/db/DbModuleManager";
export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema");
export interface McpToolOptions {
title?: string;
description?: string;
annotations?: ToolAnnotation;
tools?: Tool<any, any, any>[];
resources?: Resource<any, any, any, any>[];
}
export type SchemaWithMcpOptions<AdditionalOptions = {}> = {
mcp?: McpToolOptions & AdditionalOptions;
};
export type AppToolContext = {
app: App;
ctx: () => ModuleBuildContext;
};
export type AppToolHandlerCtx = ToolHandlerCtx<AppToolContext>;
export interface McpSchema extends s.Schema {
getTools(node: s.Node<any>): Tool<any, any, any>[];
}
export class McpSchemaHelper<AdditionalOptions = {}> {
cleanSchema: s.ObjectSchema<any, any>;
constructor(
public schema: s.Schema,
public name: string,
public options: McpToolOptions & AdditionalOptions,
) {
this.cleanSchema = this.getCleanSchema(this.schema as s.ObjectSchema);
}
getCleanSchema(schema: s.ObjectSchema) {
if (schema.type !== "object") return schema;
const props = excludePropertyTypes(
schema as any,
(i) => isPlainObject(i) && mcpSchemaSymbol in i,
);
const _schema = s.strictObject(props);
return rescursiveClean(_schema, {
removeRequired: true,
removeDefault: false,
}) as s.ObjectSchema<any, any>;
}
getToolOptions(suffix?: string) {
const { tools, resources, ...rest } = this.options;
const label = (text?: string) =>
text && [suffix && autoFormatString(suffix), text].filter(Boolean).join(" ");
return {
title: label(this.options.title ?? this.schema.title),
description: label(this.options.description ?? this.schema.description),
annotations: {
destructiveHint: true,
idempotentHint: true,
...rest.annotations,
},
};
}
getManager(ctx: AppToolHandlerCtx): DbModuleManager {
const manager = ctx.context.app.modules;
if ("mutateConfigSafe" in manager) {
return manager as DbModuleManager;
}
throw new Error("Manager not found");
}
}

View File

@@ -0,0 +1,4 @@
export * from "./$object";
export * from "./$record";
export * from "./$schema";
export * from "./McpSchemaHelper";

View File

@@ -0,0 +1,39 @@
import type { App } from "App";
import { mcpSchemaSymbol, type McpSchema } from "modules/mcp";
import { getMcpServer, isObject, s, McpServer } from "bknd/utils";
import { getVersion } from "core/env";
export function getSystemMcp(app: App) {
const middlewareServer = getMcpServer(app.server);
//const appConfig = app.modules.configs();
const { version, ...appSchema } = app.getSchema();
const schema = s.strictObject(appSchema);
const result = [...schema.walk({ maxDepth: 3 })];
const nodes = result.filter((n) => mcpSchemaSymbol in n.schema) as s.Node<McpSchema>[];
const tools = [
// tools from hono routes
...middlewareServer.tools,
// tools added from ctx
...app.modules.ctx().mcp.tools,
].sort((a, b) => a.name.localeCompare(b.name));
// tools from app schema
if (!app.isReadOnly()) {
tools.push(
...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)),
);
}
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
return new McpServer(
{
name: "bknd",
version: getVersion(),
},
{ app, ctx: () => app.modules.ctx() },
tools,
resources,
);
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from "bun:test";
import { excludePropertyTypes, rescursiveClean } from "./utils";
import { s } from "../../core/utils/schema";
describe("rescursiveOptional", () => {
it("should make all properties optional", () => {
const schema = s.strictObject({
a: s.string({ default: "a" }),
b: s.number(),
nested: s.strictObject({
c: s.string().optional(),
d: s.number(),
nested2: s.record(s.string()),
}),
});
//console.log(schema.toJSON());
const result = rescursiveClean(schema, {
removeRequired: true,
removeDefault: true,
});
const json = result.toJSON();
expect(json.required).toBeUndefined();
expect(json.properties.a.default).toBeUndefined();
expect(json.properties.nested.required).toBeUndefined();
expect(json.properties.nested.properties.nested2.required).toBeUndefined();
});
it("should exclude properties", () => {
const schema = s.strictObject({
a: s.string(),
b: s.number(),
});
const result = excludePropertyTypes(schema, (instance) => instance instanceof s.StringSchema);
expect(Object.keys(result).length).toBe(1);
});
});

View File

@@ -0,0 +1,49 @@
import { isPlainObject, transformObject, s } from "bknd/utils";
export function rescursiveClean(
input: s.Schema,
opts?: {
removeRequired?: boolean;
removeDefault?: boolean;
},
): s.Schema {
const json = input.toJSON();
const removeRequired = (obj: any) => {
if (isPlainObject(obj)) {
if ("required" in obj && opts?.removeRequired) {
obj.required = undefined;
}
if ("default" in obj && opts?.removeDefault) {
obj.default = undefined;
}
if ("properties" in obj && isPlainObject(obj.properties)) {
for (const key in obj.properties) {
obj.properties[key] = removeRequired(obj.properties[key]);
}
}
}
return obj;
};
removeRequired(json);
return s.fromSchema(json);
}
export function excludePropertyTypes(
input: s.ObjectSchema<any, any>,
props: (instance: s.Schema | unknown) => boolean,
): s.TProperties {
const properties = { ...input.properties };
return transformObject(properties, (value, key) => {
if (props(value)) {
return undefined;
}
return value;
});
}

View File

@@ -1 +1,2 @@
export { auth, permission } from "auth/middlewares";
export { auth } from "auth/middlewares/auth.middleware";
export { permission } from "auth/middlewares/permission.middleware";

View File

@@ -1,9 +1,35 @@
import { Permission } from "core/security/Permission";
import { Permission } from "auth/authorize/Permission";
import { s } from "bknd/utils";
export const accessAdmin = new Permission("system.access.admin");
export const accessApi = new Permission("system.access.api");
export const configRead = new Permission("system.config.read");
export const configReadSecrets = new Permission("system.config.read.secrets");
export const configWrite = new Permission("system.config.write");
export const schemaRead = new Permission("system.schema.read");
export const configRead = new Permission(
"system.config.read",
{},
s.object({
module: s.string().optional(),
}),
);
export const configReadSecrets = new Permission(
"system.config.read.secrets",
{},
s.object({
module: s.string().optional(),
}),
);
export const configWrite = new Permission(
"system.config.write",
{},
s.object({
module: s.string().optional(),
}),
);
export const schemaRead = new Permission(
"system.schema.read",
{},
s.object({
module: s.string().optional(),
}),
);
export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp");

View File

@@ -12,6 +12,7 @@ import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
import type { TApiUser } from "Api";
import type { AppTheme } from "ui/client/use-theme";
import type { Manifest } from "vite";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -33,6 +34,7 @@ export type AdminControllerOptions = {
debugRerenders?: boolean;
theme?: AppTheme;
logoReturnPath?: string;
manifest?: Manifest;
};
export class AdminController extends Controller {
@@ -92,7 +94,7 @@ export class AdminController extends Controller {
logout: "/api/auth/logout",
};
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*", "/tools/*"];
if (isDebug()) {
paths.push("/test/*");
}
@@ -113,8 +115,9 @@ export class AdminController extends Controller {
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
addFlashMessage(c, "You are not allowed to read the schema", "warning");
},
context: (c) => ({}),
}),
async (c) => {
const obj: AdminBkndWindowContext = {
@@ -138,17 +141,19 @@ export class AdminController extends Controller {
}
if (auth_enabled) {
const options = {
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
context: (c) => ({}),
};
const redirectRouteParams = [
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
}),
permission(SystemPermissions.accessAdmin, options as any),
permission(SystemPermissions.schemaRead, options),
async (c) => {
return c.html(c.get("html")!);
},
@@ -191,12 +196,14 @@ export class AdminController extends Controller {
const assets = {
js: "main.js",
css: "styles.css",
css: ["styles.css"],
};
if (isProd) {
let manifest: any;
if (this.options.assetsPath.startsWith("http")) {
let manifest: Manifest;
if (this.options.manifest) {
manifest = this.options.manifest;
} else if (this.options.assetsPath.startsWith("http")) {
manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", {
headers: {
Accept: "application/json",
@@ -205,14 +212,17 @@ export class AdminController extends Controller {
} else {
// @ts-ignore
manifest = await import("bknd/dist/manifest.json", {
assert: { type: "json" },
with: { type: "json" },
}).then((res) => res.default);
}
try {
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
const entry = Object.values(manifest).find((m) => m.isEntry);
if (!entry) {
throw new Error("No entry found in manifest");
}
assets.js = entry?.file;
assets.css = entry?.css ?? [];
} catch (e) {
$console.warn("Couldn't find assets in manifest", e);
}
@@ -242,7 +252,9 @@ export class AdminController extends Controller {
{isProd ? (
<Fragment>
<script type="module" src={this.options.assetsPath + assets?.js} />
<link rel="stylesheet" href={this.options.assetsPath + assets?.css} />
{assets?.css.map((css, i) => (
<link key={i} rel="stylesheet" href={this.options.assetsPath + css} />
))}
</Fragment>
) : (
<Fragment>
@@ -293,7 +305,7 @@ const wrapperStyle = css`
-moz-osx-font-smoothing: grayscale;
color: rgb(9,9,11);
background-color: rgb(250,250,250);
@media (prefers-color-scheme: dark) {
color: rgb(250,250,250);
background-color: rgb(30,31,34);

View File

@@ -1,25 +1,40 @@
import { Exception } from "core/errors";
import { isDebug } from "core/env";
import { $console, s } from "bknd/utils";
import { $console, mcpLogLevels, s } from "bknd/utils";
import { $object } from "modules/mcp";
import { cors } from "hono/cors";
import { Module } from "modules/Module";
import { AuthException } from "auth/errors";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
export const serverConfigSchema = s.strictObject({
cors: s.strictObject({
origin: s.string({ default: "*" }),
allow_methods: s.array(s.string({ enum: serverMethods }), {
default: serverMethods,
uniqueItems: true,
export const serverConfigSchema = $object(
"config_server",
{
cors: s.strictObject({
origin: s.string({ default: "*" }),
allow_methods: s.array(s.string({ enum: serverMethods }), {
default: serverMethods,
uniqueItems: true,
}),
allow_headers: s.array(s.string(), {
default: ["Content-Type", "Content-Length", "Authorization", "Accept"],
}),
allow_credentials: s.boolean({ default: true }),
}),
allow_headers: s.array(s.string(), {
default: ["Content-Type", "Content-Length", "Authorization", "Accept"],
mcp: s.strictObject({
enabled: s.boolean({ default: false }),
path: s.string({ default: "/api/system/mcp" }),
logLevel: s.string({
enum: mcpLogLevels,
default: "warning",
}),
}),
allow_credentials: s.boolean({ default: true }),
}),
});
},
{
description: "Server configuration",
},
).strict();
export type AppServerConfig = s.Static<typeof serverConfigSchema>;
@@ -37,11 +52,16 @@ export class AppServer extends Module<AppServerConfig> {
}
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,
@@ -72,6 +92,10 @@ export class AppServer extends Module<AppServerConfig> {
}
if (err instanceof AuthException) {
if (isDebug()) {
return c.json(err.toJSON(), err.code);
}
return c.json(err.toJSON(), err.getSafeErrorAndCode().code);
}

View File

@@ -8,15 +8,19 @@ import {
getTimezoneOffset,
$console,
getRuntimeKey,
SecretSchema,
jsc,
s,
describeRoute,
InvalidSchemaError,
openAPISpecs,
mcpTool,
mcp as mcpMiddleware,
isNode,
type McpServer,
threw,
} from "bknd/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
import { openAPISpecs } from "jsonv-ts/hono";
import { swaggerUI } from "@hono/swagger-ui";
import {
MODULE_NAMES,
@@ -26,6 +30,10 @@ import {
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
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<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -38,11 +46,15 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
export type SchemaResponse = {
version: string;
schema: ModuleSchemas;
readonly: boolean;
config: ModuleConfigs;
permissions: string[];
//permissions: string[];
permissions: TPermission[];
};
export class SystemController extends Controller {
_mcpServer: McpServer | null = null;
constructor(private readonly app: App) {
super();
}
@@ -51,25 +63,253 @@ export class SystemController extends Controller {
return this.app.modules.ctx();
}
register(app: App) {
app.server.route("/api/system", this.getController());
const config = app.modules.get("server").config;
if (!config.mcp.enabled) {
return;
}
const { permission, auth } = this.middlewares;
this.registerMcp();
app.server.all(
config.mcp.path,
auth(),
permission(SystemPermissions.mcp, {}),
mcpMiddleware({
setup: async () => {
if (!this._mcpServer) {
this._mcpServer = getSystemMcp(app);
this._mcpServer.onNotification((message) => {
if (message.method === "notification/message") {
const consoleMap = {
emergency: "error",
alert: "error",
critical: "error",
error: "error",
warning: "warn",
notice: "log",
info: "info",
debug: "debug",
};
const level = consoleMap[message.params.level];
if (!level) return;
$console[level](
"MCP notification",
message.params.message ?? message.params,
);
}
});
}
return {
server: this._mcpServer,
};
},
sessionsEnabled: true,
debug: {
logLevel: config.mcp.logLevel as any,
explainEndpoint: true,
},
endpoint: {
// @ts-ignore
_init: isNode() ? { duplex: "half" } : {},
},
}),
);
}
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
const hono = this.create();
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
hono.use(permission(SystemPermissions.configRead));
if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager;
hono.get(
"/raw",
describeRoute({
summary: "Get the raw config",
tags: ["system"],
}),
permission([SystemPermissions.configReadSecrets]),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
},
);
hono.get(
"/raw",
describeRoute({
summary: "Get the raw config",
tags: ["system"],
}),
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));
},
);
async function handleConfigUpdateResponse(
c: Context<any>,
cb: () => Promise<ConfigUpdate>,
) {
try {
return c.json(await cb(), { status: 202 });
} catch (e) {
$console.error("config update error", e);
if (e instanceof InvalidSchemaError) {
return c.json(
{ success: false, type: "type-invalid", errors: e.errors },
{ status: 400 },
);
}
if (e instanceof Error) {
return c.json(
{ success: false, type: "error", error: e.message },
{ status: 500 },
);
}
return c.json({ success: false, type: "unknown" }, { status: 500 });
}
}
hono.post(
"/set/:module",
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;
const { force } = c.req.valid("query");
const value = await c.req.json();
return await handleConfigUpdateResponse(c, async () => {
// you must explicitly set force to override existing values
// because omitted values gets removed
if (force === true) {
// force overwrite defined keys
const newConfig = {
...this.app.module[module].config,
...value,
};
await manager.mutateConfigSafe(module).set(newConfig);
} else {
await manager.mutateConfigSafe(module).patch("", value);
}
return {
success: true,
module,
config: this.app.module[module].config,
};
});
},
);
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 },
);
}
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, {
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");
return await handleConfigUpdateResponse(c, async () => {
await manager.mutateConfigSafe(module).patch(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
},
);
hono.put(
"/overwrite/: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");
return await handleConfigUpdateResponse(c, async () => {
await manager.mutateConfigSafe(module).overwrite(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
},
);
hono.delete(
"/remove/: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 path = c.req.param("path")!;
return await handleConfigUpdateResponse(c, async () => {
await manager.mutateConfigSafe(module).remove(path);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
},
);
}
hono.get(
"/:module?",
@@ -77,6 +317,11 @@ export class SystemController extends Controller {
summary: "Get the config for a module",
tags: ["system"],
}),
mcpTool("system_config", {
annotations: {
readOnlyHint: true,
},
}), // @todo: ":module" gets not removed
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
jsc("query", s.object({ secrets: s.boolean().optional() })),
async (c) => {
@@ -84,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);
@@ -100,124 +349,6 @@ export class SystemController extends Controller {
},
);
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
try {
return c.json(await cb(), { status: 202 });
} catch (e) {
$console.error("config update error", e);
if (e instanceof InvalidSchemaError) {
return c.json(
{ success: false, type: "type-invalid", errors: e.errors },
{ status: 400 },
);
}
if (e instanceof Error) {
return c.json({ success: false, type: "error", error: e.message }, { status: 500 });
}
return c.json({ success: false, type: "unknown" }, { status: 500 });
}
}
hono.post(
"/set/:module",
permission(SystemPermissions.configWrite),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => {
const module = c.req.param("module") as any;
const { force } = c.req.valid("query");
const value = await c.req.json();
return await handleConfigUpdateResponse(c, async () => {
// you must explicitly set force to override existing values
// because omitted values gets removed
if (force === true) {
// force overwrite defined keys
const newConfig = {
...this.app.module[module].config,
...value,
};
await this.app.mutateConfig(module).set(newConfig);
} else {
await this.app.mutateConfig(module).patch("", value);
}
return {
success: true,
module,
config: this.app.module[module].config,
};
});
},
);
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;
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 this.app.mutateConfig(module).patch(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
});
hono.patch("/patch/: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");
return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).patch(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
});
hono.put("/overwrite/: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");
return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).overwrite(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
});
hono.delete("/remove/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
const path = c.req.param("path")!;
return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).remove(path);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
});
client.route("/config", hono);
}
@@ -233,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
@@ -247,9 +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 =
// 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();
@@ -261,6 +409,7 @@ export class SystemController extends Controller {
if (module) {
return c.json({
module,
readonly,
version,
schema: schema[module],
config: config ? this.app.module[module].toJSON(secrets) : undefined,
@@ -270,23 +419,37 @@ export class SystemController extends Controller {
return c.json({
module,
version,
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({
summary: "Build the app",
tags: ["system"],
}),
mcpTool("system_build"),
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
this.ctx.guard.granted(SystemPermissions.build, c);
await this.app.build(options);
return c.json({
@@ -298,6 +461,7 @@ export class SystemController extends Controller {
hono.get(
"/ping",
mcpTool("system_ping"),
describeRoute({
summary: "Ping the server",
tags: ["system"],
@@ -307,13 +471,20 @@ export class SystemController extends Controller {
hono.get(
"/info",
mcpTool("system_info"),
describeRoute({
summary: "Get the server info",
tags: ["system"],
}),
(c) =>
c.json({
version: c.get("app")?.version(),
id: this.app._id,
version: {
config: c.get("app")?.version(),
bknd: getVersion(),
},
mode: this.app.mode,
readonly: this.app.isReadOnly(),
runtime: getRuntimeKey(),
connection: {
name: this.app.em.connection.name,
@@ -328,19 +499,6 @@ export class SystemController extends Controller {
},
origin: new URL(c.req.raw.url).origin,
plugins: Array.from(this.app.plugins.keys()),
walk: {
auth: [
...c
.get("app")
.getSchema()
.auth.walk({ data: c.get("app").toJSON(true).auth }),
]
.filter((n) => n.schema instanceof SecretSchema)
.map((n) => ({
...n,
schema: n.schema.constructor.name,
})),
},
}),
);
@@ -357,4 +515,58 @@ export class SystemController extends Controller {
return hono;
}
override registerMcp() {
const { mcp } = this.app.modules.ctx();
const { version, ...appConfig } = this.app.toJSON();
mcp.resource("system_config", "bknd://system/config", async (c) => {
await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {});
return c.json(this.app.toJSON(), {
title: "System Config",
});
})
.resource(
"system_config_module",
"bknd://system/config/{module}",
async (c, { module }) => {
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(), {
title: `Config for ${module}`,
});
},
{
list: Object.keys(appConfig),
},
)
.resource("system_schema", "bknd://system/schema", async (c) => {
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {});
return c.json(this.app.getSchema(), {
title: "System Schema",
});
})
.resource(
"system_schema_module",
"bknd://system/schema/{module}",
async (c, { module }) => {
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {
module,
});
const m = this.app.modules.get(module as any);
return c.json(m.getSchema().toJSON(), {
title: `Schema for ${module}`,
});
},
{
list: Object.keys(this.app.getSchema()),
},
);
}
}