init code-first mode by splitting module manager

This commit is contained in:
dswbx
2025-09-04 09:21:35 +02:00
parent c9773d49a6
commit e3888537f9
22 changed files with 768 additions and 541 deletions

View File

@@ -10,7 +10,7 @@ export {
type ModuleSchemas,
MODULE_NAMES,
type ModuleKey,
} from "./ModuleManager";
} from "./manager/ModuleManager";
export type { ModuleBuildContext } from "./Module";
export {

View File

@@ -1,89 +1,28 @@
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils";
import { DebugLogger } from "core/utils/DebugLogger";
import { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
import { mark, stripMark, $console, s } from "bknd/utils";
import { BkndError } from "core/errors";
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 { Module, type ModuleBuildContext } from "../Module";
import {
type InitialModuleConfigs,
type ModuleConfigs,
type Modules,
type ModuleKey,
getDefaultSchema,
getDefaultConfig,
ModuleManager,
ModuleManagerConfigUpdateEvent,
type ModuleManagerOptions,
} from "./ModuleManager";
export type { ModuleBuildContext };
export const MODULES = {
server: AppServer,
data: AppData,
auth: AppAuth,
media: AppMedia,
flows: AppFlows,
} as const;
// get names of MODULES as an array
export const MODULE_NAMES = Object.keys(MODULES) as ModuleKey[];
export type ModuleKey = keyof typeof MODULES;
export type Modules = {
[K in keyof typeof MODULES]: InstanceType<(typeof MODULES)[K]>;
};
export type ModuleSchemas = {
[K in keyof typeof MODULES]: ReturnType<(typeof MODULES)[K]["prototype"]["getSchema"]>;
};
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>;
enum Verbosity {
silent = 0,
error = 1,
log = 2,
}
export type ModuleManagerOptions = {
initial?: InitialModuleConfigs;
eventManager?: EventManager<any>;
onUpdated?: <Module extends keyof Modules>(
module: Module,
config: ModuleConfigs[Module],
) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// base path for the hono instance
basePath?: string;
// callback after server was created
onServerInit?: (server: Hono<ServerEnv>) => void;
// doesn't perform validity checks for given/fetched config
trustFetched?: 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>;
/** @deprecated */
verbosity?: Verbosity;
};
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
@@ -116,99 +55,41 @@ interface T_INTERNAL_EM {
__bknd: ConfigTable2;
}
const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
export class ModuleManagerConfigUpdateEvent<
Module extends keyof ModuleConfigs,
> extends ModuleManagerEvent<{
module: Module;
config: ModuleConfigs[Module];
}> {
static override slug = "mm-config-update";
}
export const ModuleManagerEvents = {
ModuleManagerConfigUpdateEvent,
};
// @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade
export class ModuleManager {
static Events = ModuleManagerEvents;
protected modules: Modules;
export class DbModuleManager extends ModuleManager {
// 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;
private logger: DebugLogger;
constructor(
private readonly connection: Connection,
private 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>;
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) {
const { version, ...initialConfig } = options.initial;
this._version = version;
initial = stripMark(initialConfig);
if ("version" in options.initial && options.initial.version) {
const { version: _v, ...initialConfig } = options.initial;
version = _v as number;
initial = stripMark(initialConfig) as any;
this._booted_with = "provided";
booted_with = "provided";
} else {
initial = mergeWith(getDefaultConfig(), options.initial);
this._booted_with = "partial";
booted_with = "partial";
}
}
super(connection, { ...options, initial });
this.__em = new EntityManager([__bknd], this.connection);
this._version = version;
this._booted_with = booted_with;
this.logger.log("booted with", this._booted_with);
this.createModules(initial);
}
private createModules(initial: Partial<ModuleConfigs>) {
this.logger.context("createModules").log("creating modules");
try {
const context = this.ctx(true);
for (const key in MODULES) {
const moduleConfig = initial && key in initial ? initial[key] : {};
const module = new MODULES[key](moduleConfig, context) as Module;
module.setListener(async (c) => {
await this.onModuleConfigUpdated(key, c);
});
this.modules[key] = module;
}
this.logger.log("modules created");
} catch (e) {
this.logger.log("failed to create modules", e);
throw e;
}
this.logger.clear();
}
private get verbosity() {
return this.options?.verbosity ?? Verbosity.silent;
}
isBuilt(): boolean {
return this._built;
}
/**
@@ -216,7 +97,7 @@ export class ModuleManager {
* 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) {
protected override async onModuleConfigUpdated(key: string, config: any) {
if (this.options?.onUpdated) {
await this.options.onUpdated(key as any, config);
} else {
@@ -250,55 +131,6 @@ export class ModuleManager {
return result;
}
private rebuildServer() {
this.server = new Hono<ServerEnv>();
if (this.options?.basePath) {
this.server = this.server.basePath(this.options.basePath);
}
if (this.options?.onServerInit) {
this.options.onServerInit(this.server);
}
// optional method for each module to register global middlewares, etc.
objectEach(this.modules, (module) => {
module.onServerInit(this.server);
});
}
ctx(rebuild?: boolean): ModuleBuildContext {
if (rebuild) {
this.rebuildServer();
this.em = this.em
? 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 = {
connection: this.connection,
server: this.server,
em: this.em,
emgr: this.emgr,
guard: this.guard,
flags: Module.ctx_flags,
logger: this.logger,
mcp: this.mcp,
};
return {
...ctx,
helper: new ModuleHelper(ctx),
};
}
private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
@@ -463,22 +295,7 @@ export class ModuleManager {
}
}
private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs");
objectEach(configs, (config, key) => {
try {
// setting "noEmit" to true, to not force listeners to update
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 }) {
override async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
@@ -521,11 +338,13 @@ export class ModuleManager {
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
this.createModules(_configs);
// @ts-expect-error
this.setConfigs(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
this.createModules(result.json);
this.setConfigs(result.json);
await this.buildModules();
}
}
@@ -544,7 +363,7 @@ export class ModuleManager {
return this;
}
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
protected override async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
const state = {
built: false,
modules: [] as ModuleKey[],
@@ -686,71 +505,4 @@ export class ModuleManager {
},
});
}
get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);
}
return this.modules[key];
}
version() {
return this._version;
}
built() {
return this._built;
}
configs(): ModuleConfigs {
return transformObject(this.modules, (module) => module.toJSON(true)) as any;
}
getSchema() {
const schemas = transformObject(this.modules, (module) => module.getSchema());
return {
version: this.version(),
...schemas,
} as { version: number } & ModuleSchemas;
}
toJSON(secrets?: boolean): { version: number } & ModuleConfigs {
const modules = transformObject(this.modules, (module) => {
if (this._built) {
return module.isBuilt() ? module.toJSON(secrets) : module.configDefault;
}
// returns no config if the all modules are not built
return undefined;
});
return {
version: this.version(),
...modules,
} as any;
}
}
export function getDefaultSchema() {
const schema = {
type: "object",
...transformObject(MODULES, (module) => module.prototype.getSchema()),
};
return schema as any;
}
export function getDefaultConfig(): ModuleConfigs {
const config = transformObject(MODULES, (module) => {
return module.prototype.getSchema().template(
{},
{
withOptional: true,
withExtendedOptional: true,
},
);
});
return structuredClone(config) as any;
}

View File

@@ -0,0 +1,354 @@
import { objectEach, transformObject, McpServer, type s } from "bknd/utils";
import { DebugLogger } from "core/utils/DebugLogger";
import { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
import { EventManager, Event } from "core/events";
import type { Connection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager";
import { Hono } from "hono";
import { CURRENT_VERSION } from "modules/migrations";
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";
export type { ModuleBuildContext };
export const MODULES = {
server: AppServer,
data: AppData,
auth: AppAuth,
media: AppMedia,
flows: AppFlows,
} as const;
// get names of MODULES as an array
export const MODULE_NAMES = Object.keys(MODULES) as ModuleKey[];
export type ModuleKey = keyof typeof MODULES;
export type Modules = {
[K in keyof typeof MODULES]: InstanceType<(typeof MODULES)[K]>;
};
export type ModuleSchemas = {
[K in keyof typeof MODULES]: ReturnType<(typeof MODULES)[K]["prototype"]["getSchema"]>;
};
export type ModuleConfigs = {
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
};
export type InitialModuleConfigs = {
version?: number;
} & PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
error = 1,
log = 2,
}
export type ModuleManagerOptions = {
initial?: InitialModuleConfigs;
eventManager?: EventManager<any>;
onUpdated?: <Module extends keyof Modules>(
module: Module,
config: ModuleConfigs[Module],
) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// base path for the hono instance
basePath?: string;
// callback after server was created
onServerInit?: (server: Hono<ServerEnv>) => void;
// doesn't perform validity checks for given/fetched config
trustFetched?: 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>;
/** @deprecated */
verbosity?: Verbosity;
};
const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
export class ModuleManagerConfigUpdateEvent<
Module extends keyof ModuleConfigs,
> extends ModuleManagerEvent<{
module: Module;
config: ModuleConfigs[Module];
}> {
static override slug = "mm-config-update";
}
export const ModuleManagerEvents = {
ModuleManagerConfigUpdateEvent,
};
// @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade
export class ModuleManager {
static Events = ModuleManagerEvents;
protected modules: Modules;
// ctx for modules
em!: EntityManager;
server!: Hono<ServerEnv>;
emgr!: EventManager;
guard!: Guard;
mcp!: ModuleBuildContext["mcp"];
protected _built = false;
protected logger: DebugLogger;
constructor(
protected readonly connection: Connection,
protected options?: Partial<ModuleManagerOptions>,
) {
this.modules = {} as Modules;
this.emgr = new EventManager({ ...ModuleManagerEvents });
this.logger = new DebugLogger(debug_modules);
this.createModules(options?.initial ?? {});
}
protected onModuleConfigUpdated(key: string, config: any) {}
private createModules(initial: PartialRec<ModuleConfigs>) {
this.logger.context("createModules").log("creating modules");
try {
const context = this.ctx(true);
for (const key in MODULES) {
const moduleConfig = initial && key in initial ? initial[key] : {};
const module = new MODULES[key](moduleConfig, context) as Module;
module.setListener(async (c) => {
await this.onModuleConfigUpdated(key, c);
});
this.modules[key] = module;
}
this.logger.log("modules created");
} catch (e) {
this.logger.log("failed to create modules", e);
throw e;
}
this.logger.clear();
}
private get verbosity() {
return this.options?.verbosity ?? Verbosity.silent;
}
isBuilt(): boolean {
return this._built;
}
protected rebuildServer() {
this.server = new Hono<ServerEnv>();
if (this.options?.basePath) {
this.server = this.server.basePath(this.options.basePath);
}
if (this.options?.onServerInit) {
this.options.onServerInit(this.server);
}
// optional method for each module to register global middlewares, etc.
objectEach(this.modules, (module) => {
module.onServerInit(this.server);
});
}
ctx(rebuild?: boolean): ModuleBuildContext {
if (rebuild) {
this.rebuildServer();
this.em = this.em
? 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 = {
connection: this.connection,
server: this.server,
em: this.em,
emgr: this.emgr,
guard: this.guard,
flags: Module.ctx_flags,
logger: this.logger,
mcp: this.mcp,
};
return {
...ctx,
helper: new ModuleHelper(ctx),
};
}
protected setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs");
objectEach(configs, (config, key) => {
try {
// setting "noEmit" to true, to not force listeners to update
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?: any) {
this.createModules(this.options?.initial ?? {});
await this.buildModules();
return this;
}
protected async buildModules(options?: {
graceful?: boolean;
ignoreFlags?: boolean;
drop?: 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({ force: true, drop: options?.drop });
state.synced = 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;
}
get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);
}
return this.modules[key];
}
version() {
return CURRENT_VERSION;
}
built() {
return this._built;
}
configs(): ModuleConfigs {
return transformObject(this.modules, (module) => module.toJSON(true)) as any;
}
getSchema() {
const schemas = transformObject(this.modules, (module) => module.getSchema());
return {
version: this.version(),
...schemas,
} as { version: number } & ModuleSchemas;
}
toJSON(secrets?: boolean): { version: number } & ModuleConfigs {
const modules = transformObject(this.modules, (module) => {
if (this._built) {
return module.isBuilt() ? module.toJSON(secrets) : module.configDefault;
}
// returns no config if the all modules are not built
return undefined;
});
return {
version: this.version(),
...modules,
} as any;
}
}
export function getDefaultSchema() {
const schema = {
type: "object",
...transformObject(MODULES, (module) => module.prototype.getSchema()),
};
return schema as any;
}
export function getDefaultConfig(): ModuleConfigs {
const config = transformObject(MODULES, (module) => {
return module.prototype.getSchema().template(
{},
{
withOptional: true,
withExtendedOptional: true,
},
);
});
return structuredClone(config) as any;
}

View File

@@ -6,7 +6,6 @@ import {
type McpSchema,
type SchemaWithMcpOptions,
} from "./McpSchemaHelper";
import type { Module } from "modules/Module";
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
@@ -79,6 +78,7 @@ export class ObjectToolSchema<
private toolUpdate(node: s.Node<ObjectToolSchema>) {
const schema = this.mcp.cleanSchema;
return new Tool(
[this.mcp.name, "update"].join("_"),
{
@@ -97,11 +97,12 @@ export class ObjectToolSchema<
async (params, ctx: AppToolHandlerCtx) => {
const { full, value, return_config } = params;
const [module_name] = node.instancePath;
const manager = this.mcp.getManager(ctx);
if (full) {
await ctx.context.app.mutateConfig(module_name as any).set(value);
await manager.mutateConfigSafe(module_name as any).set(value);
} else {
await ctx.context.app.mutateConfig(module_name as any).patch("", value);
await manager.mutateConfigSafe(module_name as any).patch("", value);
}
let config: any = undefined;

View File

@@ -129,13 +129,14 @@ export class RecordToolSchema<
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 ctx.context.app
.mutateConfig(module_name as any)
await manager
.mutateConfigSafe(module_name as any)
.patch([...rest, params.key], params.value);
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
@@ -175,13 +176,14 @@ export class RecordToolSchema<
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 ctx.context.app
.mutateConfig(module_name as any)
await manager
.mutateConfigSafe(module_name as any)
.patch([...rest, params.key], params.value);
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
@@ -220,13 +222,14 @@ export class RecordToolSchema<
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 ctx.context.app
.mutateConfig(module_name as any)
await manager
.mutateConfigSafe(module_name as any)
.remove([...rest, params.key].join("."));
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);

View File

@@ -58,8 +58,9 @@ export const $schema = <
async (params, ctx: AppToolHandlerCtx) => {
const { value, return_config, secrets } = params;
const [module_name, ...rest] = node.instancePath;
const manager = mcp.getManager(ctx);
await ctx.context.app.mutateConfig(module_name as any).overwrite(rest, value);
await manager.mutateConfigSafe(module_name as any).overwrite(rest, value);
let config: any = undefined;
if (return_config) {

View File

@@ -10,6 +10,7 @@ import {
} from "bknd/utils";
import type { ModuleBuildContext } from "modules";
import { excludePropertyTypes, rescursiveClean } from "./utils";
import type { DbModuleManager } from "modules/manager/DbModuleManager";
export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema");
@@ -74,4 +75,13 @@ export class McpSchemaHelper<AdditionalOptions = {}> {
},
};
}
getManager(ctx: AppToolHandlerCtx): DbModuleManager {
const manager = ctx.context.app.modules;
if ("mutateConfigSafe" in manager) {
return manager as DbModuleManager;
}
throw new Error("Manager not found");
}
}

View File

@@ -19,9 +19,11 @@ export function getSystemMcp(app: App) {
].sort((a, b) => a.name.localeCompare(b.name));
// tools from app schema
tools.push(
...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)),
);
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];

View File

@@ -26,11 +26,12 @@ import {
type ModuleConfigs,
type ModuleSchemas,
type ModuleKey,
} from "modules/ModuleManager";
} from "modules/manager/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 { DbModuleManager } from "modules/manager/DbModuleManager";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -43,6 +44,7 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
export type SchemaResponse = {
version: string;
schema: ModuleSchemas;
readonly: boolean;
config: ModuleConfigs;
permissions: string[];
};
@@ -109,22 +111,163 @@ export class SystemController extends Controller {
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]),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
},
);
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 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), 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),
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),
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),
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?",
@@ -160,124 +303,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);
}
@@ -307,6 +332,7 @@ export class SystemController extends Controller {
async (c) => {
const module = c.req.param("module") as ModuleKey | undefined;
const { config, secrets, fresh } = c.req.valid("query");
const readonly = this.app.isReadOnly();
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
@@ -321,6 +347,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,
@@ -330,6 +357,7 @@ 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(),