mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
strengthened schema ensuring for system entities
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
import { createApp } from "../../src";
|
import { createApp } from "../../src";
|
||||||
import { AuthController } from "../../src/auth/api/AuthController";
|
import { AuthController } from "../../src/auth/api/AuthController";
|
||||||
import { em, entity, text } from "../../src/data";
|
import { em, entity, make, text } from "../../src/data";
|
||||||
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||||
@@ -125,6 +125,40 @@ describe("AppAuth", () => {
|
|||||||
const fields = e.fields.map((f) => f.name);
|
const fields = e.fields.map((f) => f.name);
|
||||||
expect(e.type).toBe("system");
|
expect(e.type).toBe("system");
|
||||||
expect(fields).toContain("additional");
|
expect(fields).toContain("additional");
|
||||||
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]);
|
expect(fields).toEqual(["id", "additional", "email", "strategy", "strategy_value", "role"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensure user field configs is always correct", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
data: em({
|
||||||
|
users: entity("users", {
|
||||||
|
strategy: text({
|
||||||
|
fillable: true,
|
||||||
|
hidden: false
|
||||||
|
}),
|
||||||
|
strategy_value: text({
|
||||||
|
fillable: true,
|
||||||
|
hidden: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const users = app.em.entity("users");
|
||||||
|
const props = ["hidden", "fillable", "required"];
|
||||||
|
|
||||||
|
for (const [name, _authFieldProto] of Object.entries(AppAuth.usersFields)) {
|
||||||
|
const authField = make(name, _authFieldProto as any);
|
||||||
|
const field = users.field(name)!;
|
||||||
|
for (const prop of props) {
|
||||||
|
expect(field.config[prop]).toBe(authField.config[prop]);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ describe("AppMedia", () => {
|
|||||||
expect(fields).toContain("additional");
|
expect(fields).toContain("additional");
|
||||||
expect(fields).toEqual([
|
expect(fields).toEqual([
|
||||||
"id",
|
"id",
|
||||||
|
"additional",
|
||||||
"path",
|
"path",
|
||||||
"folder",
|
"folder",
|
||||||
"mime_type",
|
"mime_type",
|
||||||
@@ -48,8 +49,7 @@ describe("AppMedia", () => {
|
|||||||
"modified_at",
|
"modified_at",
|
||||||
"reference",
|
"reference",
|
||||||
"entity_id",
|
"entity_id",
|
||||||
"metadata",
|
"metadata"
|
||||||
"additional"
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,8 +157,7 @@ describe("Module", async () => {
|
|||||||
entities: [
|
entities: [
|
||||||
{
|
{
|
||||||
name: "u",
|
name: "u",
|
||||||
// ensured properties must come first
|
fields: ["id", "name", "important"],
|
||||||
fields: ["id", "important", "name"],
|
|
||||||
// ensured type must be present
|
// ensured type must be present
|
||||||
type: "system"
|
type: "system"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.6.0-rc.11",
|
"version": "0.6.0-rc.12",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export class App {
|
|||||||
this.trigger_first_boot = false;
|
this.trigger_first_boot = false;
|
||||||
await this.emgr.emit(new AppFirstBoot({ app: this }));
|
await this.emgr.emit(new AppFirstBoot({ app: this }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[APP] built");
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { PasswordStrategy } from "auth/authenticate/strategies";
|
|||||||
import { type DB, Exception, type PrimaryFieldType } from "core";
|
import { type DB, Exception, type PrimaryFieldType } from "core";
|
||||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||||
import type { Entity, EntityManager } from "data";
|
import type { Entity, EntityManager } from "data";
|
||||||
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
|
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
|
||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { AuthController } from "./api/AuthController";
|
import { AuthController } from "./api/AuthController";
|
||||||
@@ -224,7 +224,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toggleStrategyValueVisibility(visible: boolean) {
|
private toggleStrategyValueVisibility(visible: boolean) {
|
||||||
const field = this.getUsersEntity().field("strategy_value")!;
|
const toggle = (name: string, visible: boolean) => {
|
||||||
|
const field = this.getUsersEntity().field(name)!;
|
||||||
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
field.config.hidden = false;
|
field.config.hidden = false;
|
||||||
@@ -235,6 +236,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
field.config.hidden = template.hidden;
|
field.config.hidden = template.hidden;
|
||||||
field.config.fillable = template.fillable;
|
field.config.fillable = template.fillable;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle("strategy_value", visible);
|
||||||
|
toggle("strategy", visible);
|
||||||
|
|
||||||
// @todo: think about a PasswordField that automatically hashes on save?
|
// @todo: think about a PasswordField that automatically hashes on save?
|
||||||
}
|
}
|
||||||
@@ -250,7 +255,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
static usersFields = {
|
static usersFields = {
|
||||||
email: text().required(),
|
email: text().required(),
|
||||||
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
|
strategy: text({
|
||||||
|
fillable: ["create"],
|
||||||
|
hidden: ["update", "form"]
|
||||||
|
}).required(),
|
||||||
strategy_value: text({
|
strategy_value: text({
|
||||||
fillable: ["create"],
|
fillable: ["create"],
|
||||||
hidden: ["read", "table", "update", "form"]
|
hidden: ["read", "table", "update", "form"]
|
||||||
|
|||||||
@@ -111,13 +111,13 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
// caused issues because this.entity() was using a reference (for when initial config was given)
|
// caused issues because this.entity() was using a reference (for when initial config was given)
|
||||||
}
|
}
|
||||||
|
|
||||||
entity(e: Entity | keyof TBD | string): Entity {
|
entity(e: Entity | keyof TBD | string, silent?: boolean): Entity {
|
||||||
// make sure to always retrieve by name
|
// make sure to always retrieve by name
|
||||||
const entity = this.entities.find((entity) =>
|
const entity = this.entities.find((entity) =>
|
||||||
e instanceof Entity ? entity.name === e.name : entity.name === e
|
e instanceof Entity ? entity.name === e.name : entity.name === e
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!entity) {
|
if (!entity && !silent) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
|||||||
import type { EntityManager } from "../entities";
|
import type { EntityManager } from "../entities";
|
||||||
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
||||||
|
|
||||||
|
// @todo: contexts need to be reworked
|
||||||
|
// e.g. "table" is irrelevant, because if read is not given, it fails
|
||||||
|
|
||||||
export const ActionContext = ["create", "read", "update", "delete"] as const;
|
export const ActionContext = ["create", "read", "update", "delete"] as const;
|
||||||
export type TActionContext = (typeof ActionContext)[number];
|
export type TActionContext = (typeof ActionContext)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "data";
|
} from "data";
|
||||||
import { Entity } from "data";
|
import { Entity } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
|
import { isEqual } from "lodash-es";
|
||||||
|
|
||||||
export type ServerEnv = {
|
export type ServerEnv = {
|
||||||
Variables: {
|
Variables: {
|
||||||
@@ -154,28 +155,33 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected ensureEntity(entity: Entity) {
|
protected ensureEntity(entity: Entity) {
|
||||||
|
const instance = this.ctx.em.entity(entity.name, true);
|
||||||
|
|
||||||
// check fields
|
// check fields
|
||||||
if (!this.ctx.em.hasEntity(entity.name)) {
|
if (!instance) {
|
||||||
this.ctx.em.addEntity(entity);
|
this.ctx.em.addEntity(entity);
|
||||||
this.ctx.flags.sync_required = true;
|
this.ctx.flags.sync_required = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = this.ctx.em.entity(entity.name);
|
|
||||||
|
|
||||||
// if exists, check all fields required are there
|
// if exists, check all fields required are there
|
||||||
// @todo: check if the field also equal
|
// @todo: check if the field also equal
|
||||||
for (const field of instance.fields) {
|
for (const field of entity.fields) {
|
||||||
const _field = entity.field(field.name);
|
const instanceField = instance.field(field.name);
|
||||||
if (!_field) {
|
if (!instanceField) {
|
||||||
entity.addField(field);
|
instance.addField(field);
|
||||||
this.ctx.flags.sync_required = true;
|
this.ctx.flags.sync_required = true;
|
||||||
|
} else {
|
||||||
|
const changes = this.setEntityFieldConfigs(field, instanceField);
|
||||||
|
if (changes > 0) {
|
||||||
|
this.ctx.flags.sync_required = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace entity (mainly to keep the ensured type)
|
// replace entity (mainly to keep the ensured type)
|
||||||
this.ctx.em.__replaceEntity(
|
this.ctx.em.__replaceEntity(
|
||||||
new Entity(entity.name, entity.fields, instance.config, entity.type)
|
new Entity(instance.name, instance.fields, instance.config, entity.type)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +199,21 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected setEntityFieldConfigs(
|
||||||
|
parent: Field,
|
||||||
|
child: Field,
|
||||||
|
props: string[] = ["hidden", "fillable", "required"]
|
||||||
|
) {
|
||||||
|
let changes = 0;
|
||||||
|
for (const prop of props) {
|
||||||
|
if (!isEqual(child.config[prop], parent.config[prop])) {
|
||||||
|
child.config[prop] = parent.config[prop];
|
||||||
|
changes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
protected replaceEntityField(
|
protected replaceEntityField(
|
||||||
_entity: string | Entity,
|
_entity: string | Entity,
|
||||||
field: Field | string,
|
field: Field | string,
|
||||||
@@ -202,6 +223,10 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
|||||||
const name = typeof field === "string" ? field : field.name;
|
const name = typeof field === "string" ? field : field.name;
|
||||||
const newField =
|
const newField =
|
||||||
_newField instanceof FieldPrototype ? make(name, _newField as any) : _newField;
|
_newField instanceof FieldPrototype ? make(name, _newField as any) : _newField;
|
||||||
|
|
||||||
|
// ensure keeping vital config
|
||||||
|
this.setEntityFieldConfigs(entity.field(name)!, newField);
|
||||||
|
|
||||||
entity.__replaceField(name, newField);
|
entity.__replaceField(name, newField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export type ModuleManagerOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ConfigTable<Json = ModuleConfigs> = {
|
type ConfigTable<Json = ModuleConfigs> = {
|
||||||
|
id?: number;
|
||||||
version: number;
|
version: number;
|
||||||
type: "config" | "diff" | "backup";
|
type: "config" | "diff" | "backup";
|
||||||
json: Json;
|
json: Json;
|
||||||
@@ -236,10 +237,10 @@ export class ModuleManager {
|
|||||||
|
|
||||||
private async fetch(): Promise<ConfigTable> {
|
private async fetch(): Promise<ConfigTable> {
|
||||||
this.logger.context("fetch").log("fetching");
|
this.logger.context("fetch").log("fetching");
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
// disabling console log, because the table might not exist yet
|
// disabling console log, because the table might not exist yet
|
||||||
return await withDisabledConsole(async () => {
|
const result = await withDisabledConsole(async () => {
|
||||||
const startTime = performance.now();
|
|
||||||
const { data: result } = await this.repo().findOne(
|
const { data: result } = await this.repo().findOne(
|
||||||
{ type: "config" },
|
{ type: "config" },
|
||||||
{
|
{
|
||||||
@@ -251,9 +252,16 @@ export class ModuleManager {
|
|||||||
throw BkndError.with("no config");
|
throw BkndError.with("no config");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear();
|
return result as unknown as ConfigTable;
|
||||||
return result as ConfigTable;
|
|
||||||
}, ["log", "error", "warn"]);
|
}, ["log", "error", "warn"]);
|
||||||
|
|
||||||
|
this.logger
|
||||||
|
.log("took", performance.now() - startTime, "ms", {
|
||||||
|
version: result.version,
|
||||||
|
id: result.id
|
||||||
|
})
|
||||||
|
.clear();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async save() {
|
async save() {
|
||||||
@@ -390,6 +398,7 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setConfigs(configs: ModuleConfigs): void {
|
private setConfigs(configs: ModuleConfigs): void {
|
||||||
|
this.logger.log("setting configs");
|
||||||
objectEach(configs, (config, key) => {
|
objectEach(configs, (config, key) => {
|
||||||
try {
|
try {
|
||||||
// setting "noEmit" to true, to not force listeners to update
|
// setting "noEmit" to true, to not force listeners to update
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.use(permission(SystemPermissions.configRead));
|
hono.use(permission(SystemPermissions.configRead));
|
||||||
|
|
||||||
|
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
|
||||||
|
// @ts-expect-error "fetch" is private
|
||||||
|
return c.json(await this.app.modules.fetch());
|
||||||
|
});
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:module?",
|
"/:module?",
|
||||||
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
}
|
|
||||||
|
|
||||||
#bknd-admin,
|
#bknd-admin,
|
||||||
.bknd-admin {
|
.bknd-admin {
|
||||||
/* Chrome, Edge, and Safari */
|
/* Chrome, Edge, and Safari */
|
||||||
|
|||||||
Reference in New Issue
Block a user