Merge branch 'release/0.19' into feat/advanced-permissions

This commit is contained in:
dswbx
2025-10-24 15:15:56 +02:00
committed by GitHub
32 changed files with 587 additions and 107 deletions

View File

@@ -6,13 +6,16 @@ describe("Api", async () => {
it("should construct without options", () => { it("should construct without options", () => {
const api = new Api(); const api = new Api();
expect(api.baseUrl).toBe("http://localhost"); expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
// verified is true, because no token, user, headers or request given
// therefore nothing to check, auth state is verified
expect(api.isAuthVerified()).toBe(true);
}); });
it("should ignore force verify if no claims given", () => { it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true }); const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost"); expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false); expect(api.isAuthVerified()).toBe(true);
}); });
it("should construct from request (token)", async () => { it("should construct from request (token)", async () => {

View File

@@ -440,6 +440,35 @@ describe("Core Utils", async () => {
height: 512, height: 512,
}); });
}); });
test("isFileAccepted", () => {
const file = new File([""], "file.txt", {
type: "text/plain",
});
expect(utils.isFileAccepted(file, "text/plain")).toBe(true);
expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true);
expect(utils.isFileAccepted(file, "text/html")).toBe(false);
{
const file = new File([""], "file.jpg", {
type: "image/jpeg",
});
expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true);
expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true);
expect(utils.isFileAccepted(file, "image/png")).toBe(false);
expect(utils.isFileAccepted(file, "image/*")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true);
expect(utils.isFileAccepted(file, ".png")).toBe(false);
}
{
const file = new File([""], "file.png");
expect(utils.isFileAccepted(file, undefined as any)).toBe(true);
}
expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow();
});
}); });
describe("dates", () => { describe("dates", () => {

View File

@@ -40,6 +40,7 @@ export type ApiOptions = {
data?: SubApiOptions<DataApiOptions>; data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>; auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>; media?: SubApiOptions<MediaApiOptions>;
credentials?: RequestCredentials;
} & ( } & (
| { | {
token?: string; token?: string;
@@ -67,7 +68,7 @@ export class Api {
public auth!: AuthApi; public auth!: AuthApi;
public media!: MediaApi; public media!: MediaApi;
constructor(private options: ApiOptions = {}) { constructor(public options: ApiOptions = {}) {
// only mark verified if forced // only mark verified if forced
this.verified = options.verified === true; this.verified = options.verified === true;
@@ -129,29 +130,45 @@ export class Api {
} else if (this.storage) { } else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => { this.storage.getItem(this.tokenKey).then((token) => {
this.token_transport = "header"; 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() { private get storage() {
if (!this.options.storage) return null; const storage = this.options.storage;
return { return new Proxy(
getItem: async (key: string) => { {},
return await this.options.storage!.getItem(key); {
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) => { ) as any;
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
} }
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { updateToken(
token?: string,
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
) {
this.token = token; this.token = token;
this.verified = false; this.verified = opts?.verified === true;
if (token) { if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any; this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
@@ -159,21 +176,22 @@ export class Api {
this.user = undefined; this.user = undefined;
} }
const emit = () => {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
}
};
if (this.storage) { if (this.storage) {
const key = this.tokenKey; const key = this.tokenKey;
if (token) { if (token) {
this.storage.setItem(key, token).then(() => { this.storage.setItem(key, token).then(emit);
this.options.onAuthStateChange?.(this.getAuthState());
});
} else { } else {
this.storage.removeItem(key).then(() => { this.storage.removeItem(key).then(emit);
this.options.onAuthStateChange?.(this.getAuthState());
});
} }
} else { } else {
if (opts?.trigger !== false) { if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState()); emit();
} }
} }
@@ -182,6 +200,7 @@ export class Api {
private markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
this.verified = verfied; this.verified = verfied;
this.options.onAuthStateChange?.(this.getAuthState());
return this; return this;
} }
@@ -208,11 +227,6 @@ export class Api {
} }
async verifyAuth() { async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try { try {
const { ok, data } = await this.auth.me(); const { ok, data } = await this.auth.me();
const user = data?.user; const user = data?.user;
@@ -221,10 +235,10 @@ export class Api {
} }
this.user = user; this.user = user;
this.markAuthVerified(true);
} catch (e) { } catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined); this.updateToken(undefined);
} finally {
this.markAuthVerified(true);
} }
} }
@@ -239,6 +253,7 @@ export class Api {
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport, token_transport: this.token_transport,
verbose: this.options.verbose, verbose: this.options.verbose,
credentials: this.options.credentials,
}); });
} }
@@ -257,10 +272,9 @@ export class Api {
this.auth = new AuthApi( this.auth = new AuthApi(
{ {
...baseParams, ...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth, ...this.options.auth,
onTokenUpdate: (token) => { onTokenUpdate: (token, verified) => {
this.updateToken(token, { rebuild: true }); this.updateToken(token, { rebuild: true, verified, trigger: true });
this.options.auth?.onTokenUpdate?.(token); this.options.auth?.onTokenUpdate?.(token);
}, },
}, },

View File

@@ -385,6 +385,7 @@ export class App<
} }
} }
} }
await this.options?.manager?.onModulesBuilt?.(ctx);
} }
} }

