Merge remote-tracking branch 'origin/main' into feat/data-ui-fillable-visible
This commit is contained in:
+46
-32
@@ -40,6 +40,7 @@ export type ApiOptions = {
|
||||
data?: SubApiOptions<DataApiOptions>;
|
||||
auth?: SubApiOptions<AuthApiOptions>;
|
||||
media?: SubApiOptions<MediaApiOptions>;
|
||||
credentials?: RequestCredentials;
|
||||
} & (
|
||||
| {
|
||||
token?: string;
|
||||
@@ -67,7 +68,7 @@ export class Api {
|
||||
public auth!: AuthApi;
|
||||
public media!: MediaApi;
|
||||
|
||||
constructor(private options: ApiOptions = {}) {
|
||||
constructor(public options: ApiOptions = {}) {
|
||||
// only mark verified if forced
|
||||
this.verified = options.verified === true;
|
||||
|
||||
@@ -129,29 +130,45 @@ export class Api {
|
||||
} else if (this.storage) {
|
||||
this.storage.getItem(this.tokenKey).then((token) => {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(token ? String(token) : undefined);
|
||||
this.updateToken(token ? String(token) : undefined, {
|
||||
verified: true,
|
||||
trigger: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make storage async to allow async storages even if sync given
|
||||
* @private
|
||||
*/
|
||||
private get storage() {
|
||||
if (!this.options.storage) return null;
|
||||
return {
|
||||
getItem: async (key: string) => {
|
||||
return await this.options.storage!.getItem(key);
|
||||
const storage = this.options.storage;
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, prop) {
|
||||
return (...args: any[]) => {
|
||||
const response = storage ? storage[prop](...args) : undefined;
|
||||
if (response instanceof Promise) {
|
||||
return response;
|
||||
}
|
||||
return {
|
||||
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
|
||||
then: (fn) => fn(response),
|
||||
};
|
||||
};
|
||||
},
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
return await this.options.storage!.setItem(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
return await this.options.storage!.removeItem(key);
|
||||
},
|
||||
};
|
||||
) as any;
|
||||
}
|
||||
|
||||
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
||||
updateToken(
|
||||
token?: string,
|
||||
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
|
||||
) {
|
||||
this.token = token;
|
||||
this.verified = false;
|
||||
this.verified = opts?.verified === true;
|
||||
|
||||
if (token) {
|
||||
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||
@@ -159,21 +176,22 @@ export class Api {
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
const emit = () => {
|
||||
if (opts?.trigger !== false) {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
}
|
||||
};
|
||||
if (this.storage) {
|
||||
const key = this.tokenKey;
|
||||
|
||||
if (token) {
|
||||
this.storage.setItem(key, token).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
this.storage.setItem(key, token).then(emit);
|
||||
} else {
|
||||
this.storage.removeItem(key).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
this.storage.removeItem(key).then(emit);
|
||||
}
|
||||
} else {
|
||||
if (opts?.trigger !== false) {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +200,7 @@ export class Api {
|
||||
|
||||
private markAuthVerified(verfied: boolean) {
|
||||
this.verified = verfied;
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -208,11 +227,6 @@ export class Api {
|
||||
}
|
||||
|
||||
async verifyAuth() {
|
||||
if (!this.token) {
|
||||
this.markAuthVerified(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, data } = await this.auth.me();
|
||||
const user = data?.user;
|
||||
@@ -221,10 +235,10 @@ export class Api {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.markAuthVerified(true);
|
||||
} catch (e) {
|
||||
this.markAuthVerified(false);
|
||||
this.updateToken(undefined);
|
||||
} finally {
|
||||
this.markAuthVerified(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +253,7 @@ export class Api {
|
||||
headers: this.options.headers,
|
||||
token_transport: this.token_transport,
|
||||
verbose: this.options.verbose,
|
||||
credentials: this.options.credentials,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,10 +272,9 @@ export class Api {
|
||||
this.auth = new AuthApi(
|
||||
{
|
||||
...baseParams,
|
||||
credentials: this.options.storage ? "omit" : "include",
|
||||
...this.options.auth,
|
||||
onTokenUpdate: (token) => {
|
||||
this.updateToken(token, { rebuild: true });
|
||||
onTokenUpdate: (token, verified) => {
|
||||
this.updateToken(token, { rebuild: true, verified, trigger: true });
|
||||
this.options.auth?.onTokenUpdate?.(token);
|
||||
},
|
||||
},
|
||||
|
||||
+5
-3
@@ -245,9 +245,8 @@ export class App<
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
if (!this.isBuilt()) {
|
||||
throw new Error("App is not built yet, run build() first");
|
||||
console.error("App is not built yet, run build() first");
|
||||
}
|
||||
|
||||
return this.server.fetch as any;
|
||||
}
|
||||
|
||||
@@ -296,6 +295,7 @@ export class App<
|
||||
return this.module.auth.createUser(p);
|
||||
}
|
||||
|
||||
// @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners
|
||||
getApi(options?: LocalApiOptions) {
|
||||
const fetcher = this.server.request as typeof fetch;
|
||||
if (options && options instanceof Request) {
|
||||
@@ -311,8 +311,9 @@ export class App<
|
||||
throw new Error("MCP is not enabled");
|
||||
}
|
||||
|
||||
const url = new URL(config.path, "http://localhost").toString();
|
||||
return new McpClient({
|
||||
url: "http://localhost" + config.path,
|
||||
url,
|
||||
fetch: this.server.request,
|
||||
});
|
||||
}
|
||||
@@ -385,6 +386,7 @@ export class App<
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.options?.manager?.onModulesBuilt?.(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
||||
|
||||
export async function getApp<Env = AstroEnv>(
|
||||
config: AstroBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = import.meta.env as Env,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? import.meta.env);
|
||||
return await createFrameworkApp(config, args);
|
||||
}
|
||||
|
||||
export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
|
||||
export function serve<Env = AstroEnv>(
|
||||
config: AstroBkndConfig<Env> = {},
|
||||
args: Env = import.meta.env as Env,
|
||||
) {
|
||||
return async (fnArgs: TAstro) => {
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
|
||||
|
||||
export async function createApp<Env = BunEnv>(
|
||||
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
registerLocalMediaAdapter();
|
||||
@@ -26,18 +26,18 @@ export async function createApp<Env = BunEnv>(
|
||||
}),
|
||||
...config,
|
||||
},
|
||||
args ?? (process.env as Env),
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export function createHandler<Env = BunEnv>(
|
||||
config: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
let app: App | undefined;
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createApp(config, args ?? (process.env as Env));
|
||||
app = await createApp(config, args);
|
||||
}
|
||||
return app.fetch(req);
|
||||
};
|
||||
@@ -54,9 +54,10 @@ export function serve<Env = BunEnv>(
|
||||
buildConfig,
|
||||
adminOptions,
|
||||
serveStatic,
|
||||
beforeBuild,
|
||||
...serveOptions
|
||||
}: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
Bun.serve({
|
||||
...serveOptions,
|
||||
@@ -71,6 +72,7 @@ export function serve<Env = BunEnv>(
|
||||
adminOptions,
|
||||
distPath,
|
||||
serveStatic,
|
||||
beforeBuild,
|
||||
},
|
||||
args,
|
||||
),
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export * from "./bun.adapter";
|
||||
export * from "../node/storage";
|
||||
export * from "./connection/BunSqliteConnection";
|
||||
|
||||
export async function writer(path: string, content: string) {
|
||||
await Bun.write(path, content);
|
||||
}
|
||||
|
||||
export async function reader(path: string) {
|
||||
return await Bun.file(path).text();
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||
|
||||
/* beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog); */
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("cf adapter", () => {
|
||||
const DB_URL = ":memory:";
|
||||
|
||||
@@ -37,19 +37,19 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
ctx: Partial<CloudflareContext<Env>> = {},
|
||||
) {
|
||||
const appConfig = await makeConfig(
|
||||
const appConfig = await makeConfig(config, ctx);
|
||||
return await createRuntimeApp<Env>(
|
||||
{
|
||||
...config,
|
||||
...appConfig,
|
||||
onBuilt: async (app) => {
|
||||
if (ctx.ctx) {
|
||||
registerAsyncsExecutionContext(app, ctx?.ctx);
|
||||
}
|
||||
await config.onBuilt?.(app);
|
||||
await appConfig.onBuilt?.(app);
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
ctx?.env,
|
||||
);
|
||||
return await createRuntimeApp<Env>(appConfig, ctx?.env);
|
||||
}
|
||||
|
||||
// compatiblity
|
||||
|
||||
@@ -49,6 +49,8 @@ export function registerMedia(
|
||||
* @todo: add tests (bun tests won't work, need node native tests)
|
||||
*/
|
||||
export class StorageR2Adapter extends StorageAdapter {
|
||||
public keyPrefix: string = "";
|
||||
|
||||
constructor(private readonly bucket: R2Bucket) {
|
||||
super();
|
||||
}
|
||||
@@ -175,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter {
|
||||
}
|
||||
|
||||
protected getKey(key: string) {
|
||||
if (this.keyPrefix.length > 0) {
|
||||
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
+37
-21
@@ -6,18 +6,23 @@ import {
|
||||
guessMimeType,
|
||||
type MaybePromise,
|
||||
registries as $registries,
|
||||
type Merge,
|
||||
} from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
import type { Manifest } from "vite";
|
||||
|
||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||
app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||
CreateAppConfig & {
|
||||
app?:
|
||||
| Merge<Omit<BkndConfig, "app"> & Additional>
|
||||
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
} & Additional
|
||||
>;
|
||||
|
||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||
|
||||
@@ -51,11 +56,10 @@ export async function makeConfig<Args = DefaultArgs>(
|
||||
return { ...rest, ...additionalConfig };
|
||||
}
|
||||
|
||||
// a map that contains all apps by id
|
||||
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
||||
config: Config = {} as Config,
|
||||
args?: Args,
|
||||
): Promise<App> {
|
||||
): Promise<{ app: App; config: BkndConfig<Args> }> {
|
||||
await config.beforeBuild?.(undefined, $registries);
|
||||
|
||||
const appConfig = await makeConfig(config, args);
|
||||
@@ -65,34 +69,37 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
||||
connection = config.connection;
|
||||
} else {
|
||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||
const conf = appConfig.connection ?? { url: ":memory:" };
|
||||
const conf = appConfig.connection ?? { url: "file:data.db" };
|
||||
connection = sqlite(conf) as any;
|
||||
$console.info(`Using ${connection!.name} connection`, conf.url);
|
||||
}
|
||||
appConfig.connection = connection;
|
||||
}
|
||||
|
||||
return App.create(appConfig);
|
||||
return {
|
||||
app: App.create(appConfig),
|
||||
config: appConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||
config: FrameworkBkndConfig = {},
|
||||
args?: Args,
|
||||
): Promise<App> {
|
||||
const app = await createAdapterApp(config, args);
|
||||
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||
|
||||
if (!app.isBuilt()) {
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
await appConfig.onBuilt?.(app);
|
||||
},
|
||||
"sync",
|
||||
);
|
||||
}
|
||||
|
||||
await config.beforeBuild?.(app, $registries);
|
||||
await appConfig.beforeBuild?.(app, $registries);
|
||||
await app.build(config.buildConfig);
|
||||
}
|
||||
|
||||
@@ -103,7 +110,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
||||
args?: Args,
|
||||
): Promise<App> {
|
||||
const app = await createAdapterApp(config, args);
|
||||
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||
|
||||
if (!app.isBuilt()) {
|
||||
app.emgr.onEvent(
|
||||
@@ -116,7 +123,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
app.modules.server.get(path, handler);
|
||||
}
|
||||
|
||||
await config.onBuilt?.(app);
|
||||
await appConfig.onBuilt?.(app);
|
||||
if (adminOptions !== false) {
|
||||
app.registerAdminController(adminOptions);
|
||||
}
|
||||
@@ -124,7 +131,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
"sync",
|
||||
);
|
||||
|
||||
await config.beforeBuild?.(app, $registries);
|
||||
await appConfig.beforeBuild?.(app, $registries);
|
||||
await app.build(config.buildConfig);
|
||||
}
|
||||
|
||||
@@ -147,23 +154,32 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
||||
export function serveStaticViaImport(opts?: {
|
||||
manifest?: Manifest;
|
||||
appendRaw?: boolean;
|
||||
package?: string;
|
||||
}) {
|
||||
let files: string[] | undefined;
|
||||
const pkg = opts?.package ?? "bknd";
|
||||
|
||||
// @ts-ignore
|
||||
return async (c: Context, next: Next) => {
|
||||
if (!files) {
|
||||
const manifest =
|
||||
opts?.manifest ||
|
||||
((await import("bknd/dist/manifest.json", { with: { type: "json" } }))
|
||||
.default as Manifest);
|
||||
((
|
||||
await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, {
|
||||
with: { type: "json" },
|
||||
})
|
||||
).default as Manifest);
|
||||
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
|
||||
}
|
||||
|
||||
const path = c.req.path.substring(1);
|
||||
if (files.includes(path)) {
|
||||
try {
|
||||
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
||||
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`;
|
||||
const content = await import(/* @vite-ignore */ url, {
|
||||
with: { type: "text" },
|
||||
}).then((m) => m.default);
|
||||
|
||||
@@ -176,7 +192,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error serving static file:", e);
|
||||
console.error(`Error serving static file "${path}":`, String(e));
|
||||
return c.text("File not found", 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
|
||||
export async function getApp<Env = NextjsEnv>(
|
||||
config: NextjsBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? (process.env as Env));
|
||||
return await createFrameworkApp(config, args);
|
||||
}
|
||||
|
||||
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
||||
@@ -39,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
||||
|
||||
export function serve<Env = NextjsEnv>(
|
||||
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return async (req: Request) => {
|
||||
const app = await getApp(config, args);
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
export * from "./node.adapter";
|
||||
export * from "./storage";
|
||||
export * from "./connection/NodeSqliteConnection";
|
||||
|
||||
export async function writer(path: string, content: string) {
|
||||
await writeFile(path, content);
|
||||
}
|
||||
|
||||
export async function reader(path: string) {
|
||||
return await readFile(path, "utf-8");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||
|
||||
export async function createApp<Env = NodeEnv>(
|
||||
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
const root = path.relative(
|
||||
process.cwd(),
|
||||
@@ -33,19 +33,18 @@ export async function createApp<Env = NodeEnv>(
|
||||
serveStatic: serveStatic({ root }),
|
||||
...config,
|
||||
},
|
||||
// @ts-ignore
|
||||
args ?? { env: process.env },
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export function createHandler<Env = NodeEnv>(
|
||||
config: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
let app: App | undefined;
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createApp(config, args ?? (process.env as Env));
|
||||
app = await createApp(config, args);
|
||||
}
|
||||
return app.fetch(req);
|
||||
};
|
||||
@@ -53,7 +52,7 @@ export function createHandler<Env = NodeEnv>(
|
||||
|
||||
export function serve<Env = NodeEnv>(
|
||||
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
honoServe(
|
||||
{
|
||||
|
||||
@@ -8,14 +8,14 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
|
||||
|
||||
export async function getApp<Env = ReactRouterEnv>(
|
||||
config: ReactRouterBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? process.env);
|
||||
return await createFrameworkApp(config, args);
|
||||
}
|
||||
|
||||
export function serve<Env = ReactRouterEnv>(
|
||||
config: ReactRouterBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return async (fnArgs: ReactRouterFunctionArgs) => {
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
|
||||
+17
-2
@@ -2,7 +2,7 @@ import type { DB, PrimaryFieldType } from "bknd";
|
||||
import * as AuthPermissions from "auth/auth-permissions";
|
||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||
import { $console, secureRandomString, transformObject } from "bknd/utils";
|
||||
import { $console, secureRandomString, transformObject, pickKeys } from "bknd/utils";
|
||||
import type { Entity, EntityManager } from "data/entities";
|
||||
import { em, entity, enumm, type FieldSchema } from "data/prototype";
|
||||
import { Module } from "modules/Module";
|
||||
@@ -61,7 +61,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
|
||||
// register roles
|
||||
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
|
||||
return Role.create({ name, ...role });
|
||||
return Role.create(name, role);
|
||||
});
|
||||
this.ctx.guard.setRoles(Object.values(roles));
|
||||
this.ctx.guard.setConfig(this.config.guard ?? {});
|
||||
@@ -113,6 +113,19 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
return authConfigSchema;
|
||||
}
|
||||
|
||||
getGuardContextSchema() {
|
||||
const userschema = this.getUsersEntity().toSchema() as any;
|
||||
return {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: pickKeys(userschema.properties, this.config.jwt.fields as any),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get authenticator(): Authenticator {
|
||||
this.throwIfNotBuilt();
|
||||
return this._authenticator!;
|
||||
@@ -210,10 +223,12 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
}
|
||||
|
||||
const strategies = this.authenticator.getStrategies();
|
||||
const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()]));
|
||||
|
||||
return {
|
||||
...this.config,
|
||||
...this.authenticator.toJSON(secrets),
|
||||
roles,
|
||||
strategies: transformObject(strategies, (strategy) => ({
|
||||
enabled: this.isStrategyEnabled(strategy),
|
||||
...strategy.toJSON(secrets),
|
||||
|
||||
+11
-10
@@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
|
||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||
|
||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||
onTokenUpdate?: (token?: string) => void | Promise<void>;
|
||||
onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise<void>;
|
||||
credentials?: "include" | "same-origin" | "omit";
|
||||
};
|
||||
|
||||
@@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
async login(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
||||
credentials: this.options.credentials,
|
||||
});
|
||||
const res = await this.post<AuthResponse>([strategy, "login"], input);
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
await this.options.onTokenUpdate?.(res.body.token, true);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async register(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||
credentials: this.options.credentials,
|
||||
});
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input);
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
await this.options.onTokenUpdate?.(res.body.token, true);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.options.onTokenUpdate?.(undefined);
|
||||
return this.get(["logout"], undefined, {
|
||||
headers: {
|
||||
// this way bknd detects a json request and doesn't redirect back
|
||||
Accept: "application/json",
|
||||
},
|
||||
}).then(() => this.options.onTokenUpdate?.(undefined, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,10 @@ export class AuthController extends Controller {
|
||||
if (create) {
|
||||
hono.post(
|
||||
"/create",
|
||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||
permission(AuthPermissions.createUser, {}),
|
||||
permission(DataPermissions.entityCreate, {
|
||||
context: (c) => ({ entity: this.auth.config.entity_name }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Create a new user",
|
||||
tags: ["auth"],
|
||||
@@ -223,7 +226,6 @@ export class AuthController extends Controller {
|
||||
|
||||
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_create",
|
||||
{
|
||||
description: "Create a new user",
|
||||
@@ -238,14 +240,13 @@ export class AuthController extends Controller {
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||
|
||||
return c.json(await this.auth.createUser(params));
|
||||
},
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_token",
|
||||
{
|
||||
description: "Get a user token",
|
||||
@@ -255,7 +256,7 @@ export class AuthController extends Controller {
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.createToken);
|
||||
|
||||
const user = await getUser(params);
|
||||
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
|
||||
@@ -263,7 +264,6 @@ export class AuthController extends Controller {
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_password_change",
|
||||
{
|
||||
description: "Change a user's password",
|
||||
@@ -274,7 +274,7 @@ export class AuthController extends Controller {
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.changePassword);
|
||||
|
||||
const user = await getUser(params);
|
||||
if (!(await this.auth.changePassword(user.id, params.password))) {
|
||||
@@ -285,7 +285,6 @@ export class AuthController extends Controller {
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_password_test",
|
||||
{
|
||||
description: "Test a user's password",
|
||||
@@ -295,7 +294,7 @@ export class AuthController extends Controller {
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
|
||||
|
||||
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const controller = pw.getController(this.auth.authenticator);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
|
||||
export const createUser = new Permission("auth.user.create");
|
||||
//export const updateUser = new Permission("auth.user.update");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { objectTransform, s } from "bknd/utils";
|
||||
import { roleSchema } from "auth/authorize/Role";
|
||||
import { objectTransform, omitKeys, pick, s } from "bknd/utils";
|
||||
import { $object, $record } from "modules/mcp";
|
||||
|
||||
export const Strategies = {
|
||||
@@ -40,11 +41,8 @@ export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth
|
||||
const guardConfigSchema = s.object({
|
||||
enabled: s.boolean({ default: false }).optional(),
|
||||
});
|
||||
export const guardRoleSchema = s.strictObject({
|
||||
permissions: s.array(s.string()).optional(),
|
||||
is_default: s.boolean().optional(),
|
||||
implicit_allow: s.boolean().optional(),
|
||||
});
|
||||
|
||||
export const guardRoleSchema = roleSchema;
|
||||
|
||||
export const authConfigSchema = $object(
|
||||
"config_auth",
|
||||
|
||||
@@ -6,10 +6,8 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
import { type CookieOptions, serializeSigned } from "hono/utils/cookie";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import { pick } from "lodash-es";
|
||||
import { InvalidConditionsException } from "auth/errors";
|
||||
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
|
||||
import { $object } from "modules/mcp";
|
||||
import { s, parse, secret, runtimeSupports, truncate, $console, pickKeys } from "bknd/utils";
|
||||
import type { AuthStrategy } from "./strategies/Strategy";
|
||||
|
||||
type Input = any; // workaround
|
||||
@@ -44,6 +42,7 @@ export interface UserPool {
|
||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||
export const cookieConfig = s
|
||||
.strictObject({
|
||||
domain: s.string().optional(),
|
||||
path: s.string({ default: "/" }),
|
||||
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
||||
secure: s.boolean({ default: true }),
|
||||
@@ -229,7 +228,7 @@ export class Authenticator<
|
||||
|
||||
// @todo: add jwt tests
|
||||
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
|
||||
const user = pick(_user, this.config.jwt.fields);
|
||||
const user = pickKeys(_user, this.config.jwt.fields as any);
|
||||
|
||||
const payload: JWTPayload = {
|
||||
...user,
|
||||
@@ -255,7 +254,7 @@ export class Authenticator<
|
||||
}
|
||||
|
||||
async safeAuthResponse(_user: User): Promise<AuthResponse> {
|
||||
const user = pick(_user, this.config.jwt.fields) as SafeUser;
|
||||
const user = pickKeys(_user, this.config.jwt.fields as any) as SafeUser;
|
||||
return {
|
||||
user,
|
||||
token: await this.jwt(user),
|
||||
@@ -290,6 +289,7 @@ export class Authenticator<
|
||||
|
||||
return {
|
||||
...cookieConfig,
|
||||
domain: cookieConfig.domain ?? undefined,
|
||||
expires: new Date(Date.now() + expires * 1000),
|
||||
};
|
||||
}
|
||||
@@ -327,6 +327,31 @@ export class Authenticator<
|
||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||
}
|
||||
|
||||
async getAuthCookieHeader(token: string, headers = new Headers()) {
|
||||
const c = {
|
||||
header: (key: string, value: string) => {
|
||||
headers.set(key, value);
|
||||
},
|
||||
};
|
||||
await this.setAuthCookie(c as any, token);
|
||||
return headers;
|
||||
}
|
||||
|
||||
async removeAuthCookieHeader(headers = new Headers()) {
|
||||
const c = {
|
||||
header: (key: string, value: string) => {
|
||||
headers.set(key, value);
|
||||
},
|
||||
req: {
|
||||
raw: {
|
||||
headers,
|
||||
},
|
||||
},
|
||||
};
|
||||
this.deleteAuthCookie(c as any);
|
||||
return headers;
|
||||
}
|
||||
|
||||
async unsafeGetAuthCookie(token: string): Promise<string | undefined> {
|
||||
// this works for as long as cookieOptions.prefix is not set
|
||||
return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions);
|
||||
@@ -354,7 +379,10 @@ export class Authenticator<
|
||||
|
||||
// @todo: move this to a server helper
|
||||
isJsonRequest(c: Context): boolean {
|
||||
return c.req.header("Content-Type") === "application/json";
|
||||
return (
|
||||
c.req.header("Content-Type") === "application/json" ||
|
||||
c.req.header("Accept") === "application/json"
|
||||
);
|
||||
}
|
||||
|
||||
async getBody(c: Context) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { User } from "bknd";
|
||||
import type { Authenticator } from "auth/authenticate/Authenticator";
|
||||
import { InvalidCredentialsException } from "auth/errors";
|
||||
import { hash, $console, s, parse, jsc } from "bknd/utils";
|
||||
import { hash, $console, s, parse, jsc, describeRoute } from "bknd/utils";
|
||||
import { Hono } from "hono";
|
||||
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||
import { AuthStrategy } from "./Strategy";
|
||||
@@ -84,51 +84,67 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
||||
});
|
||||
const payloadSchema = this.getPayloadSchema();
|
||||
|
||||
hono.post("/login", jsc("query", redirectQuerySchema), async (c) => {
|
||||
try {
|
||||
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||
onError: (errors) => {
|
||||
$console.error("Invalid login payload", [...errors]);
|
||||
throw new InvalidCredentialsException();
|
||||
},
|
||||
});
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
|
||||
redirect,
|
||||
});
|
||||
} catch (e) {
|
||||
return authenticator.respondWithError(c, e as any);
|
||||
}
|
||||
});
|
||||
|
||||
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
|
||||
try {
|
||||
const { redirect } = c.req.valid("query");
|
||||
const { password, email, ...body } = parse(
|
||||
payloadSchema,
|
||||
await authenticator.getBody(c),
|
||||
{
|
||||
hono.post(
|
||||
"/login",
|
||||
describeRoute({
|
||||
summary: "Login with email and password",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
jsc("query", redirectQuerySchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||
onError: (errors) => {
|
||||
$console.error("Invalid register payload", [...errors]);
|
||||
new InvalidCredentialsException();
|
||||
$console.error("Invalid login payload", [...errors]);
|
||||
throw new InvalidCredentialsException();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
const profile = {
|
||||
...body,
|
||||
email,
|
||||
strategy_value: await this.hash(password),
|
||||
};
|
||||
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
|
||||
redirect,
|
||||
});
|
||||
} catch (e) {
|
||||
return authenticator.respondWithError(c, e as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
||||
redirect,
|
||||
});
|
||||
} catch (e) {
|
||||
return authenticator.respondWithError(c, e as any);
|
||||
}
|
||||
});
|
||||
hono.post(
|
||||
"/register",
|
||||
describeRoute({
|
||||
summary: "Register a new user with email and password",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
jsc("query", redirectQuerySchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const { redirect } = c.req.valid("query");
|
||||
const { password, email, ...body } = parse(
|
||||
payloadSchema,
|
||||
await authenticator.getBody(c),
|
||||
{
|
||||
onError: (errors) => {
|
||||
$console.error("Invalid register payload", [...errors]);
|
||||
new InvalidCredentialsException();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const profile = {
|
||||
...body,
|
||||
email,
|
||||
strategy_value: await this.hash(password),
|
||||
};
|
||||
|
||||
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
||||
redirect,
|
||||
});
|
||||
} catch (e) {
|
||||
return authenticator.respondWithError(c, e as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
+248
-79
@@ -1,9 +1,12 @@
|
||||
import { Exception } from "core/errors";
|
||||
import { $console, objectTransform } from "bknd/utils";
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { $console, mergeObject, type s } from "bknd/utils";
|
||||
import type { Permission, PermissionContext } from "auth/authorize/Permission";
|
||||
import type { Context } from "hono";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import { Role } from "./Role";
|
||||
import type { Role } from "./Role";
|
||||
import { HttpStatus } from "bknd/utils";
|
||||
import type { Policy, PolicySchema } from "./Policy";
|
||||
import { convert, type ObjectQuery } from "core/object/query/object-query";
|
||||
|
||||
export type GuardUserContext = {
|
||||
role?: string | null;
|
||||
@@ -12,41 +15,43 @@ export type GuardUserContext = {
|
||||
|
||||
export type GuardConfig = {
|
||||
enabled?: boolean;
|
||||
context?: object;
|
||||
};
|
||||
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||
|
||||
export class Guard {
|
||||
permissions: Permission[];
|
||||
roles?: Role[];
|
||||
config?: GuardConfig;
|
||||
export class GuardPermissionsException extends Exception {
|
||||
override name = "PermissionsException";
|
||||
override code = HttpStatus.FORBIDDEN;
|
||||
|
||||
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
|
||||
constructor(
|
||||
public permission: Permission,
|
||||
public policy?: Policy,
|
||||
public description?: string,
|
||||
) {
|
||||
super(`Permission "${permission.name}" not granted`);
|
||||
}
|
||||
|
||||
override toJSON(): any {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
description: this.description,
|
||||
permission: this.permission.name,
|
||||
policy: this.policy?.toJSON(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Guard {
|
||||
constructor(
|
||||
public permissions: Permission<any, any, any, any>[] = [],
|
||||
public roles: Role[] = [],
|
||||
public config?: GuardConfig,
|
||||
) {
|
||||
this.permissions = permissions;
|
||||
this.roles = roles;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static create(
|
||||
permissionNames: string[],
|
||||
roles?: Record<
|
||||
string,
|
||||
{
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
implicit_allow?: boolean;
|
||||
}
|
||||
>,
|
||||
config?: GuardConfig,
|
||||
) {
|
||||
const _roles = roles
|
||||
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
|
||||
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
|
||||
})
|
||||
: {};
|
||||
const _permissions = permissionNames.map((name) => new Permission(name));
|
||||
return new Guard(_permissions, Object.values(_roles), config);
|
||||
}
|
||||
|
||||
getPermissionNames(): string[] {
|
||||
return this.permissions.map((permission) => permission.name);
|
||||
}
|
||||
@@ -73,7 +78,7 @@ export class Guard {
|
||||
return this;
|
||||
}
|
||||
|
||||
registerPermission(permission: Permission) {
|
||||
registerPermission(permission: Permission<any, any, any, any>) {
|
||||
if (this.permissions.find((p) => p.name === permission.name)) {
|
||||
throw new Error(`Permission ${permission.name} already exists`);
|
||||
}
|
||||
@@ -82,9 +87,13 @@ export class Guard {
|
||||
return this;
|
||||
}
|
||||
|
||||
registerPermissions(permissions: Record<string, Permission>);
|
||||
registerPermissions(permissions: Permission[]);
|
||||
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
|
||||
registerPermissions(permissions: Record<string, Permission<any, any, any, any>>);
|
||||
registerPermissions(permissions: Permission<any, any, any, any>[]);
|
||||
registerPermissions(
|
||||
permissions:
|
||||
| Permission<any, any, any, any>[]
|
||||
| Record<string, Permission<any, any, any, any>>,
|
||||
) {
|
||||
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
|
||||
|
||||
for (const permission of p) {
|
||||
@@ -117,56 +126,216 @@ export class Guard {
|
||||
return this.config?.enabled === true;
|
||||
}
|
||||
|
||||
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
|
||||
hasPermission(name: string, user?: GuardUserContext): boolean;
|
||||
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
||||
if (!this.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
||||
$console.debug("guard: checking permission", {
|
||||
name,
|
||||
user: { id: user?.id, role: user?.role },
|
||||
});
|
||||
const exists = this.permissionExists(name);
|
||||
if (!exists) {
|
||||
throw new Error(`Permission ${name} does not exist`);
|
||||
}
|
||||
|
||||
const role = this.getUserRole(user);
|
||||
|
||||
if (!role) {
|
||||
$console.debug("guard: user has no role, denying");
|
||||
return false;
|
||||
} else if (role.implicit_allow === true) {
|
||||
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const rolePermission = role.permissions.find(
|
||||
(rolePermission) => rolePermission.permission.name === name,
|
||||
);
|
||||
|
||||
$console.debug("guard: rolePermission, allowing?", {
|
||||
permission: name,
|
||||
role: role.name,
|
||||
allowing: !!rolePermission,
|
||||
});
|
||||
return !!rolePermission;
|
||||
}
|
||||
|
||||
granted(permission: Permission | string, c?: GuardContext): boolean {
|
||||
private collect(permission: Permission, c: GuardContext | undefined, context: any) {
|
||||
const user = c && "get" in c ? c.get("auth")?.user : c;
|
||||
return this.hasPermission(permission as any, user);
|
||||
const ctx = {
|
||||
...((context ?? {}) as any),
|
||||
...this.config?.context,
|
||||
user,
|
||||
};
|
||||
const exists = this.permissionExists(permission.name);
|
||||
const role = this.getUserRole(user);
|
||||
const rolePermission = role?.permissions.find(
|
||||
(rolePermission) => rolePermission.permission.name === permission.name,
|
||||
);
|
||||
return {
|
||||
ctx,
|
||||
user,
|
||||
exists,
|
||||
role,
|
||||
rolePermission,
|
||||
};
|
||||
}
|
||||
|
||||
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
|
||||
if (!this.granted(permission, c)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
403,
|
||||
granted<P extends Permission<any, any, any, any>>(
|
||||
permission: P,
|
||||
c: GuardContext,
|
||||
context: PermissionContext<P>,
|
||||
): void;
|
||||
granted<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext): void;
|
||||
granted<P extends Permission<any, any, any, any>>(
|
||||
permission: P,
|
||||
c: GuardContext,
|
||||
context?: PermissionContext<P>,
|
||||
): void {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
|
||||
|
||||
// validate context
|
||||
let ctx = Object.assign({}, _ctx);
|
||||
if (permission.context) {
|
||||
ctx = permission.parseContext(ctx);
|
||||
}
|
||||
|
||||
$console.debug("guard: checking permission", {
|
||||
name: permission.name,
|
||||
context: ctx,
|
||||
});
|
||||
if (!exists) {
|
||||
throw new GuardPermissionsException(
|
||||
permission,
|
||||
undefined,
|
||||
`Permission ${permission.name} does not exist`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
throw new GuardPermissionsException(permission, undefined, "User has no role");
|
||||
}
|
||||
|
||||
if (!rolePermission) {
|
||||
if (role.implicit_allow === true) {
|
||||
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new GuardPermissionsException(
|
||||
permission,
|
||||
undefined,
|
||||
`Role "${role.name}" does not have required permission`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rolePermission?.policies.length > 0) {
|
||||
$console.debug("guard: rolePermission has policies, checking");
|
||||
|
||||
// set the default effect of the role permission
|
||||
let allowed = rolePermission.effect === "allow";
|
||||
for (const policy of rolePermission.policies) {
|
||||
$console.debug("guard: checking policy", { policy: policy.toJSON(), ctx });
|
||||
// skip filter policies
|
||||
if (policy.content.effect === "filter") continue;
|
||||
|
||||
// if condition is met, check the effect
|
||||
const meets = policy.meetsCondition(ctx);
|
||||
if (meets) {
|
||||
$console.debug("guard: policy meets condition");
|
||||
// if deny, then break early
|
||||
if (policy.content.effect === "deny") {
|
||||
$console.debug("guard: policy is deny, setting allowed to false");
|
||||
allowed = false;
|
||||
break;
|
||||
|
||||
// if allow, set allow but continue checking
|
||||
} else if (policy.content.effect === "allow") {
|
||||
allowed = true;
|
||||
}
|
||||
} else {
|
||||
$console.debug("guard: policy does not meet condition");
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
throw new GuardPermissionsException(permission, undefined, "Policy condition unmet");
|
||||
}
|
||||
}
|
||||
|
||||
$console.debug("guard allowing", {
|
||||
permission: permission.name,
|
||||
role: role.name,
|
||||
});
|
||||
}
|
||||
|
||||
filters<P extends Permission<any, any, any, any>>(
|
||||
permission: P,
|
||||
c: GuardContext,
|
||||
context: PermissionContext<P>,
|
||||
);
|
||||
filters<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext);
|
||||
filters<P extends Permission<any, any, any, any>>(
|
||||
permission: P,
|
||||
c: GuardContext,
|
||||
context?: PermissionContext<P>,
|
||||
) {
|
||||
if (!permission.isFilterable()) {
|
||||
throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
|
||||
}
|
||||
|
||||
const {
|
||||
ctx: _ctx,
|
||||
exists,
|
||||
role,
|
||||
user,
|
||||
rolePermission,
|
||||
} = this.collect(permission, c, context);
|
||||
|
||||
// validate context
|
||||
let ctx = Object.assign(
|
||||
{
|
||||
user,
|
||||
},
|
||||
_ctx,
|
||||
);
|
||||
|
||||
if (permission.context) {
|
||||
ctx = permission.parseContext(ctx, {
|
||||
coerceDropUnknown: false,
|
||||
});
|
||||
}
|
||||
|
||||
const filters: PolicySchema["filter"][] = [];
|
||||
const policies: Policy[] = [];
|
||||
if (exists && role && rolePermission && rolePermission.policies.length > 0) {
|
||||
for (const policy of rolePermission.policies) {
|
||||
if (policy.content.effect === "filter") {
|
||||
const meets = policy.meetsCondition(ctx);
|
||||
if (meets) {
|
||||
policies.push(policy);
|
||||
filters.push(policy.getReplacedFilter(ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined;
|
||||
return {
|
||||
filters,
|
||||
filter,
|
||||
policies,
|
||||
merge: (givenFilter: object | undefined) => {
|
||||
return mergeFilters(givenFilter ?? {}, filter ?? {});
|
||||
},
|
||||
matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => {
|
||||
const subjects = Array.isArray(subject) ? subject : [subject];
|
||||
if (policies.length > 0) {
|
||||
for (const policy of policies) {
|
||||
for (const subject of subjects) {
|
||||
if (!policy.meetsFilter(subject, ctx)) {
|
||||
if (opts?.throwOnError) {
|
||||
throw new GuardPermissionsException(
|
||||
permission,
|
||||
policy,
|
||||
"Policy filter not met",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) {
|
||||
const base_converted = convert(base);
|
||||
const priority_converted = convert(priority);
|
||||
const merged = mergeObject(base_converted, priority_converted);
|
||||
|
||||
// in case priority filter is also contained in base's $and, merge priority in
|
||||
if ("$or" in base_converted && base_converted.$or) {
|
||||
const $ors = base_converted.$or as ObjectQuery;
|
||||
const priority_keys = Object.keys(priority_converted);
|
||||
for (const key of priority_keys) {
|
||||
if (key in $ors) {
|
||||
merged.$or[key] = mergeObject($ors[key], priority_converted[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils";
|
||||
|
||||
export const permissionOptionsSchema = s
|
||||
.strictObject({
|
||||
description: s.string(),
|
||||
filterable: s.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type TPermission = {
|
||||
name: string;
|
||||
description?: string;
|
||||
filterable?: boolean;
|
||||
context?: any;
|
||||
};
|
||||
|
||||
export type PermissionOptions = s.Static<typeof permissionOptionsSchema>;
|
||||
export type PermissionContext<P extends Permission<any, any, any, any>> = P extends Permission<
|
||||
any,
|
||||
any,
|
||||
infer Context,
|
||||
any
|
||||
>
|
||||
? Context extends s.ObjectSchema
|
||||
? s.Static<Context>
|
||||
: never
|
||||
: never;
|
||||
|
||||
export class InvalidPermissionContextError extends InvalidSchemaError {
|
||||
override name = "InvalidPermissionContextError";
|
||||
|
||||
// changing to internal server error because it's an unexpected behavior
|
||||
override code = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
static from(e: InvalidSchemaError) {
|
||||
return new InvalidPermissionContextError(e.schema, e.value, e.errors);
|
||||
}
|
||||
}
|
||||
|
||||
export class Permission<
|
||||
Name extends string = string,
|
||||
Options extends PermissionOptions = {},
|
||||
Context extends s.ObjectSchema | undefined = undefined,
|
||||
ContextValue = Context extends s.ObjectSchema ? s.Static<Context> : undefined,
|
||||
> {
|
||||
constructor(
|
||||
public name: Name,
|
||||
public options: Options = {} as Options,
|
||||
public context: Context = undefined as Context,
|
||||
) {}
|
||||
|
||||
isFilterable() {
|
||||
return this.options.filterable === true;
|
||||
}
|
||||
|
||||
parseContext(ctx: ContextValue, opts?: ParseOptions) {
|
||||
// @todo: allow additional properties
|
||||
if (!this.context) return ctx;
|
||||
try {
|
||||
return this.context ? parse(this.context!, ctx, opts) : undefined;
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidSchemaError) {
|
||||
throw InvalidPermissionContextError.from(e);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
...this.options,
|
||||
context: this.context,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { s, parse, recursivelyReplacePlaceholders } from "bknd/utils";
|
||||
import * as query from "core/object/query/object-query";
|
||||
|
||||
export const policySchema = s
|
||||
.strictObject({
|
||||
description: s.string(),
|
||||
condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
|
||||
// @todo: potentially remove this, and invert from rolePermission.effect
|
||||
effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }),
|
||||
filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
|
||||
})
|
||||
.partial();
|
||||
export type PolicySchema = s.Static<typeof policySchema>;
|
||||
|
||||
export class Policy<Schema extends PolicySchema = PolicySchema> {
|
||||
public content: Schema;
|
||||
|
||||
constructor(content?: Schema) {
|
||||
this.content = parse(policySchema, content ?? {}, {
|
||||
withDefaults: true,
|
||||
}) as Schema;
|
||||
}
|
||||
|
||||
replace(context: object, vars?: Record<string, any>, fallback?: any) {
|
||||
return vars
|
||||
? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars, fallback)
|
||||
: context;
|
||||
}
|
||||
|
||||
getReplacedFilter(context: object, fallback?: any) {
|
||||
if (!this.content.filter) return context;
|
||||
return this.replace(this.content.filter!, context, fallback);
|
||||
}
|
||||
|
||||
meetsCondition(context: object, vars?: Record<string, any>) {
|
||||
if (!this.content.condition) return true;
|
||||
return query.validate(this.replace(this.content.condition!, vars), context);
|
||||
}
|
||||
|
||||
meetsFilter(subject: object, vars?: Record<string, any>) {
|
||||
if (!this.content.filter) return true;
|
||||
return query.validate(this.replace(this.content.filter!, vars), subject);
|
||||
}
|
||||
|
||||
getFiltered<Given extends any[]>(given: Given): Given {
|
||||
return given.filter((item) => this.meetsFilter(item)) as Given;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.content;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,39 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { s } from "bknd/utils";
|
||||
import { Permission } from "./Permission";
|
||||
import { Policy, policySchema } from "./Policy";
|
||||
|
||||
// default effect is allow for backward compatibility
|
||||
const defaultEffect = "allow";
|
||||
|
||||
export const rolePermissionSchema = s.strictObject({
|
||||
permission: s.string(),
|
||||
effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(),
|
||||
policies: s.array(policySchema).optional(),
|
||||
});
|
||||
export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>;
|
||||
|
||||
export const roleSchema = s.strictObject({
|
||||
// @todo: remove anyOf, add migration
|
||||
permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(),
|
||||
is_default: s.boolean().optional(),
|
||||
implicit_allow: s.boolean().optional(),
|
||||
});
|
||||
export type RoleSchema = s.Static<typeof roleSchema>;
|
||||
|
||||
export class RolePermission {
|
||||
constructor(
|
||||
public permission: Permission,
|
||||
public config?: any,
|
||||
public permission: Permission<any, any, any, any>,
|
||||
public policies: Policy[] = [],
|
||||
public effect: "allow" | "deny" = defaultEffect,
|
||||
) {}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
permission: this.permission.name,
|
||||
policies: this.policies.map((p) => p.toJSON()),
|
||||
effect: this.effect,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Role {
|
||||
@@ -15,31 +44,23 @@ export class Role {
|
||||
public implicit_allow: boolean = false,
|
||||
) {}
|
||||
|
||||
static createWithPermissionNames(
|
||||
name: string,
|
||||
permissionNames: string[],
|
||||
is_default: boolean = false,
|
||||
implicit_allow: boolean = false,
|
||||
) {
|
||||
return new Role(
|
||||
name,
|
||||
permissionNames.map((name) => new RolePermission(new Permission(name))),
|
||||
is_default,
|
||||
implicit_allow,
|
||||
);
|
||||
static create(name: string, config: RoleSchema) {
|
||||
const permissions =
|
||||
config.permissions?.map((p: string | RolePermissionSchema) => {
|
||||
if (typeof p === "string") {
|
||||
return new RolePermission(new Permission(p), []);
|
||||
}
|
||||
const policies = p.policies?.map((policy) => new Policy(policy));
|
||||
return new RolePermission(new Permission(p.permission), policies, p.effect);
|
||||
}) ?? [];
|
||||
return new Role(name, permissions, config.is_default, config.implicit_allow);
|
||||
}
|
||||
|
||||
static create(config: {
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
implicit_allow?: boolean;
|
||||
}) {
|
||||
return new Role(
|
||||
config.name,
|
||||
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
|
||||
config.is_default,
|
||||
config.implicit_allow,
|
||||
);
|
||||
toJSON() {
|
||||
return {
|
||||
permissions: this.permissions.map((p) => p.toJSON()),
|
||||
is_default: this.is_default,
|
||||
implicit_allow: this.implicit_allow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Permission } from "core/security/Permission";
|
||||
import { $console, patternMatch } from "bknd/utils";
|
||||
import type { Context } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
@@ -49,7 +48,7 @@ export const auth = (options?: {
|
||||
// make sure to only register once
|
||||
if (authCtx.registered) {
|
||||
skipped = true;
|
||||
$console.warn(`auth middleware already registered for ${getPath(c)}`);
|
||||
$console.debug(`auth middleware already registered for ${getPath(c)}`);
|
||||
} else {
|
||||
authCtx.registered = true;
|
||||
|
||||
@@ -67,48 +66,3 @@ export const auth = (options?: {
|
||||
authCtx.resolved = false;
|
||||
authCtx.user = undefined;
|
||||
});
|
||||
|
||||
export const permission = (
|
||||
permission: Permission | Permission[],
|
||||
options?: {
|
||||
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||
},
|
||||
) =>
|
||||
// @ts-ignore
|
||||
createMiddleware<ServerEnv>(async (c, next) => {
|
||||
const app = c.get("app");
|
||||
const authCtx = c.get("auth");
|
||||
if (!authCtx) {
|
||||
throw new Error("auth ctx not found");
|
||||
}
|
||||
|
||||
// in tests, app is not defined
|
||||
if (!authCtx.registered || !app) {
|
||||
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||
if (app?.module.auth.enabled) {
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
$console.warn(msg);
|
||||
}
|
||||
} else if (!authCtx.skip) {
|
||||
const guard = app.modules.ctx().guard;
|
||||
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||
|
||||
if (options?.onGranted || options?.onDenied) {
|
||||
let returned: undefined | void | Response;
|
||||
if (permissions.every((p) => guard.granted(p, c))) {
|
||||
returned = await options?.onGranted?.(c);
|
||||
} else {
|
||||
returned = await options?.onDenied?.(c);
|
||||
}
|
||||
if (returned instanceof Response) {
|
||||
return returned;
|
||||
}
|
||||
} else {
|
||||
permissions.some((p) => guard.throwUnlessGranted(p, c));
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Permission, PermissionContext } from "auth/authorize/Permission";
|
||||
import { $console, threw } from "bknd/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import type { RouterRoute } from "hono/types";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import type { MaybePromise } from "core/types";
|
||||
import { GuardPermissionsException } from "auth/authorize/Guard";
|
||||
|
||||
function getPath(reqOrCtx: Request | Context) {
|
||||
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
||||
return new URL(req.url).pathname;
|
||||
}
|
||||
|
||||
const permissionSymbol = Symbol.for("permission");
|
||||
|
||||
type PermissionMiddlewareOptions<P extends Permission<any, any, any, any>> = {
|
||||
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
|
||||
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
|
||||
} & (P extends Permission<any, any, infer PC, any>
|
||||
? PC extends undefined
|
||||
? {
|
||||
context?: never;
|
||||
}
|
||||
: {
|
||||
context: (c: Context<ServerEnv>) => MaybePromise<PermissionContext<P>>;
|
||||
}
|
||||
: {
|
||||
context?: never;
|
||||
});
|
||||
|
||||
export function permission<P extends Permission<any, any, any, any>>(
|
||||
permission: P,
|
||||
options: PermissionMiddlewareOptions<P>,
|
||||
) {
|
||||
// @ts-ignore (middlewares do not always return)
|
||||
const handler = createMiddleware<ServerEnv>(async (c, next) => {
|
||||
const app = c.get("app");
|
||||
const authCtx = c.get("auth");
|
||||
if (!authCtx) {
|
||||
throw new Error("auth ctx not found");
|
||||
}
|
||||
|
||||
// in tests, app is not defined
|
||||
if (!authCtx.registered || !app) {
|
||||
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||
if (app?.module.auth.enabled) {
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
$console.warn(msg);
|
||||
}
|
||||
} else if (!authCtx.skip) {
|
||||
const guard = app.modules.ctx().guard;
|
||||
const context = (await options?.context?.(c)) ?? ({} as any);
|
||||
|
||||
if (options?.onGranted || options?.onDenied) {
|
||||
let returned: undefined | void | Response;
|
||||
if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) {
|
||||
returned = await options?.onDenied?.(c);
|
||||
} else {
|
||||
returned = await options?.onGranted?.(c);
|
||||
}
|
||||
if (returned instanceof Response) {
|
||||
return returned;
|
||||
}
|
||||
} else {
|
||||
guard.granted(permission, c, context);
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
return Object.assign(handler, {
|
||||
[permissionSymbol]: { permission, options },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPermissionRoutes(hono: Hono<any>) {
|
||||
const routes: {
|
||||
route: RouterRoute;
|
||||
permission: Permission;
|
||||
options: PermissionMiddlewareOptions<Permission>;
|
||||
}[] = [];
|
||||
for (const route of hono.routes) {
|
||||
if (permissionSymbol in route.handler) {
|
||||
routes.push({
|
||||
route,
|
||||
...(route.handler[permissionSymbol] as any),
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import color from "picocolors";
|
||||
import { overridePackageJson, updateBkndPackages } from "./npm";
|
||||
import { type Template, templates, type TemplateSetupCtx } from "./templates";
|
||||
import { createScoped, flush } from "cli/utils/telemetry";
|
||||
import path from "node:path";
|
||||
|
||||
const config = {
|
||||
types: {
|
||||
@@ -20,6 +21,7 @@ const config = {
|
||||
node: "Node.js",
|
||||
bun: "Bun",
|
||||
cloudflare: "Cloudflare",
|
||||
deno: "Deno",
|
||||
aws: "AWS Lambda",
|
||||
},
|
||||
framework: {
|
||||
@@ -259,17 +261,19 @@ async function action(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// update package name
|
||||
await overridePackageJson(
|
||||
(pkg) => ({
|
||||
...pkg,
|
||||
name: ctx.name,
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
|
||||
// update package name if there is a package.json
|
||||
if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) {
|
||||
await overridePackageJson(
|
||||
(pkg) => ({
|
||||
...pkg,
|
||||
name: ctx.name,
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
|
||||
}
|
||||
|
||||
{
|
||||
if (template.installDeps !== false) {
|
||||
const install =
|
||||
options.yes ??
|
||||
(await $p.confirm({
|
||||
|
||||
@@ -93,17 +93,19 @@ export async function replacePackageJsonVersions(
|
||||
}
|
||||
|
||||
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
|
||||
const versions = {
|
||||
bknd: await sysGetVersion(),
|
||||
...(map ?? {}),
|
||||
};
|
||||
await replacePackageJsonVersions(
|
||||
async (pkg) => {
|
||||
if (pkg in versions) {
|
||||
return versions[pkg];
|
||||
}
|
||||
return;
|
||||
},
|
||||
{ dir },
|
||||
);
|
||||
try {
|
||||
const versions = {
|
||||
bknd: await sysGetVersion(),
|
||||
...(map ?? {}),
|
||||
};
|
||||
await replacePackageJsonVersions(
|
||||
async (pkg) => {
|
||||
if (pkg in versions) {
|
||||
return versions[pkg];
|
||||
}
|
||||
return;
|
||||
},
|
||||
{ dir },
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { overrideJson } from "cli/commands/create/npm";
|
||||
import type { Template } from "cli/commands/create/templates";
|
||||
import { getVersion } from "cli/utils/sys";
|
||||
|
||||
export const deno = {
|
||||
key: "deno",
|
||||
title: "Deno Basic",
|
||||
integration: "deno",
|
||||
description: "A basic bknd Deno server with static assets",
|
||||
path: "gh:bknd-io/bknd/examples/deno",
|
||||
installDeps: false,
|
||||
ref: true,
|
||||
setup: async (ctx) => {
|
||||
const version = await getVersion();
|
||||
await overrideJson(
|
||||
"deno.json",
|
||||
(json) => ({ ...json, links: undefined, imports: { bknd: `npm:bknd@${version}` } }),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
},
|
||||
} satisfies Template;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deno } from "cli/commands/create/templates/deno";
|
||||
import { cloudflare } from "./cloudflare";
|
||||
|
||||
export type TemplateSetupCtx = {
|
||||
@@ -15,6 +16,7 @@ export type Integration =
|
||||
| "react-router"
|
||||
| "astro"
|
||||
| "aws"
|
||||
| "deno"
|
||||
| "custom";
|
||||
|
||||
type TemplateScripts = "install" | "dev" | "build" | "start";
|
||||
@@ -34,6 +36,11 @@ export type Template = {
|
||||
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
|
||||
*/
|
||||
ref?: true | string;
|
||||
/**
|
||||
* control whether to install dependencies automatically
|
||||
* e.g. on deno, this is not needed
|
||||
*/
|
||||
installDeps?: boolean;
|
||||
scripts?: Partial<Record<TemplateScripts, string>>;
|
||||
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||
@@ -90,4 +97,5 @@ export const templates: Template[] = [
|
||||
path: "gh:bknd-io/bknd/examples/aws-lambda",
|
||||
ref: true,
|
||||
},
|
||||
deno,
|
||||
];
|
||||
|
||||
@@ -110,7 +110,10 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
|
||||
// try to use an in-memory connection
|
||||
} else if (options.memory) {
|
||||
console.info("Using", c.cyan("in-memory"), "connection");
|
||||
app = await makeApp({ server: { platform: options.server } });
|
||||
app = await makeApp({
|
||||
server: { platform: options.server },
|
||||
connection: { url: ":memory:" },
|
||||
});
|
||||
|
||||
// finally try to use env variables
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
log as $log,
|
||||
password as $password,
|
||||
text as $text,
|
||||
select as $select,
|
||||
} from "@clack/prompts";
|
||||
import type { App } from "App";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
@@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig
|
||||
server: "node",
|
||||
});
|
||||
|
||||
if (!app.module.auth.enabled) {
|
||||
$log.error("Auth is not enabled");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
await create(app, options);
|
||||
@@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig
|
||||
}
|
||||
|
||||
async function create(app: App, options: any) {
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const auth = app.module.auth;
|
||||
let role: string | null = null;
|
||||
const roles = Object.keys(auth.config.roles ?? {});
|
||||
|
||||
const strategy = auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
if (roles.length > 0) {
|
||||
role = (await $select({
|
||||
message: "Select role",
|
||||
options: [
|
||||
{
|
||||
value: null,
|
||||
label: "<none>",
|
||||
hint: "No role will be assigned to the user",
|
||||
},
|
||||
...roles.map((role) => ({
|
||||
value: role,
|
||||
label: role,
|
||||
})),
|
||||
],
|
||||
})) as any;
|
||||
if ($isCancel(role)) process.exit(1);
|
||||
}
|
||||
|
||||
if (!strategy) {
|
||||
$log.error("Password strategy not configured");
|
||||
@@ -76,6 +103,7 @@ async function create(app: App, options: any) {
|
||||
const created = await app.createUser({
|
||||
email,
|
||||
password: await strategy.hash(password as string),
|
||||
role,
|
||||
});
|
||||
$log.success(`Created user: ${c.cyan(created.email)}`);
|
||||
process.exit(0);
|
||||
|
||||
@@ -205,7 +205,17 @@ export class EventManager<
|
||||
if (listener.mode === "sync") {
|
||||
syncs.push(listener);
|
||||
} else {
|
||||
asyncs.push(async () => await listener.handler(event, listener.event.slug));
|
||||
asyncs.push(async () => {
|
||||
try {
|
||||
await listener.handler(event, listener.event.slug);
|
||||
} catch (e) {
|
||||
if (this.options?.onError) {
|
||||
this.options.onError(event, e);
|
||||
} else {
|
||||
$console.error("Error executing async listener", listener, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Remove if `once` is true, otherwise keep
|
||||
return !listener.once;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrimaryFieldType } from "core/config";
|
||||
import { getPath, invariant, isPlainObject } from "bknd/utils";
|
||||
|
||||
export type Primitive = PrimaryFieldType | string | number | boolean;
|
||||
export function isPrimitive(value: any): value is Primitive {
|
||||
@@ -25,6 +26,10 @@ export function exp<const Key, const Expect, CTX = any>(
|
||||
valid: (v: Expect) => boolean,
|
||||
validate: (e: Expect, a: unknown, ctx: CTX) => any,
|
||||
): Expression<Key, Expect, CTX> {
|
||||
invariant(typeof key === "string", "key must be a string");
|
||||
invariant(key[0] === "$", "key must start with '$'");
|
||||
invariant(typeof valid === "function", "valid must be a function");
|
||||
invariant(typeof validate === "function", "validate must be a function");
|
||||
return new Expression(key, valid, validate);
|
||||
}
|
||||
|
||||
@@ -50,7 +55,7 @@ function getExpression<Exps extends Expressions>(
|
||||
}
|
||||
|
||||
type LiteralExpressionCondition<Exps extends Expressions> = {
|
||||
[key: string]: Primitive | ExpressionCondition<Exps>;
|
||||
[key: string]: undefined | Primitive | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
const OperandOr = "$or" as const;
|
||||
@@ -67,8 +72,9 @@ function _convert<Exps extends Expressions>(
|
||||
expressions: Exps,
|
||||
path: string[] = [],
|
||||
): FilterQuery<Exps> {
|
||||
invariant(typeof $query === "object", "$query must be an object");
|
||||
const ExpressionConditionKeys = expressions.map((e) => e.key);
|
||||
const keys = Object.keys($query);
|
||||
const keys = Object.keys($query ?? {});
|
||||
const operands = [OperandOr] as const;
|
||||
const newQuery: FilterQuery<Exps> = {};
|
||||
|
||||
@@ -83,13 +89,21 @@ function _convert<Exps extends Expressions>(
|
||||
function validate(key: string, value: any, path: string[] = []) {
|
||||
const exp = getExpression(expressions, key as any);
|
||||
if (exp.valid(value) === false) {
|
||||
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
|
||||
throw new Error(
|
||||
`Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries($query)) {
|
||||
// skip undefined values
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// if $or, convert each value
|
||||
if (key === "$or") {
|
||||
invariant(isPlainObject(value), "$or must be an object");
|
||||
newQuery.$or = _convert(value, expressions, [...path, key]);
|
||||
|
||||
// if primitive, assume $eq
|
||||
@@ -98,7 +112,7 @@ function _convert<Exps extends Expressions>(
|
||||
newQuery[key] = { $eq: value };
|
||||
|
||||
// if object, check for expressions
|
||||
} else if (typeof value === "object") {
|
||||
} else if (isPlainObject(value)) {
|
||||
// when object is given, check if all keys are expressions
|
||||
const invalid = Object.keys(value).filter(
|
||||
(f) => !ExpressionConditionKeys.includes(f as any),
|
||||
@@ -112,9 +126,13 @@ function _convert<Exps extends Expressions>(
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`,
|
||||
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expression key: ${ExpressionConditionKeys.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,15 +167,19 @@ function _build<Exps extends Expressions>(
|
||||
throw new Error(`Expression does not exist: "${$op}"`);
|
||||
}
|
||||
if (!exp.valid(expected)) {
|
||||
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
|
||||
throw new Error(
|
||||
`Invalid value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`,
|
||||
);
|
||||
}
|
||||
return exp.validate(expected, actual, options.exp_ctx);
|
||||
}
|
||||
|
||||
// check $and
|
||||
for (const [key, value] of Object.entries($and)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
const objValue = options.value_is_kv ? key : getPath(options.object, key);
|
||||
result.$and.push(__validate($op, $v, objValue, [key]));
|
||||
result.keys.add(key);
|
||||
}
|
||||
@@ -165,7 +187,7 @@ function _build<Exps extends Expressions>(
|
||||
|
||||
// check $or
|
||||
for (const [key, value] of Object.entries($or ?? {})) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
const objValue = options.value_is_kv ? key : getPath(options.object, key);
|
||||
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
result.$or.push(__validate($op, $v, objValue, [key]));
|
||||
@@ -189,6 +211,10 @@ function _validate(results: ValidationResults): boolean {
|
||||
}
|
||||
|
||||
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
||||
if (!expressions.some((e) => e.key === "$eq")) {
|
||||
throw new Error("'$eq' expression is required");
|
||||
}
|
||||
|
||||
return {
|
||||
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
|
||||
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export class Permission<Name extends string = string> {
|
||||
constructor(public name: Name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,7 @@ export interface Serializable<Class, Json extends object = object> {
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||
|
||||
export type Merge<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
|
||||
@@ -240,3 +240,46 @@ export async function blobToFile(
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isFileAccepted(file: File | unknown, _accept: string | string[]): boolean {
|
||||
const accept = Array.isArray(_accept) ? _accept.join(",") : _accept;
|
||||
if (!accept || !accept.trim()) return true; // no restrictions
|
||||
if (!isFile(file)) {
|
||||
throw new Error("Given file is not a File instance");
|
||||
}
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
const type = (file.type || "").trim().toLowerCase();
|
||||
|
||||
// split on commas, trim whitespace
|
||||
const tokens = accept
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
// try each token until one matches
|
||||
return tokens.some((token) => {
|
||||
if (token.startsWith(".")) {
|
||||
// extension match, e.g. ".png" or ".tar.gz"
|
||||
return name.endsWith(token);
|
||||
}
|
||||
|
||||
const slashIdx = token.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
const [major, minor] = token.split("/");
|
||||
if (minor === "*") {
|
||||
// wildcard like "image/*"
|
||||
if (!type) return false;
|
||||
const [fMajor] = type.split("/");
|
||||
return fMajor === major;
|
||||
} else {
|
||||
// exact MIME like "image/svg+xml" or "application/pdf"
|
||||
// because of "text/plain;charset=utf-8"
|
||||
return type.startsWith(token);
|
||||
}
|
||||
}
|
||||
|
||||
// unknown token shape, ignore
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean {
|
||||
export function getPath(
|
||||
object: object,
|
||||
_path: string | (string | number)[],
|
||||
defaultValue = undefined,
|
||||
defaultValue: any = undefined,
|
||||
): any {
|
||||
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
|
||||
|
||||
@@ -512,3 +512,43 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function recursivelyReplacePlaceholders(
|
||||
obj: any,
|
||||
pattern: RegExp,
|
||||
variables: Record<string, any>,
|
||||
fallback?: any,
|
||||
) {
|
||||
if (typeof obj === "string") {
|
||||
// check if the entire string matches the pattern
|
||||
const match = obj.match(pattern);
|
||||
if (match && match[0] === obj && match[1]) {
|
||||
// full string match - replace with the actual value (preserving type)
|
||||
const key = match[1];
|
||||
const value = getPath(variables, key, null);
|
||||
return value !== null ? value : fallback !== undefined ? fallback : obj;
|
||||
}
|
||||
// partial match - use string replacement
|
||||
if (pattern.test(obj)) {
|
||||
return obj.replace(pattern, (match, key) => {
|
||||
const value = getPath(variables, key, null);
|
||||
// convert to string for partial replacements
|
||||
return value !== null
|
||||
? String(value)
|
||||
: fallback !== undefined
|
||||
? String(fallback)
|
||||
: match;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback));
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
|
||||
return acc;
|
||||
}, {} as object);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -61,3 +61,19 @@ export function invariant(condition: boolean | any, message: string) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
||||
try {
|
||||
fn();
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (instance) {
|
||||
if (e instanceof instance) {
|
||||
return true;
|
||||
}
|
||||
// if instance given but not what expected, throw
|
||||
throw e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Exception } from "core/errors";
|
||||
import { HttpStatus } from "bknd/utils";
|
||||
import * as s from "jsonv-ts";
|
||||
|
||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||
@@ -58,7 +60,10 @@ export const stringIdentifier = s.string({
|
||||
maxLength: 150,
|
||||
});
|
||||
|
||||
export class InvalidSchemaError extends Error {
|
||||
export class InvalidSchemaError extends Exception {
|
||||
override name = "InvalidSchemaError";
|
||||
override code = HttpStatus.UNPROCESSABLE_ENTITY;
|
||||
|
||||
constructor(
|
||||
public schema: s.Schema,
|
||||
public value: unknown,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { AppDataConfig } from "../data-schema";
|
||||
import type { EntityManager, EntityData } from "data/entities";
|
||||
import * as DataPermissions from "data/permissions";
|
||||
import { repoQuery, type RepoQuery } from "data/server/query";
|
||||
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||
|
||||
export class DataController extends Controller {
|
||||
constructor(
|
||||
@@ -42,7 +43,7 @@ export class DataController extends Controller {
|
||||
|
||||
override getController() {
|
||||
const { permission, auth } = this.middlewares;
|
||||
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
||||
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {}));
|
||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||
|
||||
// info
|
||||
@@ -58,7 +59,7 @@ export class DataController extends Controller {
|
||||
// sync endpoint
|
||||
hono.get(
|
||||
"/sync",
|
||||
permission(DataPermissions.databaseSync),
|
||||
permission(DataPermissions.databaseSync, {}),
|
||||
mcpTool("data_sync", {
|
||||
// @todo: should be removed if readonly
|
||||
annotations: {
|
||||
@@ -95,7 +96,9 @@ export class DataController extends Controller {
|
||||
// read entity schema
|
||||
hono.get(
|
||||
"/schema.json",
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Retrieve data schema",
|
||||
tags: ["data"],
|
||||
@@ -121,7 +124,9 @@ export class DataController extends Controller {
|
||||
// read schema
|
||||
hono.get(
|
||||
"/schemas/:entity/:context?",
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Retrieve entity schema",
|
||||
tags: ["data"],
|
||||
@@ -153,6 +158,22 @@ export class DataController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/types",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (c) => ({ module: "data" }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Retrieve data typescript definitions",
|
||||
tags: ["data"],
|
||||
}),
|
||||
mcpTool("data_types"),
|
||||
async (c) => {
|
||||
const et = new EntityTypescript(this.em);
|
||||
return c.text(et.toString());
|
||||
},
|
||||
);
|
||||
|
||||
// entity endpoints
|
||||
hono.route("/entity", this.getEntityRoutes());
|
||||
|
||||
@@ -161,7 +182,9 @@ export class DataController extends Controller {
|
||||
*/
|
||||
hono.get(
|
||||
"/info/:entity",
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Retrieve entity info",
|
||||
tags: ["data"],
|
||||
@@ -213,7 +236,9 @@ export class DataController extends Controller {
|
||||
// fn: count
|
||||
hono.post(
|
||||
"/:entity/fn/count",
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Count entities",
|
||||
tags: ["data"],
|
||||
@@ -236,7 +261,9 @@ export class DataController extends Controller {
|
||||
// fn: exists
|
||||
hono.post(
|
||||
"/:entity/fn/exists",
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Check if entity exists",
|
||||
tags: ["data"],
|
||||
@@ -285,16 +312,26 @@ export class DataController extends Controller {
|
||||
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
|
||||
entity,
|
||||
});
|
||||
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
const result = await this.em.repository(entity).findMany({
|
||||
...options,
|
||||
where: merge(options.where),
|
||||
});
|
||||
|
||||
return c.json(result, { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -308,7 +345,9 @@ export class DataController extends Controller {
|
||||
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
mcpTool("data_entity_read_one", {
|
||||
inputSchema: {
|
||||
param: s.object({ entity: entitiesEnum, id: idType }),
|
||||
@@ -326,11 +365,19 @@ export class DataController extends Controller {
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
if (!this.entityExists(entity) || !id) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findId(id, options);
|
||||
const { merge } = this.ctx.guard.filters(
|
||||
DataPermissions.entityRead,
|
||||
c,
|
||||
c.req.valid("param"),
|
||||
);
|
||||
const id_name = this.em.entity(entity).getPrimaryField().name;
|
||||
const result = await this.em
|
||||
.repository(entity)
|
||||
.findOne(merge({ [id_name]: id }), options);
|
||||
|
||||
return c.json(result, { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -344,7 +391,9 @@ export class DataController extends Controller {
|
||||
parameters: saveRepoQueryParams(),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
jsc(
|
||||
"param",
|
||||
s.object({
|
||||
@@ -361,9 +410,20 @@ export class DataController extends Controller {
|
||||
}
|
||||
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em
|
||||
const { entity: newEntity } = this.em
|
||||
.repository(entity)
|
||||
.findManyByReference(id, reference, options);
|
||||
.getEntityByReference(reference);
|
||||
|
||||
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
|
||||
entity: newEntity.name,
|
||||
id,
|
||||
reference,
|
||||
});
|
||||
|
||||
const result = await this.em.repository(entity).findManyByReference(id, reference, {
|
||||
...options,
|
||||
where: merge(options.where),
|
||||
});
|
||||
|
||||
return c.json(result, { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -390,7 +450,9 @@ export class DataController extends Controller {
|
||||
},
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
mcpTool("data_entity_read_many", {
|
||||
inputSchema: {
|
||||
param: s.object({ entity: entitiesEnum }),
|
||||
@@ -405,7 +467,13 @@ export class DataController extends Controller {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const options = c.req.valid("json") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
|
||||
entity,
|
||||
});
|
||||
const result = await this.em.repository(entity).findMany({
|
||||
...options,
|
||||
where: merge(options.where),
|
||||
});
|
||||
|
||||
return c.json(result, { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -421,7 +489,9 @@ export class DataController extends Controller {
|
||||
summary: "Insert one or many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityCreate),
|
||||
permission(DataPermissions.entityCreate, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
mcpTool("data_entity_insert"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||
@@ -438,6 +508,12 @@ export class DataController extends Controller {
|
||||
// to transform all validation targets into a single object
|
||||
const body = convertNumberedObjectToArray(_body);
|
||||
|
||||
this.ctx.guard
|
||||
.filters(DataPermissions.entityCreate, c, {
|
||||
entity,
|
||||
})
|
||||
.matches(body, { throwOnError: true });
|
||||
|
||||
if (Array.isArray(body)) {
|
||||
const result = await this.em.mutator(entity).insertMany(body);
|
||||
return c.json(result, 201);
|
||||
@@ -455,7 +531,9 @@ export class DataController extends Controller {
|
||||
summary: "Update many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
permission(DataPermissions.entityUpdate, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
mcpTool("data_entity_update_many", {
|
||||
inputSchema: {
|
||||
param: s.object({ entity: entitiesEnum }),
|
||||
@@ -482,7 +560,10 @@ export class DataController extends Controller {
|
||||
update: EntityData;
|
||||
where: RepoQuery["where"];
|
||||
};
|
||||
const result = await this.em.mutator(entity).updateWhere(update, where);
|
||||
const { merge } = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
|
||||
entity,
|
||||
});
|
||||
const result = await this.em.mutator(entity).updateWhere(update, merge(where));
|
||||
|
||||
return c.json(result);
|
||||
},
|
||||
@@ -495,7 +576,9 @@ export class DataController extends Controller {
|
||||
summary: "Update one",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
permission(DataPermissions.entityUpdate, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
mcpTool("data_entity_update_one"),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||
jsc("json", s.object({})),
|
||||
@@ -505,6 +588,17 @@ export class DataController extends Controller {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const body = (await c.req.json()) as EntityData;
|
||||
const fns = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
|
||||
entity,
|
||||
id,
|
||||
});
|
||||
|
||||
// if it has filters attached, fetch entry and make the check
|
||||
if (fns.filters.length > 0) {
|
||||
const { data } = await this.em.repository(entity).findId(id);
|
||||
fns.matches(data, { throwOnError: true });
|
||||
}
|
||||
|
||||
const result = await this.em.mutator(entity).updateOne(id, body);
|
||||
|
||||
return c.json(result);
|
||||
@@ -518,7 +612,9 @@ export class DataController extends Controller {
|
||||
summary: "Delete one",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
permission(DataPermissions.entityDelete, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
mcpTool("data_entity_delete_one"),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||
async (c) => {
|
||||
@@ -526,6 +622,18 @@ export class DataController extends Controller {
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
const fns = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
|
||||
entity,
|
||||
id,
|
||||
});
|
||||
|
||||
// if it has filters attached, fetch entry and make the check
|
||||
if (fns.filters.length > 0) {
|
||||
const { data } = await this.em.repository(entity).findId(id);
|
||||
fns.matches(data, { throwOnError: true });
|
||||
}
|
||||
|
||||
const result = await this.em.mutator(entity).deleteOne(id);
|
||||
|
||||
return c.json(result);
|
||||
@@ -539,7 +647,9 @@ export class DataController extends Controller {
|
||||
summary: "Delete many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
permission(DataPermissions.entityDelete, {
|
||||
context: (c) => ({ ...c.req.param() }) as any,
|
||||
}),
|
||||
mcpTool("data_entity_delete_many", {
|
||||
inputSchema: {
|
||||
param: s.object({ entity: entitiesEnum }),
|
||||
@@ -554,7 +664,10 @@ export class DataController extends Controller {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const where = (await c.req.json()) as RepoQuery["where"];
|
||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||
const { merge } = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
|
||||
entity,
|
||||
});
|
||||
const result = await this.em.mutator(entity).deleteWhere(merge(where));
|
||||
|
||||
return c.json(result);
|
||||
},
|
||||
|
||||
@@ -34,7 +34,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _schema?: SchemaManager;
|
||||
readonly emgr: EventManager<typeof EntityManager.Events>;
|
||||
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
||||
|
||||
@@ -249,11 +248,7 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
}
|
||||
|
||||
schema() {
|
||||
if (!this._schema) {
|
||||
this._schema = new SchemaManager(this);
|
||||
}
|
||||
|
||||
return this._schema;
|
||||
return new SchemaManager(this);
|
||||
}
|
||||
|
||||
// @todo: centralize and add tests
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
|
||||
import type { DB as DefaultDB, EntityRelation, PrimaryFieldType } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
@@ -280,16 +280,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
id: PrimaryFieldType,
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
|
||||
): Promise<RepositoryResult<TBD[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
where: { [this.entity.getPrimaryField().name]: id },
|
||||
limit: 1,
|
||||
},
|
||||
["offset", "sort"],
|
||||
);
|
||||
if (typeof id === "undefined" || id === null) {
|
||||
throw new InvalidSearchParamsException("id is required");
|
||||
}
|
||||
|
||||
return this.single(qb, options) as any;
|
||||
return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options);
|
||||
}
|
||||
|
||||
async findOne(
|
||||
@@ -315,23 +310,27 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
return res as any;
|
||||
}
|
||||
|
||||
getEntityByReference(reference: string): { entity: Entity; relation: EntityRelation } {
|
||||
const listable_relations = this.em.relations.listableRelationsOf(this.entity);
|
||||
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
|
||||
if (!relation) {
|
||||
throw new Error(
|
||||
`Relation "${reference}" not found or not listable on entity "${this.entity.name}"`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
entity: relation.other(this.entity).entity,
|
||||
relation,
|
||||
};
|
||||
}
|
||||
|
||||
// @todo: add unit tests, specially for many to many
|
||||
async findManyByReference(
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>,
|
||||
): Promise<RepositoryResult<EntityData>> {
|
||||
const entity = this.entity;
|
||||
const listable_relations = this.em.relations.listableRelationsOf(entity);
|
||||
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
|
||||
|
||||
if (!relation) {
|
||||
throw new Error(
|
||||
`Relation "${reference}" not found or not listable on entity "${entity.name}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const newEntity = relation.other(entity).entity;
|
||||
const { entity: newEntity, relation } = this.getEntityByReference(reference);
|
||||
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
|
||||
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -64,20 +64,27 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
|
||||
return false;
|
||||
}
|
||||
|
||||
override isValid(value: any): boolean {
|
||||
return this.isSerializable(value);
|
||||
}
|
||||
|
||||
override getValue(value: any, context: TRenderContext): any {
|
||||
switch (context) {
|
||||
case "form":
|
||||
if (value === null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
case "table":
|
||||
if (value === null) return null;
|
||||
return JSON.stringify(value);
|
||||
case "submit":
|
||||
if (typeof value === "string" && value.length === 0) {
|
||||
if (!value || (typeof value === "string" && value.length === 0)) {
|
||||
return null;
|
||||
} else if (typeof value === "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return JSON.parse(value);
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
|
||||
@@ -1,9 +1,51 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const entityRead = new Permission("data.entity.read");
|
||||
export const entityCreate = new Permission("data.entity.create");
|
||||
export const entityUpdate = new Permission("data.entity.update");
|
||||
export const entityDelete = new Permission("data.entity.delete");
|
||||
export const entityRead = new Permission(
|
||||
"data.entity.read",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
id: s.anyOf([s.number(), s.string()]).optional(),
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* Filter filters content given
|
||||
*/
|
||||
export const entityCreate = new Permission(
|
||||
"data.entity.create",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* Filter filters where clause
|
||||
*/
|
||||
export const entityUpdate = new Permission(
|
||||
"data.entity.update",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
id: s.anyOf([s.number(), s.string()]).optional(),
|
||||
}),
|
||||
);
|
||||
export const entityDelete = new Permission(
|
||||
"data.entity.delete",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
id: s.anyOf([s.number(), s.string()]).optional(),
|
||||
}),
|
||||
);
|
||||
export const databaseSync = new Permission("data.database.sync");
|
||||
export const rawQuery = new Permission("data.raw.query");
|
||||
export const rawMutate = new Permission("data.raw.mutate");
|
||||
|
||||
@@ -248,20 +248,16 @@ export class SchemaManager {
|
||||
|
||||
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
|
||||
const diff = await this.getDiff();
|
||||
let updates: number = 0;
|
||||
const statements: { sql: string; parameters: readonly unknown[] }[] = [];
|
||||
const schema = this.em.connection.kysely.schema;
|
||||
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
|
||||
|
||||
for (const table of diff) {
|
||||
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
|
||||
let local_updates: number = 0;
|
||||
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
|
||||
const dropFields = table.columns.drop;
|
||||
const dropIndices = table.indices.drop;
|
||||
|
||||
if (table.isDrop) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
if (config.drop) {
|
||||
qbs.push(schema.dropTable(table.name));
|
||||
}
|
||||
@@ -269,8 +265,6 @@ export class SchemaManager {
|
||||
let createQb = schema.createTable(table.name);
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
createQb = createQb.addColumn(...fieldSchema);
|
||||
}
|
||||
@@ -281,8 +275,6 @@ export class SchemaManager {
|
||||
if (addFieldSchemas.length > 0) {
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
|
||||
}
|
||||
@@ -292,8 +284,6 @@ export class SchemaManager {
|
||||
if (config.drop && dropFields.length > 0) {
|
||||
// drop fields
|
||||
for (const column of dropFields) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
qbs.push(schema.alterTable(table.name).dropColumn(column));
|
||||
}
|
||||
}
|
||||
@@ -311,35 +301,33 @@ export class SchemaManager {
|
||||
qb = qb.unique();
|
||||
}
|
||||
qbs.push(qb);
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
|
||||
// drop indices
|
||||
if (config.drop) {
|
||||
for (const index of dropIndices) {
|
||||
qbs.push(schema.dropIndex(index));
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (local_updates === 0) continue;
|
||||
if (qbs.length > 0) {
|
||||
statements.push(
|
||||
...qbs.map((qb) => {
|
||||
const { sql, parameters } = qb.compile();
|
||||
return { sql, parameters };
|
||||
}),
|
||||
);
|
||||
|
||||
// iterate through built qbs
|
||||
// @todo: run in batches
|
||||
for (const qb of qbs) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
statements.push({ sql, parameters });
|
||||
$console.debug(
|
||||
"[SchemaManager]",
|
||||
`${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
|
||||
);
|
||||
|
||||
if (config.force) {
|
||||
try {
|
||||
$console.debug("[SchemaManager]", sql);
|
||||
await qb.execute();
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.em.connection.executeQueries(...qbs);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute batch: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import * as q from "./query";
|
||||
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
||||
import type { PrimaryFieldType } from "modules";
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
const parse = (v: unknown, o: ParseOptions = {}) =>
|
||||
$parse(q.repoQuery, v, {
|
||||
@@ -186,4 +188,35 @@ describe("server/query", () => {
|
||||
decode({ with: { images: {}, comments: {} } }, output);
|
||||
}
|
||||
});
|
||||
|
||||
test("types", () => {
|
||||
const id = 1 as PrimaryFieldType;
|
||||
const id2 = "1" as unknown as Generated<string>;
|
||||
|
||||
const c: q.RepoQueryIn = {
|
||||
where: {
|
||||
// @ts-expect-error only primitives are allowed for $eq
|
||||
something: [],
|
||||
// this gets ignored
|
||||
another: undefined,
|
||||
// @ts-expect-error null is not a valid value
|
||||
null_is_okay: null,
|
||||
some_id: id,
|
||||
another_id: id2,
|
||||
},
|
||||
};
|
||||
|
||||
const d: q.RepoQuery = {
|
||||
where: {
|
||||
// @ts-expect-error only primitives are allowed for $eq
|
||||
something: [],
|
||||
// this gets ignored
|
||||
another: undefined,
|
||||
// @ts-expect-error null is not a valid value
|
||||
null_is_okay: null,
|
||||
some_id: id,
|
||||
another_id: id2,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,8 +84,6 @@ const where = s.anyOf([s.string(), s.object({})], {
|
||||
return WhereBuilder.convert(q);
|
||||
},
|
||||
});
|
||||
//type WhereSchemaIn = s.Static<typeof where>;
|
||||
//type WhereSchema = s.StaticCoerced<typeof where>;
|
||||
|
||||
// ------
|
||||
// with
|
||||
@@ -128,7 +126,7 @@ const withSchema = <Type = unknown>(self: s.Schema): s.Schema<{}, Type, Type> =>
|
||||
}
|
||||
}
|
||||
|
||||
return value as unknown as any;
|
||||
return value as any;
|
||||
},
|
||||
}) as any;
|
||||
|
||||
@@ -167,15 +165,3 @@ export type RepoQueryIn = {
|
||||
export type RepoQuery = s.StaticCoerced<typeof repoQuery> & {
|
||||
sort: SortSchema;
|
||||
};
|
||||
|
||||
//export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
|
||||
// @todo: CURRENT WORKAROUND
|
||||
/* export type RepoQuery = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: Record<string, RepoQuery>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
}; */
|
||||
|
||||
+3
-2
@@ -41,15 +41,16 @@ export { getSystemMcp } from "modules/mcp/system-mcp";
|
||||
/**
|
||||
* Core
|
||||
*/
|
||||
export type { MaybePromise } from "core/types";
|
||||
export type { MaybePromise, Merge } from "core/types";
|
||||
export { Exception, BkndError } from "core/errors";
|
||||
export { isDebug, env } from "core/env";
|
||||
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";
|
||||
export { Permission } from "core/security/Permission";
|
||||
export { Permission } from "auth/authorize/Permission";
|
||||
export { getFlashMessage } from "core/server/flash";
|
||||
export * from "core/drivers";
|
||||
export { Event, InvalidEventReturn } from "core/events/Event";
|
||||
export type {
|
||||
EventListener,
|
||||
ListenerMode,
|
||||
ListenerHandler,
|
||||
} from "core/events/EventListener";
|
||||
|
||||
@@ -22,6 +22,9 @@ declare module "bknd" {
|
||||
// @todo: current workaround to make it all required
|
||||
export class AppMedia extends Module<Required<TAppMediaConfig>> {
|
||||
private _storage?: Storage;
|
||||
options = {
|
||||
body_max_size: null as number | null,
|
||||
};
|
||||
|
||||
override async build() {
|
||||
if (!this.config.enabled) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export class MediaController extends Controller {
|
||||
summary: "Get the list of files",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.listFiles),
|
||||
permission(MediaPermissions.listFiles, {}),
|
||||
async (c) => {
|
||||
const files = await this.getStorageAdapter().listObjects();
|
||||
return c.json(files);
|
||||
@@ -51,7 +51,7 @@ export class MediaController extends Controller {
|
||||
summary: "Get a file by name",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.readFile),
|
||||
permission(MediaPermissions.readFile, {}),
|
||||
async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
@@ -81,7 +81,7 @@ export class MediaController extends Controller {
|
||||
summary: "Delete a file by name",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.deleteFile),
|
||||
permission(MediaPermissions.deleteFile, {}),
|
||||
async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
@@ -93,7 +93,10 @@ export class MediaController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
||||
const maxSize =
|
||||
this.media.options.body_max_size ??
|
||||
this.getStorage().getConfig().body_max_size ??
|
||||
Number.POSITIVE_INFINITY;
|
||||
|
||||
if (isDebug()) {
|
||||
hono.post(
|
||||
@@ -146,7 +149,7 @@ export class MediaController extends Controller {
|
||||
requestBody,
|
||||
}),
|
||||
jsc("param", s.object({ filename: s.string().optional() })),
|
||||
permission(MediaPermissions.uploadFile),
|
||||
permission(MediaPermissions.uploadFile, {}),
|
||||
async (c) => {
|
||||
const reqname = c.req.param("filename");
|
||||
|
||||
@@ -186,7 +189,10 @@ export class MediaController extends Controller {
|
||||
}),
|
||||
),
|
||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||
permission(DataPermissions.entityCreate, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
permission(MediaPermissions.uploadFile, {}),
|
||||
async (c) => {
|
||||
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
|
||||
export const readFile = new Permission("media.file.read");
|
||||
export const listFiles = new Permission("media.file.list");
|
||||
|
||||
@@ -48,7 +48,7 @@ export function buildMediaSchema() {
|
||||
{
|
||||
default: {},
|
||||
},
|
||||
);
|
||||
).strict();
|
||||
}
|
||||
|
||||
export const mediaConfigSchema = buildMediaSchema();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
import { makeModeConfig, type BkndModeConfig } from "./shared";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export type BkndCodeModeConfig<Args = any> = BkndModeConfig<Args>;
|
||||
|
||||
export type CodeMode<AdapterConfig extends BkndConfig> = AdapterConfig extends BkndConfig<
|
||||
infer Args
|
||||
>
|
||||
? BkndModeConfig<Args, AdapterConfig>
|
||||
: never;
|
||||
|
||||
export function code<Args>(config: BkndCodeModeConfig<Args>): BkndConfig<Args> {
|
||||
return {
|
||||
...config,
|
||||
app: async (args) => {
|
||||
const {
|
||||
config: appConfig,
|
||||
plugins,
|
||||
isProd,
|
||||
syncSchemaOptions,
|
||||
} = await makeModeConfig(config, args);
|
||||
|
||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
|
||||
$console.warn("You should not set a different mode than `db` when using code mode");
|
||||
}
|
||||
|
||||
return {
|
||||
...appConfig,
|
||||
options: {
|
||||
...appConfig?.options,
|
||||
mode: "code",
|
||||
plugins,
|
||||
manager: {
|
||||
// skip validation in prod for a speed boost
|
||||
skipValidation: isProd,
|
||||
onModulesBuilt: async (ctx) => {
|
||||
if (!isProd && syncSchemaOptions.force) {
|
||||
$console.log("[code] syncing schema");
|
||||
await ctx.em.schema().sync(syncSchemaOptions);
|
||||
}
|
||||
},
|
||||
...appConfig?.options?.manager,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
import { makeModeConfig, type BkndModeConfig } from "./shared";
|
||||
import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd";
|
||||
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { invariant, $console } from "bknd/utils";
|
||||
|
||||
export type BkndHybridModeOptions = {
|
||||
/**
|
||||
* Reader function to read the configuration from the file system.
|
||||
* This is required for hybrid mode to work.
|
||||
*/
|
||||
reader?: (path: string) => MaybePromise<string>;
|
||||
/**
|
||||
* Provided secrets to be merged into the configuration
|
||||
*/
|
||||
secrets?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type HybridBkndConfig<Args = any> = BkndModeConfig<Args, BkndHybridModeOptions>;
|
||||
export type HybridMode<AdapterConfig extends BkndConfig> = AdapterConfig extends BkndConfig<
|
||||
infer Args
|
||||
>
|
||||
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
|
||||
: never;
|
||||
|
||||
export function hybrid<Args>({
|
||||
configFilePath = "bknd-config.json",
|
||||
...rest
|
||||
}: HybridBkndConfig<Args>): BkndConfig<Args> {
|
||||
return {
|
||||
...rest,
|
||||
config: undefined,
|
||||
app: async (args) => {
|
||||
const {
|
||||
config: appConfig,
|
||||
isProd,
|
||||
plugins,
|
||||
syncSchemaOptions,
|
||||
} = await makeModeConfig(
|
||||
{
|
||||
...rest,
|
||||
configFilePath,
|
||||
},
|
||||
args,
|
||||
);
|
||||
|
||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") {
|
||||
$console.warn("You should not set a different mode than `db` when using hybrid mode");
|
||||
}
|
||||
invariant(
|
||||
typeof appConfig.reader === "function",
|
||||
"You must set the `reader` option when using hybrid mode",
|
||||
);
|
||||
|
||||
let fileConfig: ModuleConfigs;
|
||||
try {
|
||||
fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs;
|
||||
} catch (e) {
|
||||
const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs;
|
||||
await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2));
|
||||
fileConfig = defaultConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...(appConfig as any),
|
||||
beforeBuild: async (app) => {
|
||||
if (app && !isProd) {
|
||||
const mm = app.modules as DbModuleManager;
|
||||
mm.buildSyncConfig = syncSchemaOptions;
|
||||
}
|
||||
await appConfig.beforeBuild?.(app);
|
||||
},
|
||||
config: fileConfig,
|
||||
options: {
|
||||
...appConfig?.options,
|
||||
mode: isProd ? "code" : "db",
|
||||
plugins,
|
||||
manager: {
|
||||
// skip validation in prod for a speed boost
|
||||
skipValidation: isProd,
|
||||
// secrets are required for hybrid mode
|
||||
secrets: appConfig.secrets,
|
||||
...appConfig?.options?.manager,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./code";
|
||||
export * from "./hybrid";
|
||||
export * from "./shared";
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
|
||||
import { syncTypes, syncConfig } from "bknd/plugins";
|
||||
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
|
||||
import { invariant, $console } from "bknd/utils";
|
||||
|
||||
export type BkndModeOptions = {
|
||||
/**
|
||||
* Whether the application is running in production.
|
||||
*/
|
||||
isProduction?: boolean;
|
||||
/**
|
||||
* Writer function to write the configuration to the file system
|
||||
*/
|
||||
writer?: (path: string, content: string) => MaybePromise<void>;
|
||||
/**
|
||||
* Configuration file path
|
||||
*/
|
||||
configFilePath?: string;
|
||||
/**
|
||||
* Types file path
|
||||
* @default "bknd-types.d.ts"
|
||||
*/
|
||||
typesFilePath?: string;
|
||||
/**
|
||||
* Syncing secrets options
|
||||
*/
|
||||
syncSecrets?: {
|
||||
/**
|
||||
* Whether to enable syncing secrets
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Output file path
|
||||
*/
|
||||
outFile?: string;
|
||||
/**
|
||||
* Format of the output file
|
||||
* @default "env"
|
||||
*/
|
||||
format?: "json" | "env";
|
||||
/**
|
||||
* Whether to include secrets in the output file
|
||||
* @default false
|
||||
*/
|
||||
includeSecrets?: boolean;
|
||||
};
|
||||
/**
|
||||
* Determines whether to automatically sync the schema if not in production.
|
||||
* @default true
|
||||
*/
|
||||
syncSchema?: boolean | { force?: boolean; drop?: boolean };
|
||||
};
|
||||
|
||||
export type BkndModeConfig<Args = any, Additional = {}> = BkndConfig<
|
||||
Args,
|
||||
Merge<BkndModeOptions & Additional>
|
||||
>;
|
||||
|
||||
export async function makeModeConfig<
|
||||
Args = any,
|
||||
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
|
||||
>({ app, ..._config }: Config, args: Args) {
|
||||
const appConfig = typeof app === "function" ? await app(args) : app;
|
||||
|
||||
const config = {
|
||||
..._config,
|
||||
...appConfig,
|
||||
} as Omit<Config, "app">;
|
||||
|
||||
if (typeof config.isProduction !== "boolean") {
|
||||
$console.warn(
|
||||
"You should set `isProduction` option when using managed modes to prevent accidental issues",
|
||||
);
|
||||
}
|
||||
|
||||
invariant(
|
||||
typeof config.writer === "function",
|
||||
"You must set the `writer` option when using managed modes",
|
||||
);
|
||||
|
||||
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
|
||||
|
||||
const isProd = config.isProduction;
|
||||
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const syncSchemaOptions =
|
||||
typeof config.syncSchema === "object"
|
||||
? config.syncSchema
|
||||
: {
|
||||
force: config.syncSchema !== false,
|
||||
drop: true,
|
||||
};
|
||||
|
||||
if (!isProd) {
|
||||
if (typesFilePath) {
|
||||
if (plugins.some((p) => p.name === "bknd-sync-types")) {
|
||||
throw new Error("You have to unregister the `syncTypes` plugin");
|
||||
}
|
||||
plugins.push(
|
||||
syncTypes({
|
||||
enabled: true,
|
||||
includeFirstBoot: true,
|
||||
write: async (et) => {
|
||||
try {
|
||||
await config.writer?.(typesFilePath, et.toString());
|
||||
} catch (e) {
|
||||
console.error(`Error writing types to"${typesFilePath}"`, e);
|
||||
}
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
if (configFilePath) {
|
||||
if (plugins.some((p) => p.name === "bknd-sync-config")) {
|
||||
throw new Error("You have to unregister the `syncConfig` plugin");
|
||||
}
|
||||
plugins.push(
|
||||
syncConfig({
|
||||
enabled: true,
|
||||
includeFirstBoot: true,
|
||||
write: async (config) => {
|
||||
try {
|
||||
await writer?.(configFilePath, JSON.stringify(config, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Error writing config to "${configFilePath}"`, e);
|
||||
}
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
|
||||
if (syncSecretsOptions && syncSecretsOptions.enabled !== false) {
|
||||
if (plugins.some((p) => p.name === "bknd-sync-secrets")) {
|
||||
throw new Error("You have to unregister the `syncSecrets` plugin");
|
||||
}
|
||||
|
||||
let outFile = syncSecretsOptions.outFile;
|
||||
const format = syncSecretsOptions.format ?? "env";
|
||||
if (!outFile) {
|
||||
outFile = ["env", !syncSecretsOptions.includeSecrets && "example", format]
|
||||
.filter(Boolean)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
plugins.push(
|
||||
syncSecrets({
|
||||
enabled: true,
|
||||
includeFirstBoot: true,
|
||||
write: async (secrets) => {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(secrets).map(([key, value]) => [
|
||||
key,
|
||||
syncSecretsOptions.includeSecrets ? value : "",
|
||||
]),
|
||||
);
|
||||
|
||||
try {
|
||||
if (format === "env") {
|
||||
await writer?.(
|
||||
outFile,
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n"),
|
||||
);
|
||||
} else {
|
||||
await writer?.(outFile, JSON.stringify(values, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error writing secrets to "${outFile}"`, e);
|
||||
}
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
isProd,
|
||||
plugins,
|
||||
syncSchemaOptions,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { entityTypes } from "data/entities/Entity";
|
||||
import { isEqual } from "lodash-es";
|
||||
import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module";
|
||||
import type { EntityRelation } from "data/relations";
|
||||
import type { Permission } from "core/security/Permission";
|
||||
import type { Permission, PermissionContext } from "auth/authorize/Permission";
|
||||
import { Exception } from "core/errors";
|
||||
import { invariant, isPlainObject } from "bknd/utils";
|
||||
|
||||
@@ -114,10 +114,20 @@ export class ModuleHelper {
|
||||
entity.__replaceField(name, newField);
|
||||
}
|
||||
|
||||
async throwUnlessGranted(
|
||||
permission: Permission | string,
|
||||
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;
|
||||
@@ -127,12 +137,6 @@ export class ModuleHelper {
|
||||
}
|
||||
|
||||
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
|
||||
|
||||
if (!this.ctx.guard.granted(permission, user)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
403,
|
||||
);
|
||||
}
|
||||
this.ctx.guard.granted(permission, user as any, context as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager {
|
||||
private readonly _booted_with?: "provided" | "partial";
|
||||
private _stable_configs: ModuleConfigs | undefined;
|
||||
|
||||
// config used when syncing database
|
||||
public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true };
|
||||
|
||||
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
|
||||
let initial = {} as InitialModuleConfigs;
|
||||
let booted_with = "partial" as any;
|
||||
@@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
|
||||
const version_before = this.version();
|
||||
const [_version, _configs] = await migrate(version_before, result.configs.json, {
|
||||
db: this.db
|
||||
db: this.db,
|
||||
});
|
||||
|
||||
this._version = _version;
|
||||
@@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
this.logger.log("db sync requested");
|
||||
|
||||
// sync db
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await ctx.em.schema().sync(this.buildSyncConfig);
|
||||
state.synced = true;
|
||||
|
||||
// save
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { auth, permission } from "auth/middlewares";
|
||||
export { auth } from "auth/middlewares/auth.middleware";
|
||||
export { permission } from "auth/middlewares/permission.middleware";
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const accessAdmin = new Permission("system.access.admin");
|
||||
export const accessApi = new Permission("system.access.api");
|
||||
export const configRead = new Permission("system.config.read");
|
||||
export const configReadSecrets = new Permission("system.config.read.secrets");
|
||||
export const configWrite = new Permission("system.config.write");
|
||||
export const schemaRead = new Permission("system.schema.read");
|
||||
export const configRead = new Permission(
|
||||
"system.config.read",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const configReadSecrets = new Permission(
|
||||
"system.config.read.secrets",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const configWrite = new Permission(
|
||||
"system.config.write",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const schemaRead = new Permission(
|
||||
"system.schema.read",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const build = new Permission("system.build");
|
||||
export const mcp = new Permission("system.mcp");
|
||||
|
||||
@@ -114,8 +114,9 @@ export class AdminController extends Controller {
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
||||
addFlashMessage(c, "You are not allowed to read the schema", "warning");
|
||||
},
|
||||
context: (c) => ({}),
|
||||
}),
|
||||
async (c) => {
|
||||
const obj: AdminBkndWindowContext = {
|
||||
@@ -139,17 +140,19 @@ export class AdminController extends Controller {
|
||||
}
|
||||
|
||||
if (auth_enabled) {
|
||||
const options = {
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (c.get("auth")?.user) {
|
||||
$console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
},
|
||||
context: (c) => ({}),
|
||||
};
|
||||
const redirectRouteParams = [
|
||||
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
|
||||
// @ts-ignore
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (c.get("auth")?.user) {
|
||||
$console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
},
|
||||
}),
|
||||
permission(SystemPermissions.accessAdmin, options as any),
|
||||
permission(SystemPermissions.schemaRead, options),
|
||||
async (c) => {
|
||||
return c.html(c.get("html")!);
|
||||
},
|
||||
|
||||
@@ -52,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,
|
||||
@@ -87,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mcp as mcpMiddleware,
|
||||
isNode,
|
||||
type McpServer,
|
||||
threw,
|
||||
} from "bknd/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
@@ -32,6 +33,7 @@ import { getVersion } from "core/env";
|
||||
import type { Module } from "modules/Module";
|
||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
|
||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||
success: true;
|
||||
@@ -46,7 +48,8 @@ export type SchemaResponse = {
|
||||
schema: ModuleSchemas;
|
||||
readonly: boolean;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
//permissions: string[];
|
||||
permissions: TPermission[];
|
||||
};
|
||||
|
||||
export class SystemController extends Controller {
|
||||
@@ -67,10 +70,14 @@ export class SystemController extends Controller {
|
||||
if (!config.mcp.enabled) {
|
||||
return;
|
||||
}
|
||||
const { permission, auth } = this.middlewares;
|
||||
|
||||
this.registerMcp();
|
||||
|
||||
app.server.use(
|
||||
app.server.all(
|
||||
config.mcp.path,
|
||||
auth(),
|
||||
permission(SystemPermissions.mcp, {}),
|
||||
mcpMiddleware({
|
||||
setup: async () => {
|
||||
if (!this._mcpServer) {
|
||||
@@ -108,7 +115,6 @@ export class SystemController extends Controller {
|
||||
explainEndpoint: true,
|
||||
},
|
||||
endpoint: {
|
||||
path: config.mcp.path as any,
|
||||
// @ts-ignore
|
||||
_init: isNode() ? { duplex: "half" } : {},
|
||||
},
|
||||
@@ -119,7 +125,7 @@ export class SystemController extends Controller {
|
||||
private registerConfigController(client: Hono<any>): void {
|
||||
const { permission } = this.middlewares;
|
||||
// don't add auth again, it's already added in getController
|
||||
const hono = this.create().use(permission(SystemPermissions.configRead));
|
||||
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
|
||||
|
||||
if (!this.app.isReadOnly()) {
|
||||
const manager = this.app.modules as DbModuleManager;
|
||||
@@ -130,7 +136,11 @@ export class SystemController extends Controller {
|
||||
summary: "Get the raw config",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission([SystemPermissions.configReadSecrets]),
|
||||
permission(SystemPermissions.configReadSecrets, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @ts-expect-error "fetch" is private
|
||||
return c.json(await this.app.modules.fetch().then((r) => r?.configs));
|
||||
@@ -165,7 +175,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.post(
|
||||
"/set/:module",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -194,32 +208,44 @@ export class SystemController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path") as string;
|
||||
hono.post(
|
||||
"/add/:module/:path",
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path") as string;
|
||||
|
||||
if (this.app.modules.get(module).schema().has(path)) {
|
||||
return c.json(
|
||||
{ success: false, path, error: "Path already exists" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (this.app.modules.get(module).schema().has(path)) {
|
||||
return c.json(
|
||||
{ success: false, path, error: "Path already exists" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await manager.mutateConfigSafe(module).patch(path, value);
|
||||
return {
|
||||
success: true,
|
||||
module,
|
||||
config: this.app.module[module].config,
|
||||
};
|
||||
});
|
||||
});
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await manager.mutateConfigSafe(module).patch(path, value);
|
||||
return {
|
||||
success: true,
|
||||
module,
|
||||
config: this.app.module[module].config,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
hono.patch(
|
||||
"/patch/:module/:path",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -239,7 +265,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.put(
|
||||
"/overwrite/:module/:path",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -259,7 +289,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.delete(
|
||||
"/remove/:module/:path",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -295,7 +329,11 @@ export class SystemController extends Controller {
|
||||
const { secrets } = c.req.valid("query");
|
||||
const { module } = c.req.valid("param");
|
||||
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
if (secrets) {
|
||||
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
|
||||
const config = this.app.toJSON(secrets);
|
||||
|
||||
@@ -326,7 +364,11 @@ export class SystemController extends Controller {
|
||||
summary: "Get the schema for a module",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
jsc(
|
||||
"query",
|
||||
s
|
||||
@@ -340,10 +382,22 @@ export class SystemController extends Controller {
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as ModuleKey | undefined;
|
||||
const { config, secrets, fresh } = c.req.valid("query");
|
||||
const readonly = this.app.isReadOnly();
|
||||
const readonly =
|
||||
// either if app is read only in general
|
||||
this.app.isReadOnly() ||
|
||||
// or if user is not allowed to modify the config
|
||||
threw(() => this.ctx.guard.granted(SystemPermissions.configWrite, c, { module }));
|
||||
|
||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
if (config) {
|
||||
this.ctx.guard.granted(SystemPermissions.configRead, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
if (secrets) {
|
||||
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
|
||||
const { version, ...schema } = this.app.getSchema();
|
||||
|
||||
@@ -368,11 +422,23 @@ export class SystemController extends Controller {
|
||||
readonly,
|
||||
schema,
|
||||
config: config ? this.app.toJSON(secrets) : undefined,
|
||||
permissions: this.app.modules.ctx().guard.getPermissionNames(),
|
||||
permissions: this.app.modules.ctx().guard.getPermissions(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/permissions",
|
||||
describeRoute({
|
||||
summary: "Get the permissions",
|
||||
tags: ["system"],
|
||||
}),
|
||||
(c) => {
|
||||
const permissions = this.app.modules.ctx().guard.getPermissions();
|
||||
return c.json({ permissions, context: this.app.module.auth.getGuardContextSchema() });
|
||||
},
|
||||
);
|
||||
|
||||
hono.post(
|
||||
"/build",
|
||||
describeRoute({
|
||||
@@ -383,7 +449,7 @@ export class SystemController extends Controller {
|
||||
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const options = c.req.valid("query") as Record<string, boolean>;
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||
this.ctx.guard.granted(SystemPermissions.build, c);
|
||||
|
||||
await this.app.build(options);
|
||||
return c.json({
|
||||
@@ -455,7 +521,7 @@ export class SystemController extends Controller {
|
||||
const { version, ...appConfig } = this.app.toJSON();
|
||||
|
||||
mcp.resource("system_config", "bknd://system/config", async (c) => {
|
||||
await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {});
|
||||
|
||||
return c.json(this.app.toJSON(), {
|
||||
title: "System Config",
|
||||
@@ -465,7 +531,9 @@ export class SystemController extends Controller {
|
||||
"system_config_module",
|
||||
"bknd://system/config/{module}",
|
||||
async (c, { module }) => {
|
||||
await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
await this.ctx.helper.granted(c, SystemPermissions.configRead, {
|
||||
module,
|
||||
});
|
||||
|
||||
const m = this.app.modules.get(module as any) as Module;
|
||||
return c.json(m.toJSON(), {
|
||||
@@ -477,7 +545,7 @@ export class SystemController extends Controller {
|
||||
},
|
||||
)
|
||||
.resource("system_schema", "bknd://system/schema", async (c) => {
|
||||
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
|
||||
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {});
|
||||
|
||||
return c.json(this.app.getSchema(), {
|
||||
title: "System Schema",
|
||||
@@ -487,7 +555,9 @@ export class SystemController extends Controller {
|
||||
"system_schema_module",
|
||||
"bknd://system/schema/{module}",
|
||||
async (c, { module }) => {
|
||||
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
|
||||
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {
|
||||
module,
|
||||
});
|
||||
|
||||
const m = this.app.modules.get(module as any);
|
||||
return c.json(m.getSchema().toJSON(), {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { timestamps } from "./timestamps.plugin";
|
||||
import { em, entity, text } from "bknd";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("timestamps plugin", () => {
|
||||
test("should ignore if no or invalid entities are provided", async () => {
|
||||
const app = createApp({
|
||||
options: {
|
||||
plugins: [timestamps({ entities: [] })],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual([]);
|
||||
|
||||
{
|
||||
const app = createApp({
|
||||
options: {
|
||||
plugins: [timestamps({ entities: ["posts"] })],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should add timestamps to the specified entities", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
posts: entity("posts", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [timestamps({ entities: ["posts", "invalid"] })],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]);
|
||||
expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([
|
||||
"id",
|
||||
"title",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]);
|
||||
|
||||
// insert
|
||||
const mutator = app.em.mutator(app.em.entity("posts"));
|
||||
const { data } = await mutator.insertOne({ title: "Hello" });
|
||||
expect(data.created_at).toBeDefined();
|
||||
expect(data.updated_at).toBeDefined();
|
||||
expect(data.created_at).toBeInstanceOf(Date);
|
||||
expect(data.updated_at).toBeInstanceOf(Date);
|
||||
const diff = data.created_at.getTime() - data.updated_at.getTime();
|
||||
expect(diff).toBeLessThan(10);
|
||||
expect(diff).toBeGreaterThan(-1);
|
||||
|
||||
// update (set updated_at to null, otherwise it's too fast to test)
|
||||
await app.em.connection.kysely
|
||||
.updateTable("posts")
|
||||
.set({ updated_at: null })
|
||||
.where("id", "=", data.id)
|
||||
.execute();
|
||||
const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" });
|
||||
expect(updatedData.updated_at).toBeDefined();
|
||||
expect(updatedData.updated_at).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export type TimestampsPluginOptions = {
|
||||
entities: string[];
|
||||
setUpdatedOnCreate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This plugin adds `created_at` and `updated_at` fields to the specified entities.
|
||||
* Add it to your plugins in `bknd.config.ts` like this:
|
||||
*
|
||||
* ```ts
|
||||
* export default {
|
||||
* plugins: [timestamps({ entities: ["posts"] })],
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function timestamps({
|
||||
entities = [],
|
||||
setUpdatedOnCreate = true,
|
||||
}: TimestampsPluginOptions): AppPlugin {
|
||||
return (app: App) => ({
|
||||
name: "timestamps",
|
||||
schema: () => {
|
||||
if (entities.length === 0) {
|
||||
$console.warn("No entities specified for timestamps plugin");
|
||||
return;
|
||||
}
|
||||
|
||||
const appEntities = app.em.entities.map((e) => e.name);
|
||||
|
||||
return em(
|
||||
Object.fromEntries(
|
||||
entities
|
||||
.filter((e) => appEntities.includes(e))
|
||||
.map((e) => [
|
||||
e,
|
||||
entity(e, {
|
||||
created_at: datetime(),
|
||||
updated_at: datetime(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
onBuilt: async () => {
|
||||
app.emgr.onEvent(
|
||||
DatabaseEvents.MutatorInsertBefore,
|
||||
(event) => {
|
||||
const { entity, data } = event.params;
|
||||
if (entities.includes(entity.name)) {
|
||||
return {
|
||||
...data,
|
||||
created_at: new Date(),
|
||||
updated_at: setUpdatedOnCreate ? new Date() : null,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-timestamps",
|
||||
},
|
||||
);
|
||||
|
||||
app.emgr.onEvent(
|
||||
DatabaseEvents.MutatorUpdateBefore,
|
||||
async (event) => {
|
||||
const { entity, data } = event.params;
|
||||
if (entities.includes(entity.name)) {
|
||||
return {
|
||||
...data,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
}
|
||||
return data;
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-timestamps",
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
|
||||
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
|
||||
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||
|
||||
@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import type { BkndAdminProps } from "ui/Admin";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
|
||||
export type BkndContext = {
|
||||
version: number;
|
||||
readonly: boolean;
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
permissions: TPermission[];
|
||||
hasSecrets: boolean;
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
@@ -122,11 +123,14 @@ export function BkndProvider({
|
||||
fetching.current = Fetching.None;
|
||||
};
|
||||
|
||||
if ("startViewTransition" in document) {
|
||||
// disable view transitions for now
|
||||
// because it causes browser crash on heavy pages (e.g. schema)
|
||||
commit();
|
||||
/* if ("startViewTransition" in document) {
|
||||
document.startViewTransition(commit);
|
||||
} else {
|
||||
commit();
|
||||
}
|
||||
} */
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ export const ClientProvider = ({
|
||||
[JSON.stringify(apiProps)],
|
||||
);
|
||||
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
|
||||
apiProps.user ? api.getAuthState() : undefined,
|
||||
);
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Api } from "Api";
|
||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useApi } from "ui/client";
|
||||
import { useState } from "react";
|
||||
@@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => {
|
||||
return mutate((k) => typeof k === "string" && k.startsWith(key));
|
||||
};
|
||||
};
|
||||
|
||||
const mountOnceCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Simple middleware to only load on first mount.
|
||||
*/
|
||||
export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
|
||||
if (typeof key === "string") {
|
||||
if (mountOnceCache.has(key)) {
|
||||
return useSWRNext(key, fetcher, {
|
||||
...config,
|
||||
revalidateOnMount: false,
|
||||
});
|
||||
}
|
||||
const swr = useSWRNext(key, fetcher, config);
|
||||
if (swr.data) {
|
||||
mountOnceCache.set(key, true);
|
||||
}
|
||||
return swr;
|
||||
}
|
||||
return useSWRNext(key, fetcher, config);
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ type UseAuth = {
|
||||
verified: boolean;
|
||||
login: (data: LoginData) => Promise<AuthResponse>;
|
||||
register: (data: LoginData) => Promise<AuthResponse>;
|
||||
logout: () => void;
|
||||
verify: () => void;
|
||||
logout: () => Promise<void>;
|
||||
verify: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
};
|
||||
|
||||
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
api.updateToken(undefined);
|
||||
invalidate();
|
||||
await api.auth.logout();
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await api.verifyAuth();
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useBkndAuth() {
|
||||
has_admin: Object.entries(config.auth.roles ?? {}).some(
|
||||
([name, role]) =>
|
||||
role.implicit_allow ||
|
||||
minimum_permissions.every((p) => role.permissions?.includes(p)),
|
||||
minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)),
|
||||
),
|
||||
},
|
||||
routes: {
|
||||
|
||||
@@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
const sizes = {
|
||||
smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs",
|
||||
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-1.5",
|
||||
large: "px-4 py-3 rounded-md gap-2.5 text-lg",
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 12,
|
||||
smaller: 12,
|
||||
small: 14,
|
||||
default: 16,
|
||||
large: 20,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { cn, importDynamicBrowserModule } from "ui/lib/utils";
|
||||
|
||||
export type CodePreviewProps = {
|
||||
code: string;
|
||||
className?: string;
|
||||
lang?: string;
|
||||
theme?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const CodePreview = ({
|
||||
code,
|
||||
className,
|
||||
lang = "typescript",
|
||||
theme: _theme,
|
||||
enabled = true,
|
||||
}: CodePreviewProps) => {
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
|
||||
const $theme = useTheme();
|
||||
const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light";
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHighlightedHtml(null);
|
||||
|
||||
async function highlightCode() {
|
||||
try {
|
||||
// Dynamically import Shiki from CDN
|
||||
const { codeToHtml } = await importDynamicBrowserModule(
|
||||
"shiki",
|
||||
"https://esm.sh/shiki@3.13.0",
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const html = await codeToHtml(code, {
|
||||
lang,
|
||||
theme,
|
||||
structure: "inline",
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setHighlightedHtml(html);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Shiki:", error);
|
||||
// Fallback to plain text if Shiki fails to load
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlightCode();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, enabled]);
|
||||
|
||||
if (!highlightedHtml) {
|
||||
return <pre className={cn("select-text cursor-text", className)}>{code}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn("select-text cursor-text", className)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,68 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { CodeEditorProps } from "./CodeEditor";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
|
||||
export type JsonEditorProps = Omit<CodeEditorProps, "value" | "onChange"> & {
|
||||
value?: object;
|
||||
onChange?: (value: object) => void;
|
||||
emptyAs?: any;
|
||||
onInvalid?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function JsonEditor({
|
||||
editable,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
emptyAs = undefined,
|
||||
onInvalid,
|
||||
...props
|
||||
}: JsonEditorProps) {
|
||||
const [editorValue, setEditorValue] = useState<string | null | undefined>(
|
||||
value ? JSON.stringify(value, null, 2) : emptyAs,
|
||||
);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const handleChange = useDebouncedCallback((given: string) => {
|
||||
try {
|
||||
setError(false);
|
||||
onChange?.(given ? JSON.parse(given) : emptyAs);
|
||||
} catch (e) {
|
||||
onInvalid?.(e as Error);
|
||||
setError(true);
|
||||
}
|
||||
}, 250);
|
||||
const handleBlur = (e) => {
|
||||
try {
|
||||
const formatted = JSON.stringify(value, null, 2);
|
||||
setEditorValue(formatted);
|
||||
} catch (e) {}
|
||||
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorValue) {
|
||||
setEditorValue(value ? JSON.stringify(value, null, 2) : emptyAs);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted",
|
||||
!editable && "opacity-70",
|
||||
error && "border-red-500",
|
||||
className,
|
||||
)}
|
||||
editable={editable}
|
||||
_extensions={{ json: true }}
|
||||
value={editorValue ?? undefined}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -28,8 +28,9 @@ export const Group = <E extends ElementType = "div">({
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
data-role="group"
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed",
|
||||
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed w-full",
|
||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||
as === "fieldset" && error && "border-red-500",
|
||||
error && "text-red-500",
|
||||
|
||||
@@ -5,19 +5,29 @@ import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent } from "./Field";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { Field, FieldComponent, type FieldProps } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { FormContextOverride, useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
||||
|
||||
export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
export type ArrayFieldProps = {
|
||||
path?: string;
|
||||
labelAdd?: string;
|
||||
wrapperProps?: Omit<FieldwrapperProps, "name" | "children">;
|
||||
};
|
||||
|
||||
export const ArrayField = ({
|
||||
path = "",
|
||||
labelAdd = "Add",
|
||||
wrapperProps = { wrapper: "fieldset" },
|
||||
}: ArrayFieldProps) => {
|
||||
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
|
||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||
|
||||
// if unique items with enum
|
||||
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
|
||||
<FieldComponent
|
||||
required
|
||||
name={path}
|
||||
@@ -35,7 +45,7 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
|
||||
<ArrayIterator name={path}>
|
||||
{({ value }) =>
|
||||
value?.map((v, index: number) => (
|
||||
@@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
}
|
||||
</ArrayIterator>
|
||||
<div className="flex flex-row">
|
||||
<ArrayAdd path={path} schema={schema} />
|
||||
<ArrayAdd path={path} schema={schema} label={labelAdd} />
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
|
||||
const {
|
||||
value,
|
||||
path: absolutePath,
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, (ctx) => {
|
||||
return ctx.value?.[index];
|
||||
});
|
||||
const itemPath = suffixPath(path, index);
|
||||
const itemPath = suffixPath(absolutePath, index);
|
||||
let subschema = schema.items;
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
if (itemsMultiSchema) {
|
||||
@@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
subschema = _subschema;
|
||||
}
|
||||
|
||||
const handleUpdate = useEvent((pointer: string, value: any) => {
|
||||
ctx.setValue(pointer, value);
|
||||
});
|
||||
|
||||
const handleDelete = useEvent((pointer: string) => {
|
||||
ctx.deleteValue(pointer);
|
||||
});
|
||||
@@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleUpdate(itemPath, coerce(e.target.value, subschema!));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{DeleteButton}
|
||||
</div>
|
||||
<FormContextOverride prefix={itemPath} schema={subschema!}>
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
{/* another wrap is required for primitive schemas */}
|
||||
<AnotherField label={false} />
|
||||
{DeleteButton}
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const AnotherField = (props: Partial<FieldProps>) => {
|
||||
const { value } = useFormValue("");
|
||||
|
||||
const inputProps = {
|
||||
// @todo: check, potentially just provide value
|
||||
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
|
||||
};
|
||||
return <Field name={""} label={false} {...props} inputProps={inputProps} />;
|
||||
};
|
||||
|
||||
const ArrayIterator = memo(
|
||||
({ name, children }: any) => {
|
||||
return children(useFormValue(name));
|
||||
@@ -98,19 +113,25 @@ const ArrayIterator = memo(
|
||||
(prev, next) => prev.value?.length === next.value?.length,
|
||||
);
|
||||
|
||||
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
const ArrayAdd = ({
|
||||
schema,
|
||||
path: _path,
|
||||
label = "Add",
|
||||
}: { schema: JsonSchema; path: string; label?: string }) => {
|
||||
const {
|
||||
setValue,
|
||||
value: { currentIndex },
|
||||
path,
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, (ctx) => {
|
||||
} = useDerivedFieldContext(_path, (ctx) => {
|
||||
return { currentIndex: ctx.value?.length ?? 0 };
|
||||
});
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
const options = { addOptionalProps: true };
|
||||
|
||||
function handleAdd(template?: any) {
|
||||
const newPath = suffixPath(path, currentIndex);
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options));
|
||||
}
|
||||
|
||||
if (itemsMultiSchema) {
|
||||
@@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
}}
|
||||
items={itemsMultiSchema.map((s, i) => ({
|
||||
label: s!.title ?? `Option ${i + 1}`,
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)),
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)),
|
||||
}))}
|
||||
onClickItem={console.log}
|
||||
>
|
||||
<Button IconLeft={IconLibraryPlus}>Add</Button>
|
||||
<Button IconLeft={IconLibraryPlus}>{label}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button onClick={() => handleAdd()}>Add</Button>;
|
||||
return <Button onClick={() => handleAdd()}>{label}</Button>;
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ const FieldImpl = ({
|
||||
);
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={name} />;
|
||||
return <ObjectField path={name} wrapperProps={props} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
@@ -217,14 +217,14 @@ export type CustomFieldProps<Data = any> = {
|
||||
) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const CustomField = <Data = any>({
|
||||
export function CustomField<Data = any>({
|
||||
path: _path,
|
||||
valueStrict = true,
|
||||
deriveFn,
|
||||
children,
|
||||
}: CustomFieldProps<Data>) => {
|
||||
}: CustomFieldProps<Data>) {
|
||||
const ctx = useDerivedFieldContext(_path, deriveFn);
|
||||
const $value = useFormValue(ctx.path, { strict: valueStrict });
|
||||
const $value = useFormValue(_path, { strict: valueStrict });
|
||||
const setValue = (value: any) => ctx.setValue(ctx.path, value);
|
||||
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { IconBug, IconInfoCircle } from "@tabler/icons-react";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "ui/components/form/json-schema-form/Form";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { getLabel } from "./utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
export type FieldwrapperProps = {
|
||||
name: string;
|
||||
@@ -23,8 +25,9 @@ export type FieldwrapperProps = {
|
||||
children: ReactElement | ReactNode;
|
||||
errorPlacement?: "top" | "bottom";
|
||||
description?: string;
|
||||
descriptionPlacement?: "top" | "bottom";
|
||||
descriptionPlacement?: "top" | "bottom" | "label";
|
||||
fieldId?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FieldWrapper({
|
||||
@@ -38,6 +41,7 @@ export function FieldWrapper({
|
||||
descriptionPlacement = "bottom",
|
||||
children,
|
||||
fieldId,
|
||||
className,
|
||||
...props
|
||||
}: FieldwrapperProps) {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
@@ -50,17 +54,23 @@ export function FieldWrapper({
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
const Description = description && (
|
||||
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||
{description}
|
||||
</Formy.Help>
|
||||
);
|
||||
const Description = description ? (
|
||||
["top", "bottom"].includes(descriptionPlacement) ? (
|
||||
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||
{description}
|
||||
</Formy.Help>
|
||||
) : (
|
||||
<Tooltip label={description}>
|
||||
<IconInfoCircle className="size-4 opacity-50" />
|
||||
</Tooltip>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Formy.Group
|
||||
error={errors.length > 0}
|
||||
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||
className={hidden ? "hidden" : "relative"}
|
||||
className={twMerge(hidden ? "hidden" : "relative", className)}
|
||||
>
|
||||
{errorPlacement === "top" && Errors}
|
||||
<FieldDebug name={name} schema={schema} required={required} />
|
||||
@@ -69,14 +79,15 @@ export function FieldWrapper({
|
||||
<Formy.Label
|
||||
as={wrapper === "fieldset" ? "legend" : "label"}
|
||||
htmlFor={fieldId}
|
||||
className="self-start"
|
||||
className="self-start flex flex-row gap-1 items-center"
|
||||
>
|
||||
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||
{descriptionPlacement === "label" && Description}
|
||||
</Formy.Label>
|
||||
)}
|
||||
{descriptionPlacement === "top" && Description}
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-row flex-grow gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{Children.count(children) === 1 && isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
|
||||
@@ -80,6 +80,7 @@ export function Form<
|
||||
onInvalidSubmit,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = true,
|
||||
beforeSubmit,
|
||||
ignoreKeys = [],
|
||||
options = {},
|
||||
readOnly = false,
|
||||
@@ -90,6 +91,7 @@ export function Form<
|
||||
initialOpts?: LibTemplateOptions;
|
||||
ignoreKeys?: string[];
|
||||
onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void;
|
||||
beforeSubmit?: (data: Data) => Data;
|
||||
onSubmit?: (data: Data) => void | Promise<void>;
|
||||
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
@@ -128,7 +130,7 @@ export function Form<
|
||||
if (errors.length === 0) {
|
||||
await onSubmit(data as Data);
|
||||
} else {
|
||||
console.log("invalid", errors);
|
||||
console.error("form: invalid", { data, errors });
|
||||
onInvalidSubmit?.(errors, data);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -177,7 +179,8 @@ export function Form<
|
||||
});
|
||||
|
||||
const validate = useEvent((_data?: Partial<Data>) => {
|
||||
const actual = _data ?? getCurrentState()?.data;
|
||||
const before = beforeSubmit ?? ((a: any) => a);
|
||||
const actual = before((_data as any) ?? getCurrentState()?.data);
|
||||
const errors = lib.validate(actual, schema);
|
||||
setFormState((prev) => ({ ...prev, errors }));
|
||||
return { data: actual, errors };
|
||||
@@ -378,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) {
|
||||
if (options?.debug !== true && force !== true) return null;
|
||||
const ctx = useFormStateSelector((s) => s);
|
||||
|
||||
return <JsonViewer json={ctx} expand={99} />;
|
||||
return <JsonViewer json={ctx} expand={99} showCopy />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
||||
import { AnyOfField } from "./AnyOfField";
|
||||
import { Field } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { type JSONSchema, useDerivedFieldContext } from "./Form";
|
||||
import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
|
||||
export type ObjectFieldProps = {
|
||||
path?: string;
|
||||
@@ -11,7 +12,7 @@ export type ObjectFieldProps = {
|
||||
};
|
||||
|
||||
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
|
||||
const { schema, ...ctx } = useDerivedFieldContext(path);
|
||||
const { schema } = useDerivedFieldContext(path);
|
||||
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
|
||||
|
||||
@@ -24,7 +25,7 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
|
||||
{...wrapperProps}
|
||||
>
|
||||
{properties.length === 0 ? (
|
||||
<i className="opacity-50">No properties</i>
|
||||
<ObjectJsonField path={path} />
|
||||
) : (
|
||||
properties.map(([prop, schema]) => {
|
||||
const name = [path, prop].filter(Boolean).join(".");
|
||||
@@ -40,3 +41,9 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ObjectJsonField = ({ path }: { path: string }) => {
|
||||
const { value } = useFormValue(path);
|
||||
const { setValue, path: absolutePath } = useDerivedFieldContext(path);
|
||||
return <JsonEditor value={value} onChange={(value) => setValue(absolutePath, value)} />;
|
||||
};
|
||||
|
||||
@@ -67,18 +67,23 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data
|
||||
return false;
|
||||
}
|
||||
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
try {
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||
const l = pointer.split("/").pop();
|
||||
const required = parentSchema?.required?.includes(l);
|
||||
|
||||
return !!required;
|
||||
} catch (e) {
|
||||
console.error("isRequired", { pointer, schema, data, e });
|
||||
console.warn("isRequired", { pointer, schema, data, e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,13 @@ export default function JsonField({
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const value = JSON.stringify(formData, null, 2);
|
||||
|
||||
function handleChange(data) {
|
||||
try {
|
||||
onChange(JSON.parse(data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
|
||||
<JsonEditor value={formData} editable={!isDisabled} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isFileAccepted } from "bknd/utils";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { checkMaxReached } from "./helper";
|
||||
import { DropzoneInner } from "./DropzoneInner";
|
||||
@@ -173,12 +173,14 @@ export function Dropzone({
|
||||
|
||||
return specs.every((spec) => {
|
||||
if (spec.kind !== "file") {
|
||||
console.log("not a file", spec.kind);
|
||||
console.warn("file not accepted: not a file", spec.kind);
|
||||
return false;
|
||||
}
|
||||
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
||||
console.log("not allowed mimetype", spec.type);
|
||||
return allowedMimeTypes.includes(spec.type);
|
||||
if (!isFileAccepted(i, allowedMimeTypes)) {
|
||||
console.warn("file not accepted: not allowed mimetype", spec.type);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ export function useNavigate() {
|
||||
window.location.href = url;
|
||||
return;
|
||||
} else if ("target" in options) {
|
||||
const _url = window.location.origin + basepath + router.base + url;
|
||||
const _url = window.location.origin + router.base + url;
|
||||
window.open(_url, options.target);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,30 @@ import { type ClassNameValue, twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassNameValue[]) {
|
||||
return twMerge(inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import a module from a URL in the browser in a way compatible with all react frameworks (nextjs doesn't support dynamic imports)
|
||||
*/
|
||||
export async function importDynamicBrowserModule<T = any>(name: string, url: string): Promise<T> {
|
||||
if (!(window as any)[name]) {
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
script.async = true;
|
||||
script.textContent = `import * as ${name} from '${url}';window.${name} = ${name};`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
// poll for the module to be available
|
||||
const maxAttempts = 50; // 5s
|
||||
let attempts = 0;
|
||||
while (!(window as any)[name] && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!(window as any)[name]) {
|
||||
throw new Error(`Browser module "${name}" failed to load`);
|
||||
}
|
||||
}
|
||||
|
||||
return (window as any)[name] as T;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FieldApi, ReactFormExtendedApi } from "@tanstack/react-form";
|
||||
import type { JSX } from "react";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { type ComponentProps, Suspense, useState } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
@@ -283,12 +283,14 @@ function EntityJsonFormField({
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: TFieldApi; field: JsonField }) {
|
||||
const [error, setError] = useState<any>(null);
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
setError(null);
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Group error={!!error}>
|
||||
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Suspense>
|
||||
<JsonEditor
|
||||
@@ -296,6 +298,7 @@ function EntityJsonFormField({
|
||||
value={fieldApi.state.value}
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onInvalid={setError}
|
||||
minHeight="100"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { useRef } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { isDebug } from "core/env";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { TbAdjustments, TbDots, TbFilter, TbTrash, TbInfoCircle, TbCodeDots } from "react-icons/tb";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { ucFirst, s, transformObject, isObject, autoFormatString } from "bknd/utils";
|
||||
import type { ModuleSchemas } from "bknd";
|
||||
import {
|
||||
CustomField,
|
||||
Field,
|
||||
FieldWrapper,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
FormDebug,
|
||||
ObjectJsonField,
|
||||
Subscribe,
|
||||
useDerivedFieldContext,
|
||||
useFormContext,
|
||||
useFormError,
|
||||
useFormValue,
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
import type { RoleSchema } from "auth/authorize/Role";
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { cn } from "ui/lib/utils";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { mountOnce, useApiQuery } from "ui/client";
|
||||
import { CodePreview } from "ui/components/code/CodePreview";
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
|
||||
export function AuthRolesEdit(props) {
|
||||
useBrowserTitle(["Auth", "Roles", props.params.role]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Roles & Permissions" />;
|
||||
@@ -20,32 +51,69 @@ export function AuthRolesEdit(props) {
|
||||
return <AuthRolesEditInternal {...props} />;
|
||||
}
|
||||
|
||||
function AuthRolesEditInternal({ params }) {
|
||||
// currently for backward compatibility
|
||||
function getSchema(authSchema: ModuleSchemas["auth"]) {
|
||||
const roles = authSchema.properties.roles.additionalProperties;
|
||||
return {
|
||||
...roles,
|
||||
properties: {
|
||||
...roles.properties,
|
||||
permissions: {
|
||||
...roles.properties.permissions.anyOf[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formConfig = {
|
||||
options: {
|
||||
debug: isDebug(),
|
||||
},
|
||||
};
|
||||
|
||||
function AuthRolesEditInternal({ params }: { params: { role: string } }) {
|
||||
const [navigate] = useNavigate();
|
||||
const { config, actions } = useBkndAuth();
|
||||
const { config, schema: authSchema, actions } = useBkndAuth();
|
||||
const [error, setError] = useState<JsonError[]>();
|
||||
const roleName = params.role;
|
||||
const role = config.roles?.[roleName];
|
||||
const formRef = useRef<AuthRoleFormRef>(null);
|
||||
const { readonly } = useBknd();
|
||||
const { readonly, permissions } = useBknd();
|
||||
const schema = getSchema(authSchema);
|
||||
const data = {
|
||||
...role,
|
||||
// this is to maintain array structure
|
||||
permissions: permissions.map((p) => {
|
||||
return role?.permissions?.find((v: any) => v.permission === p.name);
|
||||
}),
|
||||
};
|
||||
|
||||
async function handleUpdate() {
|
||||
console.log("data", formRef.current?.isValid());
|
||||
if (!formRef.current?.isValid()) return;
|
||||
const data = formRef.current?.getData();
|
||||
const success = await actions.roles.patch(roleName, data);
|
||||
async function handleDelete() {
|
||||
const success = await actions.roles.delete(roleName);
|
||||
if (success) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (await actions.roles.delete(roleName)) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
async function handleUpdate(data: any) {
|
||||
setError(undefined);
|
||||
await actions.roles.patch(roleName, data);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
schema={schema as any}
|
||||
initialValues={data}
|
||||
{...formConfig}
|
||||
beforeSubmit={(data) => {
|
||||
return {
|
||||
...data,
|
||||
permissions: [...Object.values(data.permissions).filter(Boolean)],
|
||||
};
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
onInvalidSubmit={(errors) => {
|
||||
setError(errors);
|
||||
}}
|
||||
>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
@@ -69,9 +137,23 @@ function AuthRolesEditInternal({ params }) {
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
{!readonly && (
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
errors: state.errors.length > 0,
|
||||
submitting: state.submitting,
|
||||
})}
|
||||
>
|
||||
{({ dirty, errors, submitting }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
</Subscribe>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -85,8 +167,368 @@ function AuthRolesEditInternal({ params }) {
|
||||
/>
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<AuthRoleForm ref={formRef} role={role} />
|
||||
{error && <Alert.Exception message={"Invalid form data"} />}
|
||||
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Permissions />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field
|
||||
label="Should this role be the default?"
|
||||
name="is_default"
|
||||
description="In case an user is not assigned any role, this role will be assigned by default."
|
||||
descriptionPlacement="top"
|
||||
/>
|
||||
<Field
|
||||
label="Implicit allow missing permissions?"
|
||||
name="implicit_allow"
|
||||
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
|
||||
descriptionPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormDebug />
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
type PermissionsData = Exclude<RoleSchema["permissions"], string[] | undefined>;
|
||||
type PermissionData = PermissionsData[number];
|
||||
|
||||
const Permissions = () => {
|
||||
const { permissions } = useBknd();
|
||||
|
||||
const grouped = permissions.reduce(
|
||||
(acc, permission, index) => {
|
||||
const [group, name] = permission.name.split(".") as [string, string];
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push({ index, permission });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { index: number; permission: TPermission }[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{Object.entries(grouped).map(([group, rows]) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2" key={group}>
|
||||
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
|
||||
{rows.map(({ index, permission }) => (
|
||||
<Permission key={permission.name} permission={permission} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
|
||||
const path = `permissions.${index}`;
|
||||
const { value } = useDerivedFieldContext("permissions", (ctx) => {
|
||||
const v = ctx.value;
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const v2 = v.find((v) => v && v.permission === permission.name);
|
||||
return {
|
||||
set: !!v2,
|
||||
policies: (v2?.policies?.length ?? 0) as number,
|
||||
};
|
||||
});
|
||||
const { setValue } = useFormContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const policiesCount = value?.policies ?? 0;
|
||||
const isSet = value?.set ?? false;
|
||||
|
||||
async function handleSwitch() {
|
||||
if (isSet) {
|
||||
setValue(path, undefined);
|
||||
setOpen(false);
|
||||
} else {
|
||||
setValue(path, {
|
||||
permission: permission.name,
|
||||
policies: [],
|
||||
effect: "allow",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={permission.name}
|
||||
className={cn("flex flex-col border border-muted", open && "border-primary/20")}
|
||||
>
|
||||
<div className={cn("flex flex-row gap-2 justify-between", open && "bg-primary/5")}>
|
||||
<div className="py-4 px-4 font-mono leading-none flex flex-row gap-2 items-center">
|
||||
{permission.name}
|
||||
{permission.filterable && (
|
||||
<Tooltip label="Permission supports filtering">
|
||||
<TbFilter className="opacity-50" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex flex-row gap-1 items-center px-2">
|
||||
<div className="relative flex flex-row gap-1 items-center">
|
||||
{policiesCount > 0 && (
|
||||
<div className="bg-primary/80 text-background rounded-full size-5 flex items-center justify-center text-sm font-bold pointer-events-none">
|
||||
{policiesCount}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="ghost"
|
||||
disabled={!isSet}
|
||||
Icon={TbAdjustments}
|
||||
className={cn("disabled:opacity-20")}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
<Formy.Switch size="sm" checked={isSet} onChange={handleSwitch} />
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3.5 py-3.5">
|
||||
<Policies path={`permissions.${index}.policies`} permission={permission} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Policies = ({ path, permission }: { path: string; permission: TPermission }) => {
|
||||
const {
|
||||
setValue,
|
||||
schema: policySchema,
|
||||
lib,
|
||||
deleteValue,
|
||||
value,
|
||||
} = useDerivedFieldContext(path, ({ value }) => {
|
||||
return {
|
||||
policies: (value?.length ?? 0) as number,
|
||||
};
|
||||
});
|
||||
const policiesCount = value?.policies ?? 0;
|
||||
|
||||
function handleAdd() {
|
||||
setValue(
|
||||
`${path}.${policiesCount}`,
|
||||
lib.getTemplate(undefined, policySchema!.items, {
|
||||
addOptionalProps: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete(index: number) {
|
||||
deleteValue(`${path}.${index}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", policiesCount > 0 && "gap-8")}>
|
||||
<div className="flex flex-col gap-5">
|
||||
{policiesCount > 0 &&
|
||||
Array.from({ length: policiesCount }).map((_, i) => (
|
||||
<FormContextOverride key={i} prefix={`${path}.${i}`} schema={policySchema.items!}>
|
||||
{i > 0 && <div className="h-px bg-muted" />}
|
||||
<div className="flex flex-row gap-2 items-start">
|
||||
<div className="flex flex-col flex-grow w-full">
|
||||
<Policy permission={permission} />
|
||||
</div>
|
||||
<IconButton Icon={TbTrash} onClick={() => handleDelete(i)} size="sm" />
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<Button onClick={handleAdd}>Add Policy</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mergeSchemas = (...schemas: object[]) => {
|
||||
return s.allOf(schemas.filter(Boolean).map(s.fromSchema));
|
||||
};
|
||||
|
||||
function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
||||
if (!isObject(schema) || !Array.isArray(entities) || entities.length === 0) return schema;
|
||||
return transformObject(schema, (sub, name) => {
|
||||
if (name === "properties") {
|
||||
return transformObject(sub as Record<string, any>, (propConfig, propKey) => {
|
||||
if (propKey === "entity" && propConfig.type === "string") {
|
||||
return {
|
||||
...propConfig,
|
||||
enum: entities,
|
||||
};
|
||||
}
|
||||
return propConfig;
|
||||
});
|
||||
}
|
||||
return sub;
|
||||
});
|
||||
}
|
||||
|
||||
const Policy = ({
|
||||
permission,
|
||||
}: {
|
||||
permission: TPermission;
|
||||
}) => {
|
||||
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
||||
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
||||
}));
|
||||
const $bknd = useBknd();
|
||||
const $permissions = useApiQuery((api) => api.system.permissions(), {
|
||||
use: [mountOnce],
|
||||
});
|
||||
const entities = Object.keys($bknd.config.data.entities ?? {});
|
||||
const ctx = $permissions.data
|
||||
? mergeSchemas(
|
||||
$permissions.data.context,
|
||||
replaceEntitiesEnum(permission.context ?? null, entities),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field name="description" />
|
||||
|
||||
<CustomFieldWrapper
|
||||
name="condition"
|
||||
label="Condition"
|
||||
description="The condition that must be met for the policy to be applied."
|
||||
schema={
|
||||
ctx && {
|
||||
name: "Context",
|
||||
content: s.toTypes(ctx, "Context"),
|
||||
}
|
||||
}
|
||||
>
|
||||
<ObjectJsonField path="condition" />
|
||||
</CustomFieldWrapper>
|
||||
|
||||
<CustomField path="effect">
|
||||
{({ value, setValue }) => (
|
||||
<FieldWrapper
|
||||
name="effect"
|
||||
label="Effect"
|
||||
descriptionPlacement="label"
|
||||
description="The effect of the policy to take effect on met condition."
|
||||
>
|
||||
<SegmentedControl
|
||||
className="border border-muted"
|
||||
defaultValue={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
data={
|
||||
["allow", "deny", permission.filterable ? "filter" : undefined]
|
||||
.filter(Boolean)
|
||||
.map((effect) => ({
|
||||
label: ucFirst(effect ?? ""),
|
||||
value: effect,
|
||||
})) as any
|
||||
}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
</CustomField>
|
||||
|
||||
{value?.effect === "filter" && (
|
||||
<CustomFieldWrapper
|
||||
name="filter"
|
||||
label="Filter"
|
||||
description="Filter to apply to all queries on met condition."
|
||||
schema={
|
||||
ctx && {
|
||||
name: "Variables",
|
||||
content: s.toTypes(ctx, "Variables"),
|
||||
}
|
||||
}
|
||||
>
|
||||
<ObjectJsonField path="filter" />
|
||||
</CustomFieldWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomFieldWrapper = ({
|
||||
children,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
schema,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
schema?: {
|
||||
name: string;
|
||||
content: string | object;
|
||||
};
|
||||
}) => {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
const Errors = errors.length > 0 && (
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
return (
|
||||
<Formy.Group as="div">
|
||||
<Formy.Label
|
||||
as="label"
|
||||
htmlFor={name}
|
||||
className="flex flex-row gap-1 justify-between items-center"
|
||||
>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
{label}
|
||||
{description && (
|
||||
<Tooltip label={description}>
|
||||
<TbInfoCircle className="size-4 opacity-50" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{schema && (
|
||||
<div>
|
||||
<Popover
|
||||
overlayProps={{
|
||||
className: "max-w-none",
|
||||
}}
|
||||
position="bottom-end"
|
||||
target={() =>
|
||||
typeof schema.content === "object" ? (
|
||||
<JsonViewer
|
||||
className="w-auto max-w-120 bg-background pr-3 text-sm"
|
||||
json={schema.content}
|
||||
title={schema.name}
|
||||
expand={5}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview
|
||||
code={schema.content}
|
||||
lang="typescript"
|
||||
className="w-auto max-w-120 bg-background p-3 text-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
||||
{autoFormatString(schema.name)}
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</Formy.Label>
|
||||
{children}
|
||||
{Errors}
|
||||
</Formy.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,21 @@ import { CellValue, DataTable } from "../../components/table/DataTable";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "../../lib/routes";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
|
||||
export function AuthRolesList() {
|
||||
export function AuthRolesList(props) {
|
||||
useBrowserTitle(["Auth", "Roles"]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Auth Roles" />;
|
||||
}
|
||||
|
||||
return <AuthRolesListInternal {...props} />;
|
||||
}
|
||||
|
||||
function AuthRolesListInternal() {
|
||||
const [navigate] = useNavigate();
|
||||
const { config, actions } = useBkndAuth();
|
||||
const { readonly } = useBknd();
|
||||
@@ -21,7 +34,10 @@ export function AuthRolesList() {
|
||||
const data = Object.values(
|
||||
transformObject(config.roles ?? {}, (role, name) => ({
|
||||
role: name,
|
||||
permissions: role.permissions,
|
||||
permissions: role.permissions?.map((p) => p.permission) as string[],
|
||||
policies: role.permissions
|
||||
?.flatMap((p) => p.policies?.length ?? 0)
|
||||
.reduce((acc, curr) => acc + curr, 0),
|
||||
is_default: role.is_default ?? false,
|
||||
implicit_allow: role.implicit_allow ?? false,
|
||||
})),
|
||||
@@ -94,6 +110,9 @@ const renderValue = ({ value, property }) => {
|
||||
if (["is_default", "implicit_allow"].includes(property)) {
|
||||
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
|
||||
}
|
||||
if (property === "policies") {
|
||||
return value ? <span>{value}</span> : <span className="opacity-50">0</span>;
|
||||
}
|
||||
|
||||
if (property === "permissions") {
|
||||
const max = 3;
|
||||
|
||||
@@ -34,7 +34,11 @@ export const AuthRoleForm = forwardRef<
|
||||
getValues,
|
||||
} = useForm({
|
||||
resolver: standardSchemaResolver(schema),
|
||||
defaultValues: role,
|
||||
defaultValues: {
|
||||
...role,
|
||||
// compat
|
||||
permissions: role?.permissions?.map((p) => p.permission),
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -47,7 +51,7 @@ export const AuthRoleForm = forwardRef<
|
||||
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/*<h3 className="font-semibold">Role Permissions</h3>*/}
|
||||
<Permissions control={control} permissions={permissions} />
|
||||
<Permissions control={control} permissions={permissions.map((p) => p.name)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input.Wrapper
|
||||
@@ -111,8 +115,6 @@ const Permissions = ({
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
|
||||
console.log("grouped", grouped);
|
||||
//console.log("fieldState", fieldState, value);
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{Object.entries(grouped).map(([group, permissions]) => {
|
||||
@@ -121,7 +123,7 @@ const Permissions = ({
|
||||
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||
{permissions.map((permission) => {
|
||||
const selected = data.includes(permission);
|
||||
const selected = data.includes(permission as any);
|
||||
return (
|
||||
<div key={permission} className="flex flex-col border border-muted">
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
|
||||
@@ -215,7 +215,9 @@ const EntityContextMenu = ({
|
||||
href && {
|
||||
icon: IconExternalLink,
|
||||
label: "Open in tab",
|
||||
onClick: () => navigate(href, { target: "_blank" }),
|
||||
onClick: () => {
|
||||
navigate(href, { target: "_blank", absolute: true });
|
||||
},
|
||||
},
|
||||
separator,
|
||||
!$data.system(entity.name).any && {
|
||||
|
||||
@@ -301,7 +301,11 @@ function EntityDetailInner({
|
||||
|
||||
// @todo: add custom key for invalidation
|
||||
const $q = useApiQuery(
|
||||
(api) => api.data.readManyByReference(entity.name, id, other.reference, search),
|
||||
(api) =>
|
||||
api.data.readManyByReference(entity.name, id, other.reference, {
|
||||
...search,
|
||||
limit: search.limit + 1 /* overfetch for softscan=false */,
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: true,
|
||||
@@ -320,7 +324,6 @@ function EntityDetailInner({
|
||||
navigate(routes.data.entity.create(other.entity.name), {
|
||||
query: ref.where,
|
||||
});
|
||||
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -330,6 +333,7 @@ function EntityDetailInner({
|
||||
}
|
||||
|
||||
const isUpdating = $q.isValidating || $q.isLoading;
|
||||
const meta = $q.data?.body.meta;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -344,7 +348,7 @@ function EntityDetailInner({
|
||||
onClickRow={handleClickRow}
|
||||
onClickNew={handleClickNew}
|
||||
page={Math.floor(search.offset / search.limit) + 1}
|
||||
total={$q.data?.body?.meta?.count ?? 1}
|
||||
total={meta?.count}
|
||||
onClickPage={(page) => {
|
||||
setSearch((s) => ({
|
||||
...s,
|
||||
|
||||
@@ -63,10 +63,10 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
} catch (e) {}
|
||||
console.log("_s", _s);
|
||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||
if (_s.permissions) {
|
||||
/* if (_s.permissions) {
|
||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||
roleSchema.properties.permissions.uniqueItems = true;
|
||||
}
|
||||
} */
|
||||
|
||||
return (
|
||||
<Route path="/auth" nest>
|
||||
|
||||
@@ -27,6 +27,7 @@ import SortableTest from "./tests/sortable-test";
|
||||
import { SqlAiTest } from "./tests/sql-ai-test";
|
||||
import Themes from "./tests/themes";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import CodeEditorTest from "./tests/code-editor-test";
|
||||
|
||||
const tests = {
|
||||
DropdownTest,
|
||||
@@ -52,6 +53,7 @@ const tests = {
|
||||
JsonSchemaForm3,
|
||||
FormyTest,
|
||||
HtmlFormTest,
|
||||
CodeEditorTest,
|
||||
} as const;
|
||||
|
||||
export default function TestRoutes() {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
|
||||
export default function CodeEditorTest() {
|
||||
const [value, setValue] = useState({});
|
||||
return (
|
||||
<div className="flex flex-col p-4">
|
||||
<JsonEditor value={value} onChange={setValue} />
|
||||
<JsonViewer json={value} expand={9} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,14 @@ const authSchema = {
|
||||
},
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
const objectCodeSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
config: { type: "object", properties: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const formOptions = {
|
||||
debug: true,
|
||||
};
|
||||
@@ -77,6 +85,45 @@ export default function JsonSchemaForm3() {
|
||||
{/* <Form schema={_schema.auth.toJSON()} options={formOptions} /> */}
|
||||
|
||||
<Form
|
||||
schema={objectCodeSchema as any}
|
||||
options={formOptions}
|
||||
initialValues={{
|
||||
name: "Peter",
|
||||
config: {
|
||||
foo: "bar",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Form
|
||||
schema={s
|
||||
.object({
|
||||
name: s.string(),
|
||||
props: s.array(
|
||||
s.object({
|
||||
age: s.number(),
|
||||
config: s.object({}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.toJSON()}
|
||||
options={formOptions}
|
||||
initialValues={{
|
||||
name: "Peter",
|
||||
props: [{ age: 20, config: { foo: "bar" } }],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form
|
||||
schema={s
|
||||
.object({
|
||||
name: s.string(),
|
||||
props: s.array(s.anyOf([s.string(), s.number()])),
|
||||
})
|
||||
.toJSON()}
|
||||
options={formOptions}
|
||||
/>
|
||||
|
||||
{/* <Form
|
||||
options={{
|
||||
anyOfNoneSelectedMode: "first",
|
||||
debug: true,
|
||||
@@ -98,7 +145,7 @@ export default function JsonSchemaForm3() {
|
||||
.optional(),
|
||||
})
|
||||
.toJSON()}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
{/*<Form
|
||||
onChange={(data) => console.log("change", data)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ToolsMcp() {
|
||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||
<TbWorld />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-mono leading-none">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as Formy from "ui/components/form/Formy";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { Icon } from "ui/components/display/Icon";
|
||||
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
export function Sidebar({ open, toggle }) {
|
||||
const client = useMcpClient();
|
||||
@@ -48,7 +49,11 @@ export function Sidebar({ open, toggle }) {
|
||||
toggle={toggle}
|
||||
renderHeaderRight={() => (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{error && <Icon.Err title={error} className="size-5 pointer-events-auto" />}
|
||||
{error && (
|
||||
<Tooltip label={error}>
|
||||
<Icon.Err className="size-5 pointer-events-auto" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
||||
{tools.length}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user