mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
Merge branch 'main' into cp/216-fix-users-link
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
595
app/src/modules/db/DbModuleManager.ts
Normal file
595
app/src/modules/db/DbModuleManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
138
app/src/modules/mcp/$object.ts
Normal file
138
app/src/modules/mcp/$object.ts
Normal 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;
|
||||
};
|
||||
268
app/src/modules/mcp/$record.ts
Normal file
268
app/src/modules/mcp/$record.ts
Normal 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;
|
||||
89
app/src/modules/mcp/$schema.ts
Normal file
89
app/src/modules/mcp/$schema.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
87
app/src/modules/mcp/McpSchemaHelper.ts
Normal file
87
app/src/modules/mcp/McpSchemaHelper.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
4
app/src/modules/mcp/index.ts
Normal file
4
app/src/modules/mcp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./$object";
|
||||
export * from "./$record";
|
||||
export * from "./$schema";
|
||||
export * from "./McpSchemaHelper";
|
||||
39
app/src/modules/mcp/system-mcp.ts
Normal file
39
app/src/modules/mcp/system-mcp.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
39
app/src/modules/mcp/utils.spec.ts
Normal file
39
app/src/modules/mcp/utils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
app/src/modules/mcp/utils.ts
Normal file
49
app/src/modules/mcp/utils.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { auth, permission } from "auth/middlewares";
|
||||
export { auth } from "auth/middlewares/auth.middleware";
|
||||
export { permission } from "auth/middlewares/permission.middleware";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user