diff --git a/app/__test__/core/object/SchemaObject.spec.ts b/app/__test__/core/object/SchemaObject.spec.ts index f0d8434..01db1c7 100644 --- a/app/__test__/core/object/SchemaObject.spec.ts +++ b/app/__test__/core/object/SchemaObject.spec.ts @@ -65,11 +65,11 @@ describe("SchemaObject", async () => { expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); // array values are fully overwritten, whether accessed by index ... - m.patch("methods[0]", "POST"); - expect(m.get()).toEqual({ methods: ["POST"] }); + await m.patch("methods[0]", "POST"); + expect(m.get().methods[0]).toEqual("POST"); // or by path! - m.patch("methods", ["GET", "DELETE"]); + await m.patch("methods", ["GET", "DELETE"]); expect(m.get()).toEqual({ methods: ["GET", "DELETE"] }); }); @@ -93,15 +93,15 @@ describe("SchemaObject", async () => { expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); // expect no change, because the default then applies - m.remove("s.a"); + await m.remove("s.a"); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); // adding another path, and then deleting it - m.patch("s.c", "d"); + await m.patch("s.c", "d"); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any); // now it should be removed without applying again - m.remove("s.c"); + await m.remove("s.c"); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); }); @@ -113,14 +113,14 @@ describe("SchemaObject", async () => { ); expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); - m.set({ methods: ["GET", "POST"] }); + await m.set({ methods: ["GET", "POST"] }); expect(m.get()).toEqual({ methods: ["GET", "POST"] }); // wrong type expect(() => m.set({ methods: [1] as any })).toThrow(); }); - test("listener", async () => { + test("listener: onUpdate", async () => { let called = false; let result: any; const m = new SchemaObject( @@ -142,6 +142,30 @@ describe("SchemaObject", async () => { expect(result).toEqual({ methods: ["GET", "POST"] }); }); + test("listener: onBeforeUpdate", async () => { + let called = false; + const m = new SchemaObject( + Type.Object({ + methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }) + }), + undefined, + { + onBeforeUpdate: async (from, to) => { + await new Promise((r) => setTimeout(r, 10)); + called = true; + to.methods.push("OPTIONS"); + return to; + } + } + ); + + const result = await m.set({ methods: ["GET", "POST"] }); + expect(called).toBe(true); + expect(result).toEqual({ methods: ["GET", "POST", "OPTIONS"] }); + const [, result2] = await m.patch("methods", ["GET", "POST"]); + expect(result2).toEqual({ methods: ["GET", "POST", "OPTIONS"] }); + }); + test("throwIfRestricted", async () => { const m = new SchemaObject(Type.Object({}), undefined, { restrictPaths: ["a.b"] @@ -175,9 +199,9 @@ describe("SchemaObject", async () => { } ); - expect(() => m.patch("s.b.c", "e")).toThrow(); - expect(m.bypass().patch("s.b.c", "e")).toBeDefined(); - expect(() => m.patch("s.b.c", "f")).toThrow(); + expect(m.patch("s.b.c", "e")).rejects.toThrow(); + expect(m.bypass().patch("s.b.c", "e")).resolves.toBeDefined(); + expect(m.patch("s.b.c", "f")).rejects.toThrow(); expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } }); }); @@ -222,7 +246,7 @@ describe("SchemaObject", async () => { overwritePaths: [/^entities\..*\.fields\..*\.config/] }); - m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } }); + await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } }); expect(m.get()).toEqual({ entities: { @@ -251,7 +275,7 @@ describe("SchemaObject", async () => { overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/] }); - m.patch("entities.test", { + await m.patch("entities.test", { fields: { content: { type: "text" @@ -296,7 +320,7 @@ describe("SchemaObject", async () => { expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow(); - m.patch("entities.test", { + await m.patch("entities.test", { fields: { content: { type: "text" @@ -304,7 +328,7 @@ describe("SchemaObject", async () => { } }); - m.patch("entities.users.config", { + await m.patch("entities.users.config", { sort_dir: "desc" }); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 7861fc6..be1c7e1 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -19,10 +19,23 @@ describe("AppAuth", () => { await auth.build(); const config = auth.toJSON(); - expect(config.jwt.secret).toBeUndefined(); + expect(config.jwt).toBeUndefined(); expect(config.strategies.password.config).toBeUndefined(); }); + test("enabling auth: generate secret", async () => { + const auth = new AppAuth(undefined, ctx); + await auth.build(); + + const oldConfig = auth.toJSON(true); + //console.log(oldConfig); + await auth.schema().patch("enabled", true); + await auth.build(); + const newConfig = auth.toJSON(true); + //console.log(newConfig); + expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); + }); + test("creates user on register", async () => { const auth = new AppAuth( { diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 04a9bb8..820c3c2 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -16,6 +16,7 @@ export type CloudflareBkndConfig = { forceHttps?: boolean; }; +// @todo: move to App export type BkndConfig = { app: CreateAppConfig | ((env: Env) => CreateAppConfig); setAdminHtml?: boolean; diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index a829c8d..e0b53ce 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,16 +1,9 @@ import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import { Exception } from "core"; -import { transformObject } from "core/utils"; -import { - type Entity, - EntityIndex, - type EntityManager, - EnumField, - type Field, - type Mutator -} from "data"; +import { type Static, secureRandomString, transformObject } from "core/utils"; +import { type Entity, EntityIndex, type EntityManager } from "data"; import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; -import { cloneDeep, mergeWith, omit, pick } from "lodash-es"; +import { pick } from "lodash-es"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; @@ -22,10 +15,25 @@ declare global { } } +type AuthSchema = Static; + export class AppAuth extends Module { private _authenticator?: Authenticator; cache: Record = {}; + override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; + + if (!from.enabled && to.enabled) { + if (to.jwt.secret === defaultSecret) { + console.warn("No JWT secret provided, generating a random one"); + to.jwt.secret = secureRandomString(64); + } + } + + return to; + } + override async build() { if (!this.config.enabled) { this.setBuilt(); @@ -46,14 +54,15 @@ export class AppAuth extends Module { return new STRATEGIES[strategy.type].cls(strategy.config as any); } catch (e) { throw new Error( - `Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}` + `Could not build strategy ${String( + name + )} with config ${JSON.stringify(strategy.config)}` ); } }); - const { fields, ...jwt } = this.config.jwt; this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { - jwt + jwt: this.config.jwt }); this.registerEntities(); @@ -124,7 +133,11 @@ export class AppAuth extends Module { } private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile }); + /*console.log("--- trying to login", { + strategy: strategy.getName(), + identifier, + profile + });*/ if (!("email" in profile)) { throw new Exception("Profile must have email"); } @@ -263,17 +276,9 @@ export class AppAuth extends Module { return this.configDefault; } - const obj = { + return { ...this.config, ...this.authenticator.toJSON(secrets) }; - - return { - ...obj, - jwt: { - ...obj.jwt, - fields: this.config.jwt.fields - } - }; } } diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 19e5581..4118763 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -51,15 +51,7 @@ export const authConfigSchema = Type.Object( enabled: Type.Boolean({ default: false }), basepath: Type.String({ default: "/api/auth" }), entity_name: Type.String({ default: "users" }), - jwt: Type.Composite( - [ - jwtConfig, - Type.Object({ - fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }) - }) - ], - { default: {}, additionalProperties: false } - ), + jwt: jwtConfig, strategies: Type.Optional( StringRecord(strategiesSchema, { title: "Strategies", diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index b335623..f6114a3 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -41,10 +41,11 @@ export interface UserPool { export const jwtConfig = Type.Object( { // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth - secret: Type.String({ default: "secret" }), + secret: Type.String({ default: "" }), alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })), expiresIn: Type.Optional(Type.String()), - issuer: Type.Optional(Type.String()) + issuer: Type.Optional(Type.String()), + fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }) }, { default: {}, @@ -74,11 +75,6 @@ export class Authenticator = Record< this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); this.strategies = strategies as Strategies; this.config = parse(authenticatorConfig, config ?? {}); - - /*const secret = String(this.config.jwt.secret); - if (secret === "secret" || secret.length === 0) { - this.config.jwt.secret = randomString(64, true); - }*/ } async resolve( diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index 8865c50..c70ef28 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -11,6 +11,10 @@ import { export type SchemaObjectOptions = { onUpdate?: (config: Static) => void | Promise; + onBeforeUpdate?: ( + from: Static, + to: Static + ) => Static | Promise>; restrictPaths?: string[]; overwritePaths?: (RegExp | string)[]; forceParse?: boolean; @@ -45,6 +49,13 @@ export class SchemaObject { return this._default; } + private async onBeforeUpdate(from: Static, to: Static): Promise> { + if (this.options?.onBeforeUpdate) { + return this.options.onBeforeUpdate(from, to); + } + return to; + } + get(options?: { stripMark?: boolean }): Static { if (options?.stripMark) { return stripMark(this._config); @@ -58,8 +69,10 @@ export class SchemaObject { forceParse: true, skipMark: this.isForceParse() }); - this._value = valid; - this._config = Object.freeze(valid); + const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid); + + this._value = updatedConfig; + this._config = Object.freeze(updatedConfig); if (noEmit !== true) { await this.options?.onUpdate?.(this._config); @@ -134,7 +147,7 @@ export class SchemaObject { overwritePaths.length > 1 ? overwritePaths.filter((k) => overwritePaths.some((k2) => { - console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); + //console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); return k2 !== k && k2.startsWith(k); }) ) diff --git a/app/src/core/utils/crypto.ts b/app/src/core/utils/crypto.ts index 6996d1c..21a188a 100644 --- a/app/src/core/utils/crypto.ts +++ b/app/src/core/utils/crypto.ts @@ -27,3 +27,9 @@ export async function checksum(s: any) { const o = typeof s === "string" ? s : JSON.stringify(s); return await digest("SHA-1", o); } + +export function secureRandomString(length: number): string { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => String.fromCharCode(33 + (byte % 94))).join(""); +} diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index c3364c3..ecdf4ce 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -13,7 +13,7 @@ export type ModuleBuildContext = { guard: Guard; }; -export abstract class Module { +export abstract class Module> { private _built = false; private _schema: SchemaObject>; private _listener: any = () => null; @@ -28,10 +28,15 @@ export abstract class Module { await this._listener(c); }, restrictPaths: this.getRestrictedPaths(), - overwritePaths: this.getOverwritePaths() + overwritePaths: this.getOverwritePaths(), + onBeforeUpdate: this.onBeforeUpdate.bind(this) }); } + onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise { + return to; + } + setListener(listener: (c: ReturnType<(typeof this)["getSchema"]>) => void | Promise) { this._listener = listener; return this; @@ -92,7 +97,8 @@ export abstract class Module { }, forceParse: this.useForceParse(), restrictPaths: this.getRestrictedPaths(), - overwritePaths: this.getOverwritePaths() + overwritePaths: this.getOverwritePaths(), + onBeforeUpdate: this.onBeforeUpdate.bind(this) }); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 5777338..d425a22 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -66,10 +66,16 @@ export class SystemController implements ClassController { console.error(e); if (e instanceof TypeInvalidError) { - return c.json({ success: false, errors: e.errors }, { status: 400 }); + return c.json( + { success: false, type: "type-invalid", errors: e.errors }, + { status: 400 } + ); + } + if (e instanceof Error) { + return c.json({ success: false, type: "error", error: e.message }, { status: 500 }); } - return c.json({ success: false }, { status: 500 }); + return c.json({ success: false, type: "unknown" }, { status: 500 }); } } diff --git a/app/src/ui/client/schema/actions.ts b/app/src/ui/client/schema/actions.ts index fc10000..8020d9b 100644 --- a/app/src/ui/client/schema/actions.ts +++ b/app/src/ui/client/schema/actions.ts @@ -1,4 +1,4 @@ -import { set } from "lodash-es"; +import { type NotificationData, notifications } from "@mantine/notifications"; import type { ModuleConfigs } from "../../../modules"; import type { AppQueryClient } from "../utils/AppQueryClient"; @@ -12,6 +12,25 @@ export type TSchemaActions = ReturnType; export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { const baseUrl = client.baseUrl; const token = client.auth().state()?.token; + + async function displayError(action: string, module: string, res: Response, path?: string) { + const notification_data: NotificationData = { + id: "schema-error-" + [action, module, path].join("-"), + title: `Config update failed${path ? ": " + path : ""}`, + message: "Failed to complete config update", + color: "red", + position: "top-right", + withCloseButton: true, + autoClose: false + }; + try { + const { error } = (await res.json()) as any; + notifications.show({ ...notification_data, message: error }); + } catch (e) { + notifications.show(notification_data); + } + } + return { set: async ( module: keyof ModuleConfigs, @@ -46,6 +65,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { } return data.success; + } else { + await displayError("set", module, res); } return false; @@ -80,6 +101,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { } return data.success; + } else { + await displayError("patch", module, res, path); } return false; @@ -114,6 +137,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { } return data.success; + } else { + await displayError("overwrite", module, res, path); } return false; @@ -149,6 +174,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { } return data.success; + } else { + await displayError("add", module, res, path); } return false; @@ -182,6 +209,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { } return data.success; + } else { + await displayError("remove", module, res, path); } return false; diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 788406d..4186b6e 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { serveStatic } from "@hono/node-server/serve-static"; import { createClient } from "@libsql/client/node"; -import { App, type BkndConfig } from "./src"; +import { App, type BkndConfig, type CreateAppConfig } from "./src"; import { LibsqlConnection } from "./src/data"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { registries } from "./src/modules/registries"; @@ -26,14 +26,14 @@ window.__vite_plugin_react_preamble_installed__ = true function createApp(config: BkndConfig, env: any) { const create_config = typeof config.app === "function" ? config.app(env) : config.app; - return App.create(create_config); + return App.create(create_config as CreateAppConfig); } function setAppBuildListener(app: App, config: BkndConfig, html: string) { app.emgr.on( "app-built", async () => { - await config.onBuilt?.(app); + await config.onBuilt?.(app as any); app.module.server.setAdminHtml(html); app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); },