View File

@@ -8,12 +8,15 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>( export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {}, 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 async (fnArgs: TAstro) => {
return (await getApp(config, args)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
}; };

View File

@@ -12,7 +12,7 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
export async function createApp<Env = BunEnv>( export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {}, { 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"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -26,18 +26,18 @@ export async function createApp<Env = BunEnv>(
}), }),
...config, ...config,
}, },
args ?? (process.env as Env), args,
); );
} }
export function createHandler<Env = BunEnv>( export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {}, config: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = Bun.env as Env,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env)); app = await createApp(config, args);
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -54,9 +54,10 @@ export function serve<Env = BunEnv>(
buildConfig, buildConfig,
adminOptions, adminOptions,
serveStatic, serveStatic,
beforeBuild,
...serveOptions ...serveOptions
}: BunBkndConfig<Env> = {}, }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = Bun.env as Env,
) { ) {
Bun.serve({ Bun.serve({
...serveOptions, ...serveOptions,
@@ -71,6 +72,7 @@ export function serve<Env = BunEnv>(
adminOptions, adminOptions,
distPath, distPath,
serveStatic, serveStatic,
beforeBuild,
}, },
args, args,
), ),

View File

@@ -1,3 +1,11 @@
export * from "./bun.adapter"; export * from "./bun.adapter";
export * from "../node/storage"; export * from "../node/storage";
export * from "./connection/BunSqliteConnection"; 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();
}

View File

@@ -6,18 +6,23 @@ import {
guessMimeType, guessMimeType,
type MaybePromise, type MaybePromise,
registries as $registries, registries as $registries,
type Merge,
} from "bknd"; } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import type { Context, MiddlewareHandler, Next } from "hono"; import type { Context, MiddlewareHandler, Next } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & { export type BkndConfig<Args = any, Additional = {}> = Merge<
app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>); CreateAppConfig & {
onBuilt?: (app: App) => MaybePromise<void>; app?:
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>; | Merge<Omit<BkndConfig, "app"> & Additional>
buildConfig?: Parameters<App["build"]>[0]; | ((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>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
@@ -51,11 +56,10 @@ export async function makeConfig<Args = DefaultArgs>(
return { ...rest, ...additionalConfig }; return { ...rest, ...additionalConfig };
} }
// a map that contains all apps by id
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>( export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config, config: Config = {} as Config,
args?: Args, args?: Args,
): Promise<App> { ): Promise<{ app: App; config: BkndConfig<Args> }> {
await config.beforeBuild?.(undefined, $registries); await config.beforeBuild?.(undefined, $registries);
const appConfig = await makeConfig(config, args); const appConfig = await makeConfig(config, args);
@@ -65,34 +69,37 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
connection = config.connection; connection = config.connection;
} else { } else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite; 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; connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url); $console.info(`Using ${connection!.name} connection`, conf.url);
} }
appConfig.connection = connection; appConfig.connection = connection;
} }
return App.create(appConfig); return {
app: App.create(appConfig),
config: appConfig,
};
} }
export async function createFrameworkApp<Args = DefaultArgs>( export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {}, config: FrameworkBkndConfig = {},
args?: Args, args?: Args,
): Promise<App> { ): Promise<App> {
const app = await createAdapterApp(config, args); const { app, config: appConfig } = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
if (config.onBuilt) { if (config.onBuilt) {
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { async () => {
await config.onBuilt?.(app); await appConfig.onBuilt?.(app);
}, },
"sync", "sync",
); );
} }
await config.beforeBuild?.(app, $registries); await appConfig.beforeBuild?.(app, $registries);
await app.build(config.buildConfig); await app.build(config.buildConfig);
} }
@@ -103,7 +110,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {}, { serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args, args?: Args,
): Promise<App> { ): Promise<App> {
const app = await createAdapterApp(config, args); const { app, config: appConfig } = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
app.emgr.onEvent( app.emgr.onEvent(
@@ -116,7 +123,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
app.modules.server.get(path, handler); app.modules.server.get(path, handler);
} }
await config.onBuilt?.(app); await appConfig.onBuilt?.(app);
if (adminOptions !== false) { if (adminOptions !== false) {
app.registerAdminController(adminOptions); app.registerAdminController(adminOptions);
} }
@@ -124,7 +131,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
"sync", "sync",
); );
await config.beforeBuild?.(app, $registries); await appConfig.beforeBuild?.(app, $registries);
await app.build(config.buildConfig); await app.build(config.buildConfig);
} }

View File

@@ -9,9 +9,9 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>( export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>, 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"]) { function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -39,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>( export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {}, { cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
return async (req: Request) => { return async (req: Request) => {
const app = await getApp(config, args); const app = await getApp(config, args);

View File

@@ -1,3 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
export * from "./node.adapter"; export * from "./node.adapter";
export * from "./storage"; export * from "./storage";
export * from "./connection/NodeSqliteConnection"; 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");
}

View File

@@ -17,7 +17,7 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>( export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {}, { distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
@@ -33,19 +33,18 @@ export async function createApp<Env = NodeEnv>(
serveStatic: serveStatic({ root }), serveStatic: serveStatic({ root }),
...config, ...config,
}, },
// @ts-ignore args,
args ?? { env: process.env },
); );
} }
export function createHandler<Env = NodeEnv>( export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {}, config: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env)); app = await createApp(config, args);
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -53,7 +52,7 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>( export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {}, { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
honoServe( honoServe(
{ {

View File

@@ -8,14 +8,14 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>( export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>, 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>( export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {}, config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
return async (fnArgs: ReactRouterFunctionArgs) => { return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);

View File

@@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & { export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token?: string) => void | Promise<void>; onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit"; credentials?: "include" | "same-origin" | "omit";
}; };
@@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, { const res = await this.post<AuthResponse>([strategy, "login"], input);
credentials: this.options.credentials,
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token, true);
} }
return res; return res;
} }
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, { const res = await this.post<AuthResponse>([strategy, "register"], input);
credentials: this.options.credentials,
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token, true);
} }
return res; return res;
} }
@@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async logout() { 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));
} }
} }

View File

@@ -42,6 +42,7 @@ export interface UserPool {
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
export const cookieConfig = s export const cookieConfig = s
.strictObject({ .strictObject({
domain: s.string().optional(),
path: s.string({ default: "/" }), path: s.string({ default: "/" }),
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
secure: s.boolean({ default: true }), secure: s.boolean({ default: true }),
@@ -288,6 +289,7 @@ export class Authenticator<
return { return {
...cookieConfig, ...cookieConfig,
domain: cookieConfig.domain ?? undefined,
expires: new Date(Date.now() + expires * 1000), expires: new Date(Date.now() + expires * 1000),
}; };
} }
@@ -377,7 +379,10 @@ export class Authenticator<
// @todo: move this to a server helper // @todo: move this to a server helper
isJsonRequest(c: Context): boolean { 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) { async getBody(c: Context) {

View File

@@ -6,3 +6,7 @@ export interface Serializable<Class, Json extends object = object> {
export type MaybePromise<T> = T | Promise<T>; export type MaybePromise<T> = T | Promise<T>;
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> }; export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type Merge<T> = {
[K in keyof T]: T[K];
};

View File

@@ -240,3 +240,46 @@ export async function blobToFile(
lastModified: Date.now(), 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;
});
}

View File

@@ -16,6 +16,7 @@ import type { AppDataConfig } from "../data-schema";
import type { EntityManager, EntityData } from "data/entities"; import type { EntityManager, EntityData } from "data/entities";
import * as DataPermissions from "data/permissions"; import * as DataPermissions from "data/permissions";
import { repoQuery, type RepoQuery } from "data/server/query"; import { repoQuery, type RepoQuery } from "data/server/query";
import { EntityTypescript } from "data/entities/EntityTypescript";
export class DataController extends Controller { export class DataController extends Controller {
constructor( constructor(
@@ -158,6 +159,20 @@ export class DataController extends Controller {
}, },
); );
hono.get(
"/types",
permission(DataPermissions.entityRead),
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 // entity endpoints
hono.route("/entity", this.getEntityRoutes()); hono.route("/entity", this.getEntityRoutes());

View File

@@ -41,7 +41,7 @@ export { getSystemMcp } from "modules/mcp/system-mcp";
/** /**
* Core * Core
*/ */
export type { MaybePromise } from "core/types"; export type { MaybePromise, Merge } from "core/types";
export { Exception, BkndError } from "core/errors"; export { Exception, BkndError } from "core/errors";
export { isDebug, env } from "core/env"; export { isDebug, env } from "core/env";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";

49
app/src/modes/code.ts Normal file
View File

@@ -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,
},
},
};
},
};
}

88
app/src/modes/hybrid.ts Normal file
View File

@@ -0,0 +1,88 @@
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;
}
},
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,
},
},
};
},
};
}

3
app/src/modes/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./code";
export * from "./hybrid";
export * from "./shared";

183
app/src/modes/shared.ts Normal file
View File

@@ -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>,
>(_config: Config, args: Args) {
const appConfig = typeof _config.app === "function" ? await _config.app(args) : _config.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?.enabled) {
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,
};
}

View File

@@ -8,6 +8,7 @@ export type BaseModuleApiOptions = {
host: string; host: string;
basepath?: string; basepath?: string;
token?: string; token?: string;
credentials?: RequestCredentials;
headers?: Headers; headers?: Headers;
token_transport?: "header" | "cookie" | "none"; token_transport?: "header" | "cookie" | "none";
verbose?: boolean; verbose?: boolean;
@@ -106,6 +107,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
const request = new Request(url, { const request = new Request(url, {
..._init, ..._init,
credentials: this.options.credentials,
method, method,
body, body,
headers, headers,

View File

@@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager {
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined; private _stable_configs: ModuleConfigs | undefined;
// config used when syncing database
public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true };
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) { constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
let initial = {} as InitialModuleConfigs; let initial = {} as InitialModuleConfigs;
let booted_with = "partial" as any; let booted_with = "partial" as any;
@@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager {
const version_before = this.version(); const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.configs.json, { const [_version, _configs] = await migrate(version_before, result.configs.json, {
db: this.db db: this.db,
}); });
this._version = _version; this._version = _version;
@@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager {
this.logger.log("db sync requested"); this.logger.log("db sync requested");
// sync db // sync db
await ctx.em.schema().sync({ force: true }); await ctx.em.schema().sync(this.buildSyncConfig);
state.synced = true; state.synced = true;
// save // save

View File

@@ -52,11 +52,16 @@ export class AppServer extends Module<AppServerConfig> {
} }
override async build() { 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( this.client.use(
"*", "*",
cors({ 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, allowMethods: this.config.cors.allow_methods,
allowHeaders: this.config.cors.allow_headers, allowHeaders: this.config.cors.allow_headers,
credentials: this.config.cors.allow_credentials, credentials: this.config.cors.allow_credentials,

View File

@@ -53,9 +53,7 @@ export const ClientProvider = ({
[JSON.stringify(apiProps)], [JSON.stringify(apiProps)],
); );
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>( const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
apiProps.user ? api.getAuthState() : undefined,
);
return ( return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>

View File

@@ -16,8 +16,8 @@ type UseAuth = {
verified: boolean; verified: boolean;
login: (data: LoginData) => Promise<AuthResponse>; login: (data: LoginData) => Promise<AuthResponse>;
register: (data: LoginData) => Promise<AuthResponse>; register: (data: LoginData) => Promise<AuthResponse>;
logout: () => void; logout: () => Promise<void>;
verify: () => void; verify: () => Promise<void>;
setToken: (token: string) => void; setToken: (token: string) => void;
}; };
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
} }
async function logout() { async function logout() {
api.updateToken(undefined); await api.auth.logout();
invalidate(); await invalidate();
} }
async function verify() { async function verify() {
await api.verifyAuth(); await api.verifyAuth();
await invalidate();
} }
return { return {

View File

@@ -9,8 +9,8 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from "react"; } from "react";
import { isFileAccepted } from "bknd/utils";
import { type FileWithPath, useDropzone } from "./use-dropzone"; import { type FileWithPath, useDropzone } from "./use-dropzone";
import { checkMaxReached } from "./helper"; import { checkMaxReached } from "./helper";
import { DropzoneInner } from "./DropzoneInner"; import { DropzoneInner } from "./DropzoneInner";
@@ -173,12 +173,14 @@ export function Dropzone({
return specs.every((spec) => { return specs.every((spec) => {
if (spec.kind !== "file") { if (spec.kind !== "file") {
console.log("not a file", spec.kind); console.warn("file not accepted: not a file", spec.kind);
return false; return false;
} }
if (allowedMimeTypes && allowedMimeTypes.length > 0) { if (allowedMimeTypes && allowedMimeTypes.length > 0) {
console.log("not allowed mimetype", spec.type); if (!isFileAccepted(i, allowedMimeTypes)) {
return allowedMimeTypes.includes(spec.type); console.warn("file not accepted: not allowed mimetype", spec.type);
return false;
}
} }
return true; return true;
}); });

View File

@@ -95,7 +95,7 @@ export function useNavigate() {
window.location.href = url; window.location.href = url;
return; return;
} else if ("target" in options) { } 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); window.open(_url, options.target);
return; return;
} }

View File

@@ -215,7 +215,9 @@ const EntityContextMenu = ({
href && { href && {
icon: IconExternalLink, icon: IconExternalLink,
label: "Open in tab", label: "Open in tab",
onClick: () => navigate(href, { target: "_blank" }), onClick: () => {
navigate(href, { target: "_blank", absolute: true });
},
}, },
separator, separator,
!$data.system(entity.name).any && { !$data.system(entity.name).any && {

View File

@@ -33,7 +33,9 @@
"bknd": ["./src/index.ts"], "bknd": ["./src/index.ts"],
"bknd/utils": ["./src/core/utils/index.ts"], "bknd/utils": ["./src/core/utils/index.ts"],
"bknd/adapter": ["./src/adapter/index.ts"], "bknd/adapter": ["./src/adapter/index.ts"],
"bknd/client": ["./src/ui/client/index.ts"] "bknd/adapter/*": ["./src/adapter/*/index.ts"],
"bknd/client": ["./src/ui/client/index.ts"],
"bknd/modes": ["./src/modes/index.ts"]
} }
}, },
"include": [ "include": [

View File

@@ -1,15 +1,13 @@
# Stage 1: Build stage # Stage 1: Build stage
FROM node:24 as builder FROM node:24 as builder
WORKDIR /app WORKDIR /app
# define bknd version to be used as: # define bknd version to be used as:
# `docker build --build-arg VERSION=<version> -t bknd .` # `docker build --build-arg VERSION=<version> -t bknd .`
ARG VERSION=0.17.1 ARG VERSION=0.18.0
# Install & copy required cli # Install & copy required cli
RUN npm install --omit=dev bknd@${VERSION} RUN npm install --omit=dev bknd@${VERSION}
RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist
# Stage 2: Final minimal image # Stage 2: Final minimal image
FROM node:24-alpine FROM node:24-alpine
@@ -19,14 +17,14 @@ WORKDIR /app
# Install required dependencies # Install required dependencies
RUN npm install -g pm2 RUN npm install -g pm2
RUN echo '{"type":"module"}' > package.json RUN echo '{"type":"module"}' > package.json
RUN npm install jsonv-ts @libsql/client
# Copy dist and node_modules from builder
COPY --from=builder /app/node_modules/bknd/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# Create volume and init args # Create volume and init args
VOLUME /data VOLUME /data
ENV DEFAULT_ARGS="--db-url file:/data/data.db" ENV DEFAULT_ARGS="--db-url file:/data/data.db"
# Copy output from builder
COPY --from=builder /output/dist ./dist
EXPOSE 1337 EXPOSE 1337
CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"] CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"]