diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index a479d19..95cac8e 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -1,8 +1,8 @@ /// import { describe, expect, it } from "bun:test"; import { Hono } from "hono"; -import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils"; -import { MediaApi } from "../../src/media/api/MediaApi"; +import { getFileFromContext, isFile, isReadableStream } from "core/utils"; +import { MediaApi } from "media/api/MediaApi"; import { assetsPath, assetsTmpPath } from "../helper"; const mockedBackend = new Hono() @@ -39,10 +39,28 @@ describe("MediaApi", () => { // @ts-ignore tests const api = new MediaApi({ token: "token", + token_transport: "header", }); expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); }); + it("should return empty headers if not using `header` transport", () => { + expect( + new MediaApi({ + token_transport: "cookie", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + expect( + new MediaApi({ + token_transport: "none", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + }); + it("should get file: native", async () => { const name = "image.png"; const path = `${assetsTmpPath}/${name}`; @@ -103,8 +121,12 @@ describe("MediaApi", () => { }); it("should upload file in various ways", async () => { - // @ts-ignore tests - const api = new MediaApi({}, mockedBackend.request); + const api = new MediaApi( + { + upload_fetcher: mockedBackend.request, + }, + mockedBackend.request, + ); const file = Bun.file(`${assetsPath}/image.png`); async function matches(req: Promise, filename: string) { diff --git a/app/__test__/core/Registry.spec.ts b/app/__test__/core/Registry.spec.ts index 637a836..2a11250 100644 --- a/app/__test__/core/Registry.spec.ts +++ b/app/__test__/core/Registry.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; -import type { TObject, TString } from "@sinclair/typebox"; -import { Registry } from "../../src/core/registry/Registry"; -import { type TSchema, Type } from "../../src/core/utils"; +import { type TObject, type TString, Type } from "@sinclair/typebox"; +import { Registry } from "core"; type Constructor = new (...args: any[]) => T; diff --git a/app/__test__/core/object/SchemaObject.spec.ts b/app/__test__/core/object/SchemaObject.spec.ts index 0c745e4..580ab57 100644 --- a/app/__test__/core/object/SchemaObject.spec.ts +++ b/app/__test__/core/object/SchemaObject.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { SchemaObject } from "../../../src/core"; -import { Type } from "../../../src/core/utils"; +import { Type } from "@sinclair/typebox"; describe("SchemaObject", async () => { test("basic", async () => { diff --git a/app/__test__/data/specs/Entity.spec.ts b/app/__test__/data/specs/Entity.spec.ts index 358c106..033d10b 100644 --- a/app/__test__/data/specs/Entity.spec.ts +++ b/app/__test__/data/specs/Entity.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { Entity, NumberField, TextField } from "../../../src/data"; +import { Entity, NumberField, TextField } from "data"; +import * as p from "data/prototype"; describe("[data] Entity", async () => { const entity = new Entity("test", [ @@ -47,14 +48,7 @@ describe("[data] Entity", async () => { expect(entity.getField("new_field")).toBe(field); }); - // @todo: move this to ClientApp - /*test("serialize and deserialize", async () => { - const json = entity.toJSON(); - //sconsole.log("json", json.fields); - const newEntity = Entity.deserialize(json); - //console.log("newEntity", newEntity.toJSON().fields); - expect(newEntity).toBeInstanceOf(Entity); - expect(json).toEqual(newEntity.toJSON()); - expect(json.fields).toEqual(newEntity.toJSON().fields); - });*/ + test.only("types", async () => { + console.log(entity.toTypes()); + }); }); diff --git a/app/__test__/data/specs/EntityManager.spec.ts b/app/__test__/data/specs/EntityManager.spec.ts index c9b5dba..6401995 100644 --- a/app/__test__/data/specs/EntityManager.spec.ts +++ b/app/__test__/data/specs/EntityManager.spec.ts @@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => { em.addRelation(new ManyToOneRelation(posts, users)); expect(em.relations.all.length).toBe(1); expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation); - expect(em.relationsOf("users")).toEqual([em.relations.all[0]]); - expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]); + expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]); + expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]); expect(em.hasRelations("users")).toBe(true); expect(em.hasRelations("posts")).toBe(true); expect(em.relatedEntitiesOf("users")).toEqual([posts]); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index f50ca83..54254d4 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -266,5 +266,12 @@ describe("[data] Repository (Events)", async () => { expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); events.clear(); + + // check find one on findMany with limit 1 + await repo.findMany({ where: { id: 1 }, limit: 1 }); + await repo.emgr.executeAsyncs(); + expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); + expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); + events.clear(); }); }); diff --git a/app/__test__/data/specs/fields/FieldIndex.spec.ts b/app/__test__/data/specs/fields/FieldIndex.spec.ts index 0dd656c..1337f63 100644 --- a/app/__test__/data/specs/fields/FieldIndex.spec.ts +++ b/app/__test__/data/specs/fields/FieldIndex.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Type } from "../../../../src/core/utils"; +import { Type } from "@sinclair/typebox"; import { Entity, EntityIndex, Field } from "../../../../src/data"; class TestField extends Field { diff --git a/app/__test__/flows/SubWorkflowTask.spec.ts b/app/__test__/flows/SubWorkflowTask.spec.ts index c52a0a2..e43473a 100644 --- a/app/__test__/flows/SubWorkflowTask.spec.ts +++ b/app/__test__/flows/SubWorkflowTask.spec.ts @@ -1,5 +1,23 @@ import { describe, expect, test } from "bun:test"; -import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows"; +import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows"; +import { Type } from "@sinclair/typebox"; + +export class StringifyTask extends Task< + typeof StringifyTask.schema, + Output +> { + type = "stringify"; + + static override schema = Type.Optional( + Type.Object({ + input: Type.Optional(Type.String()), + }), + ); + + async execute() { + return JSON.stringify(this.params.input) as Output; + } +} describe("SubFlowTask", async () => { test("Simple Subflow", async () => { @@ -22,8 +40,6 @@ describe("SubFlowTask", async () => { const execution = flow.createExecution(); await execution.start(); - /*console.log(execution.logs); - console.log(execution.getResponse());*/ expect(execution.getResponse()).toEqual("Subflow output: subflow"); }); @@ -40,8 +56,8 @@ describe("SubFlowTask", async () => { loop: true, input: [1, 2, 3], }); - const task3 = new RenderTask("render2", { - render: `Subflow output: {{ sub.output | join: ", " }}`, + const task3 = new StringifyTask("stringify", { + input: "{{ sub.output }}", }); const flow = new Flow("test", [task, task2, task3], []); @@ -51,41 +67,6 @@ describe("SubFlowTask", async () => { const execution = flow.createExecution(); await execution.start(); - console.log("errors", execution.getErrors()); - - /*console.log(execution.logs); - console.log(execution.getResponse());*/ - - expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3"); - }); - - test("Simple loop from flow input", async () => { - const subTask = new RenderTask("render", { - render: "run {{ flow.output }}", - }); - - const subflow = new Flow("subflow", [subTask]); - - const task = new LogTask("log"); - const task2 = new SubFlowTask("sub", { - flow: subflow, - loop: true, - input: "{{ flow.output | json }}", - }); - const task3 = new RenderTask("render2", { - render: `Subflow output: {{ sub.output | join: ", " }}`, - }); - - const flow = new Flow("test", [task, task2, task3], []); - flow.task(task).asInputFor(task2); - flow.task(task2).asInputFor(task3); - - const execution = flow.createExecution(); - await execution.start([4, 5, 6]); - - /*console.log(execution.logs); - console.log(execution.getResponse());*/ - - expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6"); + expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"'); }); }); diff --git a/app/__test__/flows/Task.spec.ts b/app/__test__/flows/Task.spec.ts index 2016478..4acbe78 100644 --- a/app/__test__/flows/Task.spec.ts +++ b/app/__test__/flows/Task.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Type } from "../../src/core/utils"; +import { Type } from "@sinclair/typebox"; import { Task } from "../../src/flows"; import { dynamic } from "../../src/flows/tasks/Task"; @@ -51,62 +51,4 @@ describe("Task", async () => { expect(result.test).toEqual({ key: "path", value: "1/1" }); }); - - test("resolveParams: with json", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })), - }), - { - test: "{{ some | json }}", - }, - { - some: { - key: "path", - value: "1/1", - }, - }, - ); - - expect(result.test).toEqual({ key: "path", value: "1/1" }); - }); - - test("resolveParams: with array", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Array(Type.String())), - }), - { - test: '{{ "1,2,3" | split: "," | json }}', - }, - ); - - expect(result.test).toEqual(["1", "2", "3"]); - }); - - test("resolveParams: boolean", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Boolean()), - }), - { - test: "{{ true }}", - }, - ); - - expect(result.test).toEqual(true); - }); - - test("resolveParams: float", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Number(), Number.parseFloat), - }), - { - test: "{{ 3.14 }}", - }, - ); - - expect(result.test).toEqual(3.14); - }); }); diff --git a/app/__test__/flows/inputs.test.ts b/app/__test__/flows/inputs.test.ts index 25a1c82..d1801f4 100644 --- a/app/__test__/flows/inputs.test.ts +++ b/app/__test__/flows/inputs.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { Event, EventManager } from "../../src/core/events"; -import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils"; +import { parse } from "../../src/core/utils"; +import { type Static, type StaticDecode, Type } from "@sinclair/typebox"; import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows"; import { dynamic } from "../../src/flows/tasks/Task"; diff --git a/app/__test__/flows/workflow-basic.test.ts b/app/__test__/flows/workflow-basic.test.ts index 56579f5..9df12e5 100644 --- a/app/__test__/flows/workflow-basic.test.ts +++ b/app/__test__/flows/workflow-basic.test.ts @@ -1,7 +1,8 @@ // eslint-disable-next-line import/no-unresolved import { describe, expect, test } from "bun:test"; import { isEqual } from "lodash-es"; -import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils"; +import { _jsonp, withDisabledConsole } from "../../src/core/utils"; +import { type Static, Type } from "@sinclair/typebox"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows"; /*beforeAll(disableConsoleLog); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 9091fbb..ede12f6 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -69,7 +69,7 @@ describe("AppAuth", () => { }, body: JSON.stringify({ email: "some@body.com", - password: "123456", + password: "12345678", }), }); enableConsoleLog(); @@ -81,6 +81,65 @@ describe("AppAuth", () => { } }); + test("creates user on register (bcrypt)", async () => { + const auth = new AppAuth( + { + enabled: true, + strategies: { + password: { + type: "password", + config: { + hashing: "bcrypt", + }, + }, + }, + // @ts-ignore + jwt: { + secret: "123456", + }, + }, + ctx, + ); + + await auth.build(); + await ctx.em.schema().sync({ force: true }); + + // expect no users, but the query to pass + const res = await ctx.em.repository("users").findMany(); + expect(res.data.length).toBe(0); + + const app = new AuthController(auth).getController(); + + { + disableConsoleLog(); + const res = await app.request("/password/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "some@body.com", + password: "12345678", + }), + }); + enableConsoleLog(); + expect(res.status).toBe(200); + + const { data: users } = await ctx.em.repository("users").findMany(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("some@body.com"); + } + + { + // check user in database + const rawUser = await ctx.connection.kysely + .selectFrom("users") + .selectAll() + .executeTakeFirstOrThrow(); + expect(rawUser.strategy_value).toStartWith("$"); + } + }); + test("registers auth middleware for bknd routes only", async () => { const app = createApp({ initialConfig: { diff --git a/app/__test__/modules/Module.spec.ts b/app/__test__/modules/Module.spec.ts index bcb6aa8..8cca811 100644 --- a/app/__test__/modules/Module.spec.ts +++ b/app/__test__/modules/Module.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { type TSchema, Type, stripMark } from "../../src/core/utils"; +import { stripMark } from "../../src/core/utils"; +import { type TSchema, Type } from "@sinclair/typebox"; import { EntityManager, em, entity, index, text } from "../../src/data"; import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { Module } from "../../src/modules/Module"; @@ -9,10 +10,10 @@ function createModule(schema: Schema) { getSchema() { return schema; } - toJSON() { + override toJSON() { return this.config; } - useForceParse() { + override useForceParse() { return true; } } diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index 461d11e..0ea0194 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils"; -import { Connection, entity, text } from "../../src/data"; -import { Module } from "../../src/modules/Module"; -import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager"; -import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; +import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils"; +import { Type } from "@sinclair/typebox"; +import { Connection, entity, text } from "data"; +import { Module } from "modules/Module"; +import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager"; +import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations"; import { getDummyConnection } from "../helper"; import { diff } from "core/object/diff"; import type { Static } from "@sinclair/typebox"; diff --git a/app/package.json b/app/package.json index fc7aa99..0101d93 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/bknd-io/bknd/issues" }, "scripts": { - "dev": "vite", + "dev": "BKND_CLI_LOG_LEVEL=debug vite", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts", @@ -48,16 +48,15 @@ "@cfworker/json-schema": "^4.1.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.2", "@hello-pangea/dnd": "^18.0.1", "@libsql/client": "^0.15.2", "@mantine/core": "^7.17.1", "@mantine/hooks": "^7.17.1", - "@sinclair/typebox": "^0.34.30", "@tanstack/react-form": "^1.0.5", "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", @@ -65,14 +64,12 @@ "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", "kysely": "^0.27.6", - "liquidjs": "^10.21.0", - "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", - "picocolors": "^1.1.1", "radix-ui": "^1.1.3", "swr": "^2.3.3", - "wrangler": "^4.4.1" + "lodash-es": "^4.17.21", + "@sinclair/typebox": "0.34.30" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -108,6 +105,7 @@ "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "posthog-js-lite": "^3.4.2", + "picocolors": "^1.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", diff --git a/app/src/Api.ts b/app/src/Api.ts index 593979e..3b356f6 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -8,6 +8,11 @@ import { omitKeys } from "core/utils"; export type TApiUser = SafeUser; +export type ApiFetcher = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Response | Promise; + declare global { interface Window { __BKND__: { @@ -21,7 +26,7 @@ export type ApiOptions = { headers?: Headers; key?: string; localStorage?: boolean; - fetcher?: typeof fetch; + fetcher?: ApiFetcher; verbose?: boolean; verified?: boolean; } & ( @@ -117,8 +122,6 @@ export class Api { this.updateToken(token); } } - - //console.warn("Couldn't extract token"); } updateToken(token?: string, rebuild?: boolean) { diff --git a/app/src/App.ts b/app/src/App.ts index 1e7b52a..9683616 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -15,7 +15,7 @@ import * as SystemPermissions from "modules/permissions"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; -// biome-ignore format: must be there +// biome-ignore format: must be here import { Api, type ApiOptions } from "Api"; import type { ServerEnv } from "modules/Controller"; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 523372f..8ff1d08 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -7,6 +7,7 @@ import { getFresh } from "./modes/fresh"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import type { App } from "bknd"; +import { $console } from "core"; export type CloudflareEnv = object; export type CloudflareBkndConfig = RuntimeBkndConfig & { @@ -37,7 +38,7 @@ export function serve( const url = new URL(request.url); if (config.manifest && config.static === "assets") { - console.warn("manifest is not useful with static 'assets'"); + $console.warn("manifest is not useful with static 'assets'"); } else if (!config.manifest && config.static === "kv") { throw new Error("manifest is required with static 'kv'"); } diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 4b9f3d7..0c97293 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -5,6 +5,7 @@ import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; import { makeConfig as makeAdapterConfig } from "bknd/adapter"; import type { ExecutionContext } from "hono"; +import { $console } from "core"; export const constants = { exec_async_event_id: "cf_register_waituntil", @@ -27,12 +28,12 @@ export function makeConfig( if (!appConfig.connection) { let db: D1Database | undefined; if (bindings?.db) { - console.log("Using database from bindings"); + $console.log("Using database from bindings"); db = bindings.db; } else if (Object.keys(args).length > 0) { const binding = getBinding(args, "D1Database"); if (binding) { - console.log(`Using database from env "${binding.key}"`); + $console.log(`Using database from env "${binding.key}"`); db = binding.value; } } diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 414c197..310fd24 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -3,6 +3,7 @@ import type { App, CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; import { constants, registerAsyncsExecutionContext } from "../config"; +import { $console } from "core"; export async function getDurable( config: CloudflareBkndConfig, @@ -13,7 +14,7 @@ export async function getDurable( const key = config.key ?? "app"; if ([config.onBuilt, config.beforeBuild].some((x) => x)) { - console.log("onBuilt and beforeBuild are not supported with DurableObject mode"); + $console.warn("onBuilt and beforeBuild are not supported with DurableObject mode"); } const start = performance.now(); diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index d894065..af085d6 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -20,7 +20,7 @@ export async function getFresh( ...config, onBuilt: async (app) => { registerAsyncsExecutionContext(app, ctx.ctx); - config.onBuilt?.(app); + await config.onBuilt?.(app); }, }, ctx.env, diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts index 16c4fa5..ffa98af 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import { Miniflare } from "miniflare"; import { StorageR2Adapter } from "./StorageR2Adapter"; import { adapterTestSuite } from "media"; -import { nodeTestRunner } from "adapter/node"; +import { nodeTestRunner } from "adapter/node/test"; import path from "node:path"; // https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index 62c41f6..030855a 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,8 +1,10 @@ import { registries } from "bknd"; import { isDebug } from "bknd/core"; -import { StringEnum, Type } from "bknd/utils"; +import { StringEnum } from "bknd/utils"; import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media"; import { getBindings } from "../bindings"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export function makeSchema(bindings: string[] = []) { return Type.Object( @@ -122,12 +124,10 @@ export class StorageR2Adapter extends StorageAdapter { } } - //console.log("response headers:before", headersToObject(responseHeaders)); this.writeHttpMetadata(responseHeaders, object); responseHeaders.set("etag", object.httpEtag); responseHeaders.set("Content-Length", String(object.size)); responseHeaders.set("Last-Modified", object.uploaded.toUTCString()); - //console.log("response headers:after", headersToObject(responseHeaders)); return new Response(object.body, { status: object.range ? 206 : 200, diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index c009c07..16a6b2a 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -3,7 +3,6 @@ import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageL export * from "./node.adapter"; export { StorageLocalAdapter, type LocalAdapterConfig }; -export { nodeTestRunner } from "./test"; let registered = false; export function registerLocalMediaAdapter() { diff --git a/app/src/adapter/node/node.adapter.native-spec.ts b/app/src/adapter/node/node.adapter.native-spec.ts index c4ece3b..62dcc1b 100644 --- a/app/src/adapter/node/node.adapter.native-spec.ts +++ b/app/src/adapter/node/node.adapter.native-spec.ts @@ -1,7 +1,7 @@ import { describe, before, after } from "node:test"; import * as node from "./node.adapter"; import { adapterTestSuite } from "adapter/adapter-test-suite"; -import { nodeTestRunner } from "adapter/node"; +import { nodeTestRunner } from "adapter/node/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; before(() => disableConsoleLog()); diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 816eb92..ed07800 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -4,6 +4,7 @@ import { serveStatic } from "@hono/node-server/serve-static"; import { registerLocalMediaAdapter } from "adapter/node/index"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { config as $config } from "bknd/core"; +import { $console } from "core"; type NodeEnv = NodeJS.ProcessEnv; export type NodeBkndConfig = RuntimeBkndConfig & { @@ -62,7 +63,7 @@ export function serve( fetch: createHandler(config, args, opts), }, (connInfo) => { - console.log(`Server is running on http://localhost:${connInfo.port}`); + $console.log(`Server is running on http://localhost:${connInfo.port}`); listener?.(connInfo); }, ); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts index 2177ce8..d237a53 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts @@ -1,5 +1,6 @@ import { describe } from "node:test"; -import { StorageLocalAdapter, nodeTestRunner } from "adapter/node"; +import { nodeTestRunner } from "adapter/node/test"; +import { StorageLocalAdapter } from "adapter/node"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { readFileSync } from "node:fs"; import path from "node:path"; diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts index 8a44f40..88bb395 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -1,7 +1,9 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { type Static, Type, isFile, parse } from "bknd/utils"; +import { type Static, isFile, parse } from "bknd/utils"; import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media"; import { StorageAdapter, guessMimeType as guess } from "bknd/media"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export const localAdapterConfig = Type.Object( { diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 9739566..898c9f2 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,29 +1,23 @@ -import { - type AuthAction, - AuthPermissions, - Authenticator, - type ProfileExchange, - Role, - type Strategy, -} from "auth"; +import { Authenticator, AuthPermissions, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; -import { type Static, secureRandomString, transformObject } from "core/utils"; +import { $console, type DB } from "core"; +import { secureRandomString, transformObject } from "core/utils"; import type { Entity, EntityManager } from "data"; -import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; -import { pick } from "lodash-es"; +import { em, entity, enumm, type FieldSchema, text } from "data/prototype"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; -import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; +import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; +import { AppUserPool } from "auth/AppUserPool"; +import type { AppEntity } from "core/config"; export type UserFieldSchema = FieldSchema; declare module "core" { + interface Users extends AppEntity, UserFieldSchema {} interface DB { - users: { id: PrimaryFieldType } & UserFieldSchema; + users: Users; } } -type AuthSchema = Static; export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { @@ -31,12 +25,12 @@ export class AppAuth extends Module { cache: Record = {}; _controller!: AuthController; - override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) { 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"); + $console.warn("No JWT secret provided, generating a random one"); to.jwt.secret = secureRandomString(64); } } @@ -80,7 +74,7 @@ export class AppAuth extends Module { } }); - this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { + this._authenticator = new Authenticator(strategies, new AppUserPool(this), { jwt: this.config.jwt, cookie: this.config.cookie, }); @@ -90,7 +84,7 @@ export class AppAuth extends Module { this._controller = new AuthController(this); this.ctx.server.route(this.config.basepath, this._controller.getController()); - this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); + this.ctx.guard.registerPermissions(AuthPermissions); } isStrategyEnabled(strategy: Strategy | string) { @@ -122,120 +116,6 @@ export class AppAuth extends Module { return this.ctx.em as any; } - private async resolveUser( - action: AuthAction, - strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - if (!this.config.allow_register && action === "register") { - throw new Exception("Registration is not allowed", 403); - } - - const fields = this.getUsersEntity() - .getFillableFields("create") - .map((f) => f.name); - const filteredProfile = Object.fromEntries( - Object.entries(profile).filter(([key]) => fields.includes(key)), - ); - - switch (action) { - case "login": - return this.login(strategy, identifier, filteredProfile); - case "register": - return this.register(strategy, identifier, filteredProfile); - } - } - - private filterUserData(user: any) { - return pick(user, this.config.jwt.fields); - } - - private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - this.toggleStrategyValueVisibility(true); - const result = await this.em - .repo(users as unknown as "users") - .findOne({ email: profile.email! }); - this.toggleStrategyValueVisibility(false); - if (!result.data) { - throw new Exception("User not found", 404); - } - - // compare strategy and identifier - if (result.data.strategy !== strategy.getName()) { - //console.log("!!! User registered with different strategy"); - throw new Exception("User registered with different strategy"); - } - - if (result.data.strategy_value !== identifier) { - throw new Exception("Invalid credentials"); - } - - return this.filterUserData(result.data); - } - - private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have an email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - const { data } = await this.em.repo(users).findOne({ email: profile.email! }); - if (data) { - throw new Exception("User already exists"); - } - - const payload: any = { - ...profile, - strategy: strategy.getName(), - strategy_value: identifier, - }; - - const mutator = this.em.mutator(users); - mutator.__unstable_toggleSystemEntityCreation(false); - this.toggleStrategyValueVisibility(true); - const createResult = await mutator.insertOne(payload); - mutator.__unstable_toggleSystemEntityCreation(true); - this.toggleStrategyValueVisibility(false); - if (!createResult.data) { - throw new Error("Could not create user"); - } - - return this.filterUserData(createResult.data); - } - - private toggleStrategyValueVisibility(visible: boolean) { - const toggle = (name: string, visible: boolean) => { - const field = this.getUsersEntity().field(name)!; - - if (visible) { - field.config.hidden = false; - field.config.fillable = true; - } else { - // reset to normal - const template = AppAuth.usersFields.strategy_value.config; - field.config.hidden = template.hidden; - field.config.fillable = template.fillable; - } - }; - - toggle("strategy_value", visible); - toggle("strategy", visible); - - // @todo: think about a PasswordField that automatically hashes on save? - } - getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> { const entity_name = this.config.entity_name; if (forceCreate || !this.em.hasEntity(entity_name)) { @@ -288,7 +168,7 @@ export class AppAuth extends Module { throw new Error("Cannot create user, auth not enabled"); } - const strategy = "password"; + const strategy = "password" as const; const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const strategy_value = await pw.hash(password); const mutator = this.em.mutator(this.config.entity_name as "users"); @@ -315,8 +195,7 @@ export class AppAuth extends Module { ...this.authenticator.toJSON(secrets), strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), - type: strategy.getType(), - config: strategy.toJSON(secrets), + ...strategy.toJSON(secrets), })), }; } diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts new file mode 100644 index 0000000..23f24d0 --- /dev/null +++ b/app/src/auth/AppUserPool.ts @@ -0,0 +1,83 @@ +import { AppAuth } from "auth/AppAuth"; +import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; +import { $console } from "core"; +import { pick } from "lodash-es"; +import { + InvalidConditionsException, + UnableToCreateUserException, + UserNotFoundException, +} from "auth/errors"; + +export class AppUserPool implements UserPool { + constructor(private appAuth: AppAuth) {} + + get em() { + return this.appAuth.em; + } + + get users() { + return this.appAuth.getUsersEntity(); + } + + async findBy(strategy: string, prop: keyof SafeUser, value: any) { + $console.debug("[AppUserPool:findBy]", { strategy, prop, value }); + this.toggleStrategyValueVisibility(true); + const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy }); + this.toggleStrategyValueVisibility(false); + + if (!result.data) { + $console.debug("[AppUserPool]: User not found"); + throw new UserNotFoundException(); + } + + return result.data; + } + + async create(strategy: string, payload: CreateUser & Partial>) { + $console.debug("[AppUserPool:create]", { strategy, payload }); + if (!("strategy_value" in payload)) { + throw new InvalidConditionsException("Profile must have a strategy_value value"); + } + + const fields = this.users.getSelect(undefined, "create"); + const safeProfile = pick(payload, fields) as any; + const createPayload: Omit = { + ...safeProfile, + strategy, + }; + + const mutator = this.em.mutator(this.users); + mutator.__unstable_toggleSystemEntityCreation(false); + this.toggleStrategyValueVisibility(true); + const createResult = await mutator.insertOne(createPayload); + mutator.__unstable_toggleSystemEntityCreation(true); + this.toggleStrategyValueVisibility(false); + if (!createResult.data) { + throw new UnableToCreateUserException(); + } + + $console.debug("[AppUserPool]: User created", createResult.data); + return createResult.data; + } + + private toggleStrategyValueVisibility(visible: boolean) { + const toggle = (name: string, visible: boolean) => { + const field = this.users.field(name)!; + + if (visible) { + field.config.hidden = false; + field.config.fillable = true; + } else { + // reset to normal + const template = AppAuth.usersFields.strategy_value.config; + field.config.hidden = template.hidden; + field.config.fillable = template.fillable; + } + }; + + toggle("strategy_value", visible); + toggle("strategy", visible); + + // @todo: think about a PasswordField that automatically hashes on save? + } +} diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 536f93b..1597888 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,9 +1,11 @@ import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; import { tbValidator as tb } from "core"; -import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; +import { TypeInvalidError, parse, transformObject } from "core/utils"; import { DataPermissions } from "data"; import type { Hono } from "hono"; import { Controller, type ServerEnv } from "modules/Controller"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export type AuthActionResponse = { success: boolean; diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index ce45ea5..e607d97 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,6 +1,8 @@ import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; -import { type Static, StringRecord, Type, objectTransform } from "core/utils"; +import { type Static, StringRecord, objectTransform } from "core/utils"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const Strategies = { password: { diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 78c2db7..894f09a 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,13 +1,12 @@ -import { type DB, Exception, type PrimaryFieldType } from "core"; +import { $console, type DB, Exception } from "core"; import { addFlashMessage } from "core/server/flash"; import { type Static, StringEnum, type TObject, - Type, parse, runtimeSupports, - transformObject, + truncate, } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; @@ -15,6 +14,9 @@ import { sign, verify } from "hono/jwt"; import type { CookieOptions } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; +import * as tbbox from "@sinclair/typebox"; +import { InvalidConditionsException } from "auth/errors"; +const { Type } = tbbox; type Input = any; // workaround export type JWTPayload = Parameters[0]; @@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const; export type StrategyActionName = (typeof strategyActions)[number]; export type StrategyAction = { schema: S; - preprocess: (input: unknown) => Promise>; + preprocess: (input: Static) => Promise>; }; export type StrategyActions = Partial>; // @todo: add schema to interface to ensure proper inference +// @todo: add tests (e.g. invalid strategy_value) export interface Strategy { getController: (auth: Authenticator) => Hono; getType: () => string; @@ -37,28 +40,22 @@ export interface Strategy { getActions?: () => StrategyActions; } -export type User = { - id: PrimaryFieldType; - email: string; - password: string; - role?: string | null; -}; +export type User = DB["users"]; export type ProfileExchange = { email?: string; - username?: string; - sub?: string; - password?: string; + strategy?: string; + strategy_value?: string; [key: string]: any; }; -export type SafeUser = Omit; +export type SafeUser = Omit; export type CreateUser = Pick & { [key: string]: any }; export type AuthResponse = { user: SafeUser; token: string }; -export interface UserPool { - findBy: (prop: Fields, value: string | number) => Promise; - create: (user: CreateUser) => Promise; +export interface UserPool { + findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise; + create: (strategy: string, user: CreateUser) => Promise; } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds @@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({ type AuthConfig = Static; export type AuthAction = "login" | "register"; +export type AuthResolveOptions = { + identifier?: "email" | string; + redirect?: string; + forceJsonResponse?: boolean; +}; export type AuthUserResolver = ( action: AuthAction, strategy: Strategy, - identifier: string, profile: ProfileExchange, -) => Promise; + opts?: AuthResolveOptions, +) => Promise; type AuthClaims = SafeUser & { iat: number; iss?: string; @@ -113,33 +115,117 @@ type AuthClaims = SafeUser & { }; export class Authenticator = Record> { - private readonly strategies: Strategies; private readonly config: AuthConfig; - private readonly userResolver: AuthUserResolver; - constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { - this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); - this.strategies = strategies as Strategies; + constructor( + private readonly strategies: Strategies, + private readonly userPool: UserPool, + config?: AuthConfig, + ) { this.config = parse(authenticatorConfig, config ?? {}); } - async resolve( - action: AuthAction, + async resolveLogin( + c: Context, strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - //console.log("resolve", { action, strategy: strategy.getName(), profile }); - const user = await this.userResolver(action, strategy, identifier, profile); + profile: Partial, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + // @todo: centralize identifier and checks + // @todo: check identifier value (if allowed) + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } - if (user) { - return { - user, - token: await this.jwt(user), - }; + const user = await this.userPool.findBy( + strategy.getName(), + identifier as any, + profile[identifier], + ); + + if (!user.strategy_value) { + throw new InvalidConditionsException("User must have a strategy value"); + } else if (user.strategy !== strategy.getName()) { + throw new InvalidConditionsException("User signed up with a different strategy"); + } + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + async resolveRegister( + c: Context, + strategy: Strategy, + profile: CreateUser, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } + if (!("strategy_value" in profile)) { + throw new InvalidConditionsException("Profile must have a strategy value"); + } + + const user = await this.userPool.create(strategy.getName(), { + ...profile, + strategy_value: profile.strategy_value, + }); + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) { + const successUrl = this.getSafeUrl( + c, + opts?.redirect ?? this.config.cookie.pathSuccess ?? "/", + ); + + if ("token" in data) { + await this.setAuthCookie(c, data.token); + + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + return c.json(data); + } + + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); } - throw new Error("User could not be resolved"); + throw new Exception("Invalid response"); + } + + async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) { + $console.error("respondWithError", error); + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + // let the server handle it + throw error; + } + + await addFlashMessage(c, String(error), "error"); + + const referer = this.getSafeUrl(c, opts?.redirect ?? c.req.header("Referer") ?? "/"); + return c.redirect(referer); } getStrategies(): Strategies { @@ -158,7 +244,7 @@ export class Authenticator = Record< } // @todo: add jwt tests - async jwt(_user: Omit): Promise { + async jwt(_user: SafeUser | ProfileExchange): Promise { const user = pick(_user, this.config.jwt.fields); const payload: JWTPayload = { @@ -184,6 +270,14 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } + async safeAuthResponse(_user: User): Promise { + const user = pick(_user, this.config.jwt.fields) as SafeUser; + return { + user, + token: await this.jwt(user), + }; + } + async verify(jwt: string): Promise { try { const payload = await verify( @@ -225,7 +319,7 @@ export class Authenticator = Record< return token; } catch (e: any) { if (e instanceof Error) { - console.error("[Error:getAuthCookie]", e.message); + $console.error("[getAuthCookie]", e.message); } return undefined; @@ -242,11 +336,13 @@ export class Authenticator = Record< } private async setAuthCookie(c: Context, token: string) { + $console.debug("setting auth cookie", truncate(token)); const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } private async deleteAuthCookie(c: Context) { + $console.debug("deleting auth cookie"); await deleteCookie(c, "auth", this.cookieOptions); } @@ -262,7 +358,6 @@ export class Authenticator = Record< // @todo: move this to a server helper isJsonRequest(c: Context): boolean { - //return c.req.header("Content-Type") === "application/x-www-form-urlencoded"; return c.req.header("Content-Type") === "application/json"; } @@ -286,37 +381,6 @@ export class Authenticator = Record< return p; } - async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { - const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/"); - const referer = redirect ?? c.req.header("Referer") ?? successUrl; - //console.log("auth respond", { redirect, successUrl, successPath }); - - if ("token" in data) { - await this.setAuthCookie(c, data.token); - - if (this.isJsonRequest(c)) { - return c.json(data); - } - - // can't navigate to "/" – doesn't work on nextjs - //console.log("auth success, redirecting to", successUrl); - return c.redirect(successUrl); - } - - if (this.isJsonRequest(c)) { - return c.json(data, 400); - } - - let message = "An error occured"; - if (data instanceof Exception) { - message = data.message; - } - - await addFlashMessage(c, message, "error"); - //console.log("auth failed, redirecting to", referer); - return c.redirect(referer); - } - // @todo: don't extract user from token, but from the database or cache async resolveAuthFromRequest(c: Context): Promise { let token: string | undefined; @@ -341,13 +405,3 @@ export class Authenticator = Record< }; } } - -export function createStrategyAction( - schema: S, - preprocess: (input: Static) => Promise>, -) { - return { - schema, - preprocess, - } as StrategyAction; -} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 8f30c58..706e14b 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,152 +1,135 @@ -import type { Authenticator, Strategy } from "auth"; -import { isDebug, tbValidator as tb } from "core"; -import { type Static, StringEnum, Type, parse } from "core/utils"; -import { hash } from "core/utils"; -import { type Context, Hono } from "hono"; -import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator"; +import { type Authenticator, InvalidCredentialsException, type User } from "auth"; +import { $console, tbValidator as tb } from "core"; +import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils"; +import { Hono } from "hono"; +import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; +import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "./Strategy"; -type LoginSchema = { username: string; password: string } | { email: string; password: string }; -type RegisterSchema = { email: string; password: string; [key: string]: any }; +const { Type } = tbbox; -const schema = Type.Object({ - hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }), +const schema = StrictObject({ + hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }), + rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })), }); export type PasswordStrategyOptions = Static; -/*export type PasswordStrategyOptions2 = { - hashing?: "plain" | "bcrypt" | "sha256"; -};*/ -export class PasswordStrategy implements Strategy { - private options: PasswordStrategyOptions; +export class PasswordStrategy extends Strategy { + constructor(config: Partial = {}) { + super(config as any, "password", "password", "form"); - constructor(options: Partial = {}) { - this.options = parse(schema, options); - } - - async hash(password: string) { - switch (this.options.hashing) { - case "sha256": - return hash.sha256(password); - default: - return password; - } - } - - async login(input: LoginSchema) { - if (!("email" in input) || !("password" in input)) { - throw new Error("Invalid input: Email and password must be provided"); - } - - const hashedPassword = await this.hash(input.password); - return { ...input, password: hashedPassword }; - } - - async register(input: RegisterSchema) { - if (!input.email || !input.password) { - throw new Error("Invalid input: Email and password must be provided"); - } - - return { - ...input, - password: await this.hash(input.password), - }; - } - - getController(authenticator: Authenticator): Hono { - const hono = new Hono(); - - return hono - .post( - "/login", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - try { - const payload = await this.login(body); - const data = await authenticator.resolve( - "login", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - } catch (e) { - return await authenticator.respond(c, e); - } - }, - ) - .post( - "/register", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - const payload = await this.register(body); - const data = await authenticator.resolve( - "register", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - }, - ); - } - - getActions(): StrategyActions { - return { - create: createStrategyAction( - Type.Object({ - email: Type.String({ - pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", - }), - password: Type.String({ - minLength: 8, // @todo: this should be configurable - }), - }), - async ({ password, ...input }) => { - return { - ...input, - strategy_value: await this.hash(password), - }; - }, - ), - }; + this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => { + return { + ...input, + strategy_value: await this.hash(password), + }; + }); } getSchema() { return schema; } - getType() { - return "password"; + private getPayloadSchema() { + return Type.Object({ + email: Type.String({ + pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$", + }), + password: Type.String({ + minLength: 8, // @todo: this should be configurable + }), + }); } - getMode() { - return "form" as const; + async hash(password: string) { + switch (this.config.hashing) { + case "sha256": + return hash.sha256(password); + case "bcrypt": { + const salt = await bcryptGenSalt(this.config.rounds ?? 4); + return bcryptHash(password, salt); + } + default: + return password; + } } - getName() { - return "password" as const; + async compare(actual: string, compare: string): Promise { + switch (this.config.hashing) { + case "sha256": { + const compareHashed = await this.hash(compare); + return actual === compareHashed; + } + case "bcrypt": + return await bcryptCompare(compare, actual); + } + + return false; } - toJSON(secrets?: boolean) { - return secrets ? this.options : undefined; + verify(password: string) { + return async (user: User) => { + const compare = await this.compare(user?.strategy_value!, password); + if (compare !== true) { + throw new InvalidCredentialsException(); + } + }; + } + + getController(authenticator: Authenticator): Hono { + const hono = new Hono(); + const redirectQuerySchema = Type.Object({ + redirect: Type.Optional(Type.String()), + }); + const payloadSchema = this.getPayloadSchema(); + + hono.post("/login", tb("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", tb("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; } } diff --git a/app/src/auth/authenticate/strategies/Strategy.ts b/app/src/auth/authenticate/strategies/Strategy.ts new file mode 100644 index 0000000..28fb95c --- /dev/null +++ b/app/src/auth/authenticate/strategies/Strategy.ts @@ -0,0 +1,63 @@ +import type { + Authenticator, + StrategyAction, + StrategyActionName, + StrategyActions, +} from "../Authenticator"; +import type { Hono } from "hono"; +import type { Static, TSchema } from "@sinclair/typebox"; +import { parse, type TObject } from "core/utils"; + +export type StrategyMode = "form" | "external"; + +export abstract class Strategy { + protected actions: StrategyActions = {}; + + constructor( + protected config: Static, + public type: string, + public name: string, + public mode: StrategyMode, + ) { + // don't worry about typing, it'll throw if invalid + this.config = parse(this.getSchema(), (config ?? {}) as any) as Static; + } + + protected registerAction( + name: StrategyActionName, + schema: S, + preprocess: StrategyAction["preprocess"], + ): void { + this.actions[name] = { + schema, + preprocess, + } as const; + } + + protected abstract getSchema(): Schema; + + abstract getController(auth: Authenticator): Hono; + + getType(): string { + return this.type; + } + + getMode() { + return this.mode; + } + + getName(): string { + return this.name; + } + + toJSON(secrets?: boolean): { type: string; config: Static | {} | undefined } { + return { + type: this.getType(), + config: secrets ? this.config : undefined, + }; + } + + getActions(): StrategyActions { + return this.actions; + } +} diff --git a/app/src/auth/authenticate/strategies/index.ts b/app/src/auth/authenticate/strategies/index.ts index 86c3ba2..2ca487e 100644 --- a/app/src/auth/authenticate/strategies/index.ts +++ b/app/src/auth/authenticate/strategies/index.ts @@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy"; export * as issuers from "./oauth/issuers"; export { - PasswordStrategy, type PasswordStrategyOptions, + PasswordStrategy, OAuthStrategy, OAuthCallbackException, CustomOAuthStrategy, diff --git a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts index 6e7f458..9e9c3b8 100644 --- a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts @@ -1,43 +1,35 @@ -import { type Static, StringEnum, Type } from "core/utils"; +import { type Static, StrictObject, StringEnum } from "core/utils"; +import * as tbbox from "@sinclair/typebox"; import type * as oauth from "oauth4webapi"; import { OAuthStrategy } from "./OAuthStrategy"; +const { Type } = tbbox; type SupportedTypes = "oauth2" | "oidc"; type RequireKeys = Required> & Omit; const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" }); -const oauthSchemaCustom = Type.Object( +const oauthSchemaCustom = StrictObject( { type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: Type.String(), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - token_endpoint_auth_method: StringEnum(["client_secret_basic"]), - }, - { - additionalProperties: false, - }, - ), - as: Type.Object( - { - issuer: Type.String(), - code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), - scopes_supported: Type.Optional(Type.Array(Type.String())), - scope_separator: Type.Optional(Type.String({ default: " " })), - authorization_endpoint: Type.Optional(UrlString), - token_endpoint: Type.Optional(UrlString), - userinfo_endpoint: Type.Optional(UrlString), - }, - { - additionalProperties: false, - }, - ), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + token_endpoint_auth_method: StringEnum(["client_secret_basic"]), + }), + as: StrictObject({ + issuer: Type.String(), + code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), + scopes_supported: Type.Optional(Type.Array(Type.String())), + scope_separator: Type.Optional(Type.String({ default: " " })), + authorization_endpoint: Type.Optional(UrlString), + token_endpoint: Type.Optional(UrlString), + userinfo_endpoint: Type.Optional(UrlString), + }), // @todo: profile mapping }, - { title: "Custom OAuth", additionalProperties: false }, + { title: "Custom OAuth" }, ); type OAuthConfigCustom = Static; @@ -62,6 +54,11 @@ export type IssuerConfig = { }; export class CustomOAuthStrategy extends OAuthStrategy { + constructor(config: OAuthConfigCustom) { + super(config as any); + this.type = "custom_oauth"; + } + override getIssuerConfig(): IssuerConfig { return { ...this.config, profile: async (info) => info } as any; } @@ -70,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy { override getSchema() { return oauthSchemaCustom; } - - override getType() { - return "custom_oauth"; - } } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index b3177b5..2055d17 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -1,10 +1,13 @@ -import type { AuthAction, Authenticator, Strategy } from "auth"; +import type { AuthAction, Authenticator } from "auth"; import { Exception, isDebug } from "core"; -import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils"; +import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils"; import { type Context, Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import * as oauth from "oauth4webapi"; import * as issuers from "./issuers"; +import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "auth/authenticate/strategies/Strategy"; +const { Type } = tbbox; type ConfiguredIssuers = keyof typeof issuers; type SupportedTypes = "oauth2" | "oidc"; @@ -13,17 +16,12 @@ type RequireKeys = Required> & O const schemaProvided = Type.Object( { - //type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - }, - { - additionalProperties: false, - }, - ), + type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + }), }, { title: "OAuth" }, ); @@ -71,11 +69,13 @@ export class OAuthCallbackException extends Exception { } } -export class OAuthStrategy implements Strategy { - constructor(private _config: OAuthConfig) {} +export class OAuthStrategy extends Strategy { + constructor(config: ProvidedOAuthConfig) { + super(config, "oauth", config.name, "external"); + } - get config() { - return this._config; + getSchema() { + return schemaProvided; } getIssuerConfig(): IssuerConfig { @@ -103,7 +103,7 @@ export class OAuthStrategy implements Strategy { type: info.type, client: { ...info.client, - ...this._config.client, + ...this.config.client, }, }; } @@ -172,8 +172,7 @@ export class OAuthStrategy implements Strategy { ) { const config = await this.getConfig(); const { client, as, type } = config; - //console.log("config", config); - console.log("callbackParams", callbackParams, options); + const parameters = oauth.validateAuthResponse( as, client, // no client_secret required @@ -181,13 +180,9 @@ export class OAuthStrategy implements Strategy { oauth.expectNoState, ); if (oauth.isOAuth2Error(parameters)) { - //console.log("callback.error", parameters); throw new OAuthCallbackException(parameters, "validateAuthResponse"); } - /*console.log( - "callback.parameters", - JSON.stringify(Object.fromEntries(parameters.entries()), null, 2), - );*/ + const response = await oauth.authorizationCodeGrantRequest( as, client, @@ -195,13 +190,9 @@ export class OAuthStrategy implements Strategy { options.redirect_uri, options.state, ); - //console.log("callback.response", response); const challenges = oauth.parseWwwAuthenticateChallenges(response); if (challenges) { - for (const challenge of challenges) { - //console.log("callback.challenge", challenge); - } // @todo: Handle www-authenticate challenges as needed throw new OAuthCallbackException(challenges, "www-authenticate"); } @@ -216,20 +207,13 @@ export class OAuthStrategy implements Strategy { expectedNonce, ); if (oauth.isOAuth2Error(result)) { - console.log("callback.error", result); // @todo: Handle OAuth 2.0 response body error throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse"); } - //console.log("callback.result", result); - const claims = oauth.getValidatedIdTokenClaims(result); - //console.log("callback.IDTokenClaims", claims); - const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!); - const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest); - //console.log("callback.resultUser", resultUser); return await config.profile(resultUser, config, claims); // @todo: check claims } @@ -240,8 +224,7 @@ export class OAuthStrategy implements Strategy { ) { const config = await this.getConfig(); const { client, type, as, profile } = config; - console.log("config", { client, as, type }); - console.log("callbackParams", callbackParams, options); + const parameters = oauth.validateAuthResponse( as, client, // no client_secret required @@ -249,13 +232,9 @@ export class OAuthStrategy implements Strategy { oauth.expectNoState, ); if (oauth.isOAuth2Error(parameters)) { - console.log("callback.error", parameters); throw new OAuthCallbackException(parameters, "validateAuthResponse"); } - console.log( - "callback.parameters", - JSON.stringify(Object.fromEntries(parameters.entries()), null, 2), - ); + const response = await oauth.authorizationCodeGrantRequest( as, client, @@ -266,9 +245,6 @@ export class OAuthStrategy implements Strategy { const challenges = oauth.parseWwwAuthenticateChallenges(response); if (challenges) { - for (const challenge of challenges) { - //console.log("callback.challenge", challenge); - } // @todo: Handle www-authenticate challenges as needed throw new OAuthCallbackException(challenges, "www-authenticate"); } @@ -279,19 +255,15 @@ export class OAuthStrategy implements Strategy { try { result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response); if (oauth.isOAuth2Error(result)) { - console.log("error", result); throw new Error(); // Handle OAuth 2.0 response body error } } catch (e) { result = (await copy.json()) as any; - console.log("failed", result); } const res2 = await oauth.userInfoRequest(as, client, result.access_token!); const user = await res2.json(); - console.log("res2", res2, user); - console.log("result", result); return await config.profile(user, config, result); } @@ -301,7 +273,6 @@ export class OAuthStrategy implements Strategy { ): Promise { const type = this.getIssuerConfig().type; - console.log("type", type); switch (type) { case "oidc": return await this.oidc(callbackParams, options); @@ -325,7 +296,6 @@ export class OAuthStrategy implements Strategy { }; const setState = async (c: Context, config: TState): Promise => { - console.log("--- setting state", config); await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, { secure: true, httpOnly: true, @@ -356,7 +326,6 @@ export class OAuthStrategy implements Strategy { const params = new URLSearchParams(url.search); const state = await getState(c); - console.log("state", state); // @todo: add config option to determine if state.action is allowed const redirect_uri = @@ -369,21 +338,28 @@ export class OAuthStrategy implements Strategy { state: state.state, }); - try { - const data = await auth.resolve(state.action, this, profile.sub, profile); - console.log("******** RESOLVED ********", data); + const safeProfile = { + email: profile.email, + strategy_value: profile.sub, + } as const; - if (state.mode === "cookie") { - return await auth.respond(c, data, state.redirect); + const verify = async (user) => { + if (user.strategy_value !== profile.sub) { + throw new Exception("Invalid credentials"); } + }; + const opts = { + redirect: state.redirect, + forceJsonResponse: state.mode !== "cookie", + } as const; - return c.json(data); - } catch (e) { - if (state.mode === "cookie") { - return await auth.respond(c, e, state.redirect); - } - - throw e; + switch (state.action) { + case "login": + return auth.resolveLogin(c, this, safeProfile, verify, opts); + case "register": + return auth.resolveRegister(c, this, safeProfile, verify, opts); + default: + throw new Error("Invalid action"); } }); @@ -412,10 +388,8 @@ export class OAuthStrategy implements Strategy { redirect_uri, state, }); - //console.log("_state", state); await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" }); - console.log("--redirecting to", response.url); return c.redirect(response.url); }); @@ -456,28 +430,15 @@ export class OAuthStrategy implements Strategy { return hono; } - getType() { - return "oauth"; - } - - getMode() { - return "external" as const; - } - - getName() { - return this.config.name; - } - - getSchema() { - return schemaProvided; - } - - toJSON(secrets?: boolean) { + override toJSON(secrets?: boolean) { const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]); return { - type: this.getIssuerConfig().type, - ...config, + ...super.toJSON(secrets), + config: { + ...config, + type: this.getIssuerConfig().type, + }, }; } } diff --git a/app/src/auth/authenticate/strategies/oauth/issuers/github.ts b/app/src/auth/authenticate/strategies/oauth/issuers/github.ts index 4f98b9f..2874761 100644 --- a/app/src/auth/authenticate/strategies/oauth/issuers/github.ts +++ b/app/src/auth/authenticate/strategies/oauth/issuers/github.ts @@ -34,8 +34,6 @@ export const github: IssuerConfig = { config: Omit, tokenResponse: any, ) => { - console.log("github info", info, config, tokenResponse); - try { const res = await fetch("https://api.github.com/user/emails", { headers: { @@ -45,7 +43,6 @@ export const github: IssuerConfig = { }, }); const data = (await res.json()) as GithubUserEmailResponse; - console.log("data", data); const email = data.find((e: any) => e.primary)?.email; if (!email) { throw new Error("No primary email found"); diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 8a1da25..a45c160 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,4 +1,4 @@ -import { Exception, Permission } from "core"; +import { $console, Exception, Permission } from "core"; import { objectTransform } from "core/utils"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; @@ -14,8 +14,6 @@ export type GuardConfig = { }; export type GuardContext = Context | GuardUserContext; -const debug = false; - export class Guard { permissions: Permission[]; roles?: Role[]; @@ -83,8 +81,12 @@ export class Guard { return this; } - registerPermissions(permissions: Permission[]) { - for (const permission of permissions) { + registerPermissions(permissions: Record); + registerPermissions(permissions: Permission[]); + registerPermissions(permissions: Permission[] | Record) { + const p = Array.isArray(permissions) ? permissions : Object.values(permissions); + + for (const permission of p) { this.registerPermission(permission); } @@ -95,16 +97,14 @@ export class Guard { if (user && typeof user.role === "string") { const role = this.roles?.find((role) => role.name === user?.role); if (role) { - debug && console.log("guard: role found", [user.role]); + $console.debug(`guard: role "${user.role}" found`); return role; } } - debug && - console.log("guard: role not found", { - user: user, - role: user?.role, - }); + $console.debug("guard: role not found", { + user, + }); return this.getDefaultRole(); } @@ -120,11 +120,14 @@ export class Guard { hasPermission(name: string, user?: GuardUserContext): boolean; hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { if (!this.isEnabled()) { - //console.log("guard not enabled, allowing"); 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`); @@ -133,10 +136,10 @@ export class Guard { const role = this.getUserRole(user); if (!role) { - debug && console.log("guard: role not found, denying"); + $console.debug("guard: user has no role, denying"); return false; } else if (role.implicit_allow === true) { - debug && console.log("guard: role implicit allow, allowing"); + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); return true; } @@ -144,12 +147,11 @@ export class Guard { (rolePermission) => rolePermission.permission.name === name, ); - debug && - console.log("guard: rolePermission, allowing?", { - permission: name, - role: role.name, - allowing: !!rolePermission, - }); + $console.debug("guard: rolePermission, allowing?", { + permission: name, + role: role.name, + allowing: !!rolePermission, + }); return !!rolePermission; } diff --git a/app/src/auth/errors.ts b/app/src/auth/errors.ts index e10266e..7b725af 100644 --- a/app/src/auth/errors.ts +++ b/app/src/auth/errors.ts @@ -1,28 +1,66 @@ -import { Exception } from "core"; +import { Exception, isDebug } from "core"; +import { HttpStatus } from "core/utils"; -export class UserExistsException extends Exception { +export class AuthException extends Exception { + getSafeErrorAndCode() { + return { + error: "Invalid credentials", + code: HttpStatus.UNAUTHORIZED, + }; + } + + override toJSON(): any { + if (isDebug()) { + return super.toJSON(); + } + + return { + error: this.getSafeErrorAndCode().error, + type: "AuthException", + }; + } +} + +export class UserExistsException extends AuthException { override name = "UserExistsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor() { super("User already exists"); } } -export class UserNotFoundException extends Exception { +export class UserNotFoundException extends AuthException { override name = "UserNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor() { super("User not found"); } } -export class InvalidCredentialsException extends Exception { +export class InvalidCredentialsException extends AuthException { override name = "InvalidCredentialsException"; - override code = 401; + override code = HttpStatus.UNAUTHORIZED; constructor() { super("Invalid credentials"); } } + +export class UnableToCreateUserException extends AuthException { + override name = "UnableToCreateUserException"; + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + constructor() { + super("Unable to create user"); + } +} + +export class InvalidConditionsException extends AuthException { + override code = HttpStatus.UNPROCESSABLE_ENTITY; + + constructor(message: string) { + super(message ?? "Invalid conditions"); + } +} diff --git a/app/src/auth/index.ts b/app/src/auth/index.ts index b99de5a..513eb9a 100644 --- a/app/src/auth/index.ts +++ b/app/src/auth/index.ts @@ -1,5 +1,4 @@ export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors"; -export { sha256 } from "./utils/hash"; export { type ProfileExchange, type Strategy, diff --git a/app/src/auth/utils/hash.ts b/app/src/auth/utils/hash.ts deleted file mode 100644 index 5056938..0000000 --- a/app/src/auth/utils/hash.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @deprecated: moved to @bknd/core -export async function sha256(password: string, salt?: string) { - // 1. Convert password to Uint8Array - const encoder = new TextEncoder(); - const data = encoder.encode((salt ?? "") + password); - - // 2. Hash the data using SHA-256 - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - - // 3. Convert hash to hex string for easier display - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/app/src/cli/commands/config.ts b/app/src/cli/commands/config.ts index 3d853ab..81e6cb7 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -8,6 +8,8 @@ export const config: CliCommand = (program) => { .option("--pretty", "pretty print") .action((options) => { const config = getDefaultConfig(); + + // biome-ignore lint/suspicious/noConsoleLog: console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); }); }; diff --git a/app/src/cli/commands/copy-assets.ts b/app/src/cli/commands/copy-assets.ts index 813288c..d9d1d12 100644 --- a/app/src/cli/commands/copy-assets.ts +++ b/app/src/cli/commands/copy-assets.ts @@ -32,5 +32,6 @@ async function action(options: { out?: string; clean?: boolean }) { // delete ".vite" directory in out await fs.rm(path.resolve(out, ".vite"), { recursive: true }); + // biome-ignore lint/suspicious/noConsoleLog: console.log(c.green(`Assets copied to: ${c.bold(out)}`)); } diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index 3c5233d..e3d7fc4 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -43,10 +43,12 @@ export const create: CliCommand = (program) => { function errorOutro() { $p.outro(color.red("Failed to create project.")); + // biome-ignore lint/suspicious/noConsoleLog: console.log( color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") + color.cyan("https://github.com/bknd-io/bknd/issues"), ); + // biome-ignore lint/suspicious/noConsoleLog: console.log(""); process.exit(1); } @@ -55,7 +57,14 @@ async function onExit() { await flush(); } -async function action(options: { template?: string; dir?: string; integration?: string, yes?: boolean, clean?: boolean }) { +async function action(options: { + template?: string; + dir?: string; + integration?: string; + yes?: boolean; + clean?: boolean; +}) { + // biome-ignore lint/suspicious/noConsoleLog: console.log(""); const $t = createScoped("create"); $t.capture("start", { @@ -96,10 +105,12 @@ async function action(options: { template?: string; dir?: string; integration?: $t.properties.at = "dir"; if (fs.existsSync(downloadOpts.dir)) { - const clean = options.clean ?? await $p.confirm({ - message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, - initialValue: false, - }); + const clean = + options.clean ?? + (await $p.confirm({ + message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, + initialValue: false, + })); if ($p.isCancel(clean)) { await onExit(); process.exit(1); @@ -174,8 +185,6 @@ async function action(options: { template?: string; dir?: string; integration?: process.exit(1); } - //console.log("integration", { type, integration }); - const choices = templates.filter((t) => t.integration === integration); if (choices.length === 0) { await onExit(); @@ -261,9 +270,11 @@ async function action(options: { template?: string; dir?: string; integration?: $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); { - const install = options.yes ?? await $p.confirm({ - message: "Install dependencies?", - }); + const install = + options.yes ?? + (await $p.confirm({ + message: "Install dependencies?", + })); if ($p.isCancel(install)) { await onExit(); diff --git a/app/src/cli/commands/debug.ts b/app/src/cli/commands/debug.ts index 9f817c3..a9a1e6f 100644 --- a/app/src/cli/commands/debug.ts +++ b/app/src/cli/commands/debug.ts @@ -17,6 +17,7 @@ export const debug: CliCommand = (program) => { const subjects = { paths: async () => { + // biome-ignore lint/suspicious/noConsoleLog: console.log("[PATHS]", { rootpath: getRootPath(), distPath: getDistPath(), @@ -27,6 +28,7 @@ const subjects = { }); }, routes: async () => { + // biome-ignore lint/suspicious/noConsoleLog: console.log("[APP ROUTES]"); const credentials = getConnectionCredentialsFromEnv(); const app = createApp({ connection: credentials }); diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index cc1b2ae..9f63382 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -5,3 +5,4 @@ export { debug } from "./debug"; export { user } from "./user"; export { create } from "./create"; export { copyAssets } from "./copy-assets"; +export { types } from "./types"; diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 808458e..caccc6c 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,6 +1,6 @@ import path from "node:path"; import type { Config } from "@libsql/client/node"; -import { config } from "core"; +import { $console, config } from "core"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; @@ -37,7 +37,7 @@ export async function startServer( options: { port: number; open?: boolean }, ) { const port = options.port; - console.log(`Using ${server} serve`); + $console.log(`Using ${server} serve`); switch (server) { case "node": { @@ -59,7 +59,8 @@ export async function startServer( } const url = `http://localhost:${port}`; - console.info("Server listening on", url); + $console.info("Server listening on", url); + if (options.open) { await open(url); } diff --git a/app/src/cli/commands/schema.ts b/app/src/cli/commands/schema.ts index 13c1c1e..5dceee9 100644 --- a/app/src/cli/commands/schema.ts +++ b/app/src/cli/commands/schema.ts @@ -8,6 +8,7 @@ export const schema: CliCommand = (program) => { .option("--pretty", "pretty print") .action((options) => { const schema = getDefaultSchema(); + // biome-ignore lint/suspicious/noConsoleLog: console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); }); }; diff --git a/app/src/cli/commands/types/index.ts b/app/src/cli/commands/types/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/app/src/cli/commands/types/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/app/src/cli/commands/types/types.ts b/app/src/cli/commands/types/types.ts new file mode 100644 index 0000000..3d53618 --- /dev/null +++ b/app/src/cli/commands/types/types.ts @@ -0,0 +1,37 @@ +import type { CliCommand } from "cli/types"; +import { Option } from "commander"; +import { makeAppFromEnv } from "cli/commands/run"; +import { EntityTypescript } from "data/entities/EntityTypescript"; +import { writeFile } from "cli/utils/sys"; +import c from "picocolors"; + +export const types: CliCommand = (program) => { + program + .command("types") + .description("generate types") + .addOption(new Option("-o, --outfile ", "output file").default("bknd-types.d.ts")) + .addOption(new Option("--no-write", "do not write to file").default(true)) + .action(action); +}; + +async function action({ + outfile, + write, +}: { + outfile: string; + write: boolean; +}) { + const app = await makeAppFromEnv({ + server: "node", + }); + await app.build(); + + const et = new EntityTypescript(app.em); + + if (write) { + await writeFile(outfile, et.toString()); + console.info(`\nTypes written to ${c.cyan(outfile)}`); + } else { + console.info(et.toString()); + } +} diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index a726062..6c66acc 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -169,6 +169,7 @@ async function token(app: App, options: any) { } $log.info(`User found: ${c.cyan(user.email)}`); + // biome-ignore lint/suspicious/noConsoleLog: console.log( `\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`, ); diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 0d2e152..381937f 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -1,5 +1,6 @@ +import { $console } from "core"; import { execSync, exec as nodeExec } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import path from "node:path"; import url from "node:url"; @@ -48,6 +49,16 @@ export async function fileExists(filePath: string) { } } +export async function writeFile(filePath: string, content: string) { + try { + await nodeWriteFile(path.resolve(process.cwd(), filePath), content); + return true; + } catch (e) { + $console.error("Failed to write file", e); + return false; + } +} + export function exec(command: string, opts?: { silent?: boolean; env?: Record }) { const stdio = opts?.silent ? "pipe" : "inherit"; const output = execSync(command, { diff --git a/app/src/core/clients/aws/AwsClient.ts b/app/src/core/clients/aws/AwsClient.ts index 838367f..b5c33cf 100644 --- a/app/src/core/clients/aws/AwsClient.ts +++ b/app/src/core/clients/aws/AwsClient.ts @@ -29,7 +29,6 @@ export class AwsClient extends Aws4fetchClient { } getUrl(path: string = "/", searchParamsObj: Record = {}): string { - //console.log("super:getUrl", path, searchParamsObj); const url = new URL(path); const converted = this.convertParams(searchParamsObj); Object.entries(converted).forEach(([key, value]) => { @@ -76,8 +75,6 @@ export class AwsClient extends Aws4fetchClient { } const raw = await response.text(); - //console.log("raw", raw); - //console.log(JSON.stringify(xmlToObject(raw), null, 2)); return xmlToObject(raw) as T; } diff --git a/app/src/core/config.ts b/app/src/core/config.ts index f48385f..189b6c9 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -3,7 +3,11 @@ */ import type { Generated } from "kysely"; -export type PrimaryFieldType = number | Generated; +export type PrimaryFieldType = IdType | Generated; + +export interface AppEntity { + id: PrimaryFieldType; +} export interface DB { // make sure to make unknown as "any" diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index 9173b4b..4b64c34 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -1,9 +1,12 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { HttpStatus } from "./utils/reqres"; + export class Exception extends Error { - code = 400; + code: ContentfulStatusCode = HttpStatus.BAD_REQUEST; override name = "Exception"; protected _context = undefined; - constructor(message: string, code?: number) { + constructor(message: string, code?: ContentfulStatusCode) { super(message); if (code) { this.code = code; diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts index 9defb4c..009556b 100644 --- a/app/src/core/events/Event.ts +++ b/app/src/core/events/Event.ts @@ -14,6 +14,10 @@ export abstract class Event { params: Params; returned: boolean = false; + /** + * Shallow validation of the event return + * It'll be deeply validated on the place where it is called + */ validate(value: Returning): Event | void { throw new EventReturnedWithoutValidation(this as any, value); } diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 1c20e58..59efc8f 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,5 +1,6 @@ import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; +import { $console } from "core"; export type RegisterListenerConfig = | ListenerMode @@ -83,10 +84,6 @@ export class EventManager< } else { // @ts-expect-error slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug; - /*eventOrSlug instanceof Event - ? // @ts-expect-error slug is static - eventOrSlug.constructor.slug - : eventOrSlug.slug;*/ } return !!this.events.find((e) => slug === e.slug); @@ -128,8 +125,7 @@ export class EventManager< if (listener.id) { const existing = this.listeners.find((l) => l.id === listener.id); if (existing) { - // @todo: add a verbose option? - //console.warn(`Listener with id "${listener.id}" already exists.`); + $console.debug(`Listener with id "${listener.id}" already exists.`); return this; } } @@ -191,7 +187,7 @@ export class EventManager< // @ts-expect-error slug is static const slug = event.constructor.slug; if (!this.enabled) { - console.log("EventManager disabled, not emitting", slug); + $console.debug("EventManager disabled, not emitting", slug); return event; } @@ -240,7 +236,7 @@ export class EventManager< } catch (e) { if (e instanceof InvalidEventReturn) { this.options?.onInvalidReturn?.(_event, e); - console.warn(`Invalid return of event listener for "${slug}": ${e.message}`); + $console.warn(`Invalid return of event listener for "${slug}": ${e.message}`); } else if (this.options?.onError) { this.options.onError(_event, e); } else { diff --git a/app/src/core/index.ts b/app/src/core/index.ts index 5c63a4b..a0f572d 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono"; export { tbValidator } from "./server/lib/tbValidator"; export { Exception, BkndError } from "./errors"; export { isDebug, env } from "./env"; -export { type PrimaryFieldType, config, type DB } from "./config"; +export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config"; export { AwsClient } from "./clients/aws/AwsClient"; export { SimpleRenderer, @@ -25,8 +25,10 @@ export { isBooleanLike, } from "./object/query/query"; export { Registry, type Constructor } from "./registry/Registry"; +export { getFlashMessage } from "./server/flash"; export * from "./console"; +export * from "./events"; // compatibility export type Middleware = MiddlewareHandler; diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index 8151c8a..2119978 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -73,6 +73,7 @@ export class SchemaObject { forceParse: true, skipMark: this.isForceParse(), }); + // regardless of "noEmit" – this should always be triggered const updatedConfig = await this.onBeforeUpdate(this._config, valid); @@ -122,19 +123,15 @@ export class SchemaObject { const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); - //console.log(getFullPathKeys(value).map((k) => path + "." + k)); // overwrite arrays and primitives, only deep merge objects // @ts-ignore - //console.log("---alt:new", _jsonp(mergeObject(current, partial))); const config = mergeObjectWith(current, partial, (objValue, srcValue) => { if (Array.isArray(objValue) && Array.isArray(srcValue)) { return srcValue; } }); - //console.log("---new", _jsonp(config)); - //console.log("overwritePaths", this.options?.overwritePaths); if (this.options?.overwritePaths) { const keys = getFullPathKeys(value).map((k) => { // only prepend path if given @@ -149,7 +146,6 @@ export class SchemaObject { } }); }); - //console.log("overwritePaths", keys, overwritePaths); if (overwritePaths.length > 0) { // filter out less specific paths (but only if more than 1) @@ -157,12 +153,10 @@ export class SchemaObject { overwritePaths.length > 1 ? overwritePaths.filter((k) => overwritePaths.some((k2) => { - //console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); return k2 !== k && k2.startsWith(k); }), ) : overwritePaths; - //console.log("specific", specific); for (const p of specific) { set(config, p, get(partial, p)); @@ -170,8 +164,6 @@ export class SchemaObject { } } - //console.log("patch", _jsonp({ path, value, partial, config, current })); - const newConfig = await this.set(config); return [partial, newConfig]; } @@ -181,14 +173,11 @@ export class SchemaObject { const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); - //console.log(getFullPathKeys(value).map((k) => path + "." + k)); // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); - //console.log("overwrite", { path, value, partial, config, current }); - const newConfig = await this.set(config); return [partial, newConfig]; } @@ -198,7 +187,6 @@ export class SchemaObject { if (p.length > 1) { const parent = p.slice(0, -1).join("."); if (!has(this._config, parent)) { - //console.log("parent", parent, JSON.stringify(this._config, null, 2)); throw new Error(`Parent path "${parent}" does not exist`); } } diff --git a/app/src/core/template/SimpleRenderer.spec.ts b/app/src/core/template/SimpleRenderer.spec.ts new file mode 100644 index 0000000..6f922f3 --- /dev/null +++ b/app/src/core/template/SimpleRenderer.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { SimpleRenderer } from "core"; + +describe(SimpleRenderer, () => { + const renderer = new SimpleRenderer( + { + name: "World", + views: 123, + nested: { + foo: "bar", + baz: ["quz", "foo"], + }, + someArray: [1, 2, 3], + enabled: true, + }, + { + renderKeys: true, + }, + ); + + test("strings", async () => { + const tests = [ + ["Hello {{ name }}, count: {{views}}", "Hello World, count: 123"], + ["Nested: {{nested.foo}}", "Nested: bar"], + ["Nested: {{nested.baz[0]}}", "Nested: quz"], + ] as const; + + for (const [template, expected] of tests) { + expect(await renderer.renderString(template)).toEqual(expected); + } + }); + + test("arrays", async () => { + const tests = [ + [ + ["{{someArray[0]}}", "{{someArray[1]}}", "{{someArray[2]}}"], + ["1", "2", "3"], + ], + ] as const; + + for (const [template, expected] of tests) { + const result = await renderer.render(template); + expect(result).toEqual(expected as any); + } + }); + + test("objects", async () => { + const tests = [ + [ + { + foo: "{{name}}", + bar: "{{views}}", + baz: "{{nested.foo}}", + quz: "{{nested.baz[0]}}", + }, + { + foo: "World", + bar: "123", + baz: "bar", + quz: "quz", + }, + ], + ] as const; + + for (const [template, expected] of tests) { + const result = await renderer.render(template); + expect(result).toEqual(expected as any); + } + }); +}); diff --git a/app/src/core/template/SimpleRenderer.ts b/app/src/core/template/SimpleRenderer.ts index 1a16d1d..b3bf6aa 100644 --- a/app/src/core/template/SimpleRenderer.ts +++ b/app/src/core/template/SimpleRenderer.ts @@ -1,17 +1,13 @@ -import { Liquid, LiquidError } from "liquidjs"; -import type { RenderOptions } from "liquidjs/dist/liquid-options"; -import { BkndError } from "../errors"; +import { get } from "lodash-es"; export type TemplateObject = Record>; -export type TemplateTypes = string | TemplateObject; +export type TemplateTypes = string | TemplateObject | any; -export type SimpleRendererOptions = RenderOptions & { +export type SimpleRendererOptions = { renderKeys?: boolean; }; export class SimpleRenderer { - private engine = new Liquid(); - constructor( private variables: Record = {}, private options: SimpleRendererOptions = {}, @@ -22,7 +18,6 @@ export class SimpleRenderer { } static hasMarkup(template: string | object): boolean { - //console.log("has markup?", template); let flat: string = ""; if (Array.isArray(template) || typeof template === "object") { @@ -34,49 +29,29 @@ export class SimpleRenderer { flat = String(template); } - //console.log("** flat", flat); - - const checks = ["{{", "{%", "{#", "{:"]; - const hasMarkup = checks.some((check) => flat.includes(check)); - //console.log("--has markup?", hasMarkup); - return hasMarkup; + const checks = ["{{"]; + return checks.some((check) => flat.includes(check)); } - async render(template: Given): Promise { - try { - if (typeof template === "string") { - return (await this.renderString(template)) as unknown as Given; - } else if (Array.isArray(template)) { - return (await Promise.all( - template.map((item) => this.render(item)), - )) as unknown as Given; - } else if (typeof template === "object") { - return (await this.renderObject(template)) as unknown as Given; - } - } catch (e) { - if (e instanceof LiquidError) { - const details = { - name: e.name, - token: { - kind: e.token.kind, - input: e.token.input, - begin: e.token.begin, - end: e.token.end, - }, - }; + async render(template: Given): Promise { + if (typeof template === "undefined" || template === null) return template; - throw new BkndError(e.message, details, "liquid"); - } - - throw e; + if (typeof template === "string") { + return (await this.renderString(template)) as unknown as Given; + } else if (Array.isArray(template)) { + return (await Promise.all(template.map((item) => this.render(item)))) as unknown as Given; + } else if (typeof template === "object") { + return (await this.renderObject(template as any)) as unknown as Given; } throw new Error("Invalid template type"); } async renderString(template: string): Promise { - //console.log("*** renderString", template, this.variables); - return this.engine.parseAndRender(template, this.variables, this.options); + return template.replace(/{{\s*([^{}]+?)\s*}}/g, (_, expr: string) => { + const value = get(this.variables, expr.trim()); + return value == null ? "" : String(value); + }); } async renderObject(template: TemplateObject): Promise { diff --git a/app/src/core/utils/DebugLogger.ts b/app/src/core/utils/DebugLogger.ts index 23a1b44..febab17 100644 --- a/app/src/core/utils/DebugLogger.ts +++ b/app/src/core/utils/DebugLogger.ts @@ -9,13 +9,11 @@ export class DebugLogger { } context(context: string) { - //console.log("[ settings context ]", context, this._context); this._context.push(context); return this; } clear() { - //console.log("[ clear context ]", this._context.pop(), this._context); this._context.pop(); return this; } @@ -33,6 +31,8 @@ export class DebugLogger { const indents = " ".repeat(Math.max(this._context.length - 1, 0)); const context = this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; + + // biome-ignore lint/suspicious/noConsoleLog: console.log(indents, context, time, ...args); this.last = now; diff --git a/app/src/core/utils/dates.ts b/app/src/core/utils/dates.ts index 4628004..1d37cb3 100644 --- a/app/src/core/utils/dates.ts +++ b/app/src/core/utils/dates.ts @@ -4,7 +4,6 @@ import weekOfYear from "dayjs/plugin/weekOfYear.js"; declare module "dayjs" { interface Dayjs { week(): number; - week(value: number): dayjs.Dayjs; } } diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index 96970a7..c3db78f 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -2,6 +2,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; import { randomString } from "core/utils/strings"; import type { Context } from "hono"; import { invariant } from "core/utils/runtime"; +import { $console } from "../console"; export function getContentName(request: Request): string | undefined; export function getContentName(contentDisposition: string): string | undefined; @@ -130,7 +131,7 @@ export async function getFileFromContext(c: Context): Promise { return await blobToFile(v); } } catch (e) { - console.warn("Error parsing form data", e); + $console.warn("Error parsing form data", e); } } else { try { @@ -141,7 +142,7 @@ export async function getFileFromContext(c: Context): Promise { return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); } } catch (e) { - console.warn("Error parsing blob", e); + $console.warn("Error parsing blob", e); } } diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index e3a0135..a8469c7 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -359,3 +359,50 @@ export function getPath( throw new Error(`Invalid path: ${path.join(".")}`); } } + +export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string { + const nl = indent ? "\n" : ""; + const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : ""); + const openPad = pad(_level + 1); + const closePad = pad(_level); + + // primitives + if (value === null) return "null"; + if (value === undefined) return "undefined"; + const t = typeof value; + if (t === "string") return JSON.stringify(value); // handles escapes + if (t === "number" || t === "boolean") return String(value); + + // arrays + if (Array.isArray(value)) { + const out = value + .map((v) => objectToJsLiteral(v, indent, _level + 1)) + .join(", " + (indent ? nl + openPad : "")); + return ( + "[" + + (indent && value.length ? nl + openPad : "") + + out + + (indent && value.length ? nl + closePad : "") + + "]" + ); + } + + // objects + if (t === "object") { + const entries = Object.entries(value).map(([k, v]) => { + const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier? + const key = idOk ? k : JSON.stringify(k); // quote if needed + return key + ": " + objectToJsLiteral(v, indent, _level + 1); + }); + const out = entries.join(", " + (indent ? nl + openPad : "")); + return ( + "{" + + (indent && entries.length ? nl + openPad : "") + + out + + (indent && entries.length ? nl + closePad : "") + + "}" + ); + } + + throw new TypeError(`Unsupported data type: ${t}`); +} diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 3e9ec12..0ba600f 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -1,7 +1,3 @@ -import { randomString } from "core/utils/strings"; -import type { Context } from "hono"; -import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; - export function headersToObject(headers: Headers): Record { if (!headers) return {}; return { ...Object.fromEntries(headers.entries()) }; @@ -102,7 +98,7 @@ export function decodeSearch(str) { export const enum HttpStatus { // Informational responses (100–199) CONTINUE = 100, - SWITCHING_PROTOCOLS = 101, + //SWITCHING_PROTOCOLS = 101, PROCESSING = 102, EARLY_HINTS = 103, @@ -111,8 +107,8 @@ export const enum HttpStatus { CREATED = 201, ACCEPTED = 202, NON_AUTHORITATIVE_INFORMATION = 203, - NO_CONTENT = 204, - RESET_CONTENT = 205, + //NO_CONTENT = 204, + //RESET_CONTENT = 205, PARTIAL_CONTENT = 206, MULTI_STATUS = 207, ALREADY_REPORTED = 208, @@ -123,7 +119,7 @@ export const enum HttpStatus { MOVED_PERMANENTLY = 301, FOUND = 302, SEE_OTHER = 303, - NOT_MODIFIED = 304, + //NOT_MODIFIED = 304, USE_PROXY = 305, TEMPORARY_REDIRECT = 307, PERMANENT_REDIRECT = 308, @@ -172,3 +168,13 @@ export const enum HttpStatus { NOT_EXTENDED = 510, NETWORK_AUTHENTICATION_REQUIRED = 511, } +// biome-ignore lint/suspicious/noConstEnum: +export const enum HttpStatusEmpty { + // Informational responses (100–199) + SWITCHING_PROTOCOLS = 101, + // Successful responses (200–299) + NO_CONTENT = 204, + RESET_CONTENT = 205, + // Redirection messages (300–399) + NOT_MODIFIED = 304, +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index be115f5..c052331 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -132,3 +132,8 @@ export function slugify(str: string): string { .replace(/-+/g, "-") // remove consecutive hyphens ); } + +export function truncate(str: string, length = 50, end = "..."): string { + if (str.length <= length) return str; + return str.substring(0, length) + end; +} diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index ee44a5b..1267afc 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -1,18 +1,11 @@ -import { - Kind, - type ObjectOptions, - type SchemaOptions, - type Static, - type StaticDecode, - type StringOptions, - type TLiteral, - type TLiteralValue, - type TObject, - type TRecord, - type TSchema, - type TString, - Type, +import * as tb from "@sinclair/typebox"; +import type { TypeRegistry, + Static, + StaticDecode, + TSchema, + SchemaOptions, + TObject, } from "@sinclair/typebox"; import { DefaultErrorFunction, @@ -43,7 +36,7 @@ const validationSymbol = Symbol("tb-parse-validation"); export class TypeInvalidError extends Error { errors: ValueError[]; constructor( - public schema: TSchema, + public schema: tb.TSchema, public data: unknown, message?: string, ) { @@ -92,29 +85,28 @@ export function mark(obj: any, validated = true) { } } -export function parse( +export function parse( schema: Schema, - data: RecursivePartial>, + data: RecursivePartial>, options?: ParseOptions, -): Static { +): tb.Static { if (!options?.forceParse && typeof data === "object" && validationSymbol in data) { if (options?.useDefaults === false) { - return data as Static; + return data as tb.Static; } // this is important as defaults are expected - return Default(schema, data as any) as Static; + return Default(schema, data as any) as tb.Static; } const parsed = options?.useDefaults === false ? data : Default(schema, data); if (Check(schema, parsed)) { options?.skipMark !== true && mark(parsed, true); - return parsed as Static; + return parsed as tb.Static; } else if (options?.onError) { options.onError(Errors(schema, data)); } else { - //console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2)); throw new TypeInvalidError(schema, data); } @@ -122,26 +114,24 @@ export function parse( return undefined as any; } -export function parseDecode( +export function parseDecode( schema: Schema, - data: RecursivePartial>, -): StaticDecode { - //console.log("parseDecode", schema, data); + data: RecursivePartial>, +): tb.StaticDecode { const parsed = Default(schema, data); if (Check(schema, parsed)) { - return parsed as StaticDecode; + return parsed as tb.StaticDecode; } - //console.log("errors", ...Errors(schema, data)); throw new TypeInvalidError(schema, data); } -export function strictParse( +export function strictParse( schema: Schema, - data: Static, + data: tb.Static, options?: ParseOptions, -): Static { +): tb.Static { return parse(schema, data as any, options); } @@ -150,11 +140,14 @@ export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) { return typeof value === "string" && schema.enum.includes(value); }); } -registerCustomTypeboxKinds(TypeRegistry); +registerCustomTypeboxKinds(tb.TypeRegistry); -export const StringEnum = (values: T, options?: StringOptions) => - Type.Unsafe({ - [Kind]: "StringEnum", +export const StringEnum = ( + values: T, + options?: tb.StringOptions, +) => + tb.Type.Unsafe({ + [tb.Kind]: "StringEnum", type: "string", enum: values, ...options, @@ -162,45 +155,47 @@ export const StringEnum = (values: T, options // key value record compatible with RJSF and typebox inference // acting like a Record, but using an Object with additionalProperties -export const StringRecord = (properties: T, options?: ObjectOptions) => - Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord< - TString, +export const StringRecord = (properties: T, options?: tb.ObjectOptions) => + tb.Type.Object({}, { ...options, additionalProperties: properties }) as unknown as tb.TRecord< + tb.TString, typeof properties >; // fixed value that only be what is given + prefilled -export const Const = (value: T, options?: SchemaOptions) => - Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral; +export const Const = ( + value: T, + options?: tb.SchemaOptions, +) => + tb.Type.Literal(value, { + ...options, + default: value, + const: value, + readOnly: true, + }) as tb.TLiteral; -export const StringIdentifier = Type.String({ +export const StringIdentifier = tb.Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$", minLength: 2, maxLength: 150, }); +export const StrictObject = ( + properties: T, + options?: tb.ObjectOptions, +): tb.TObject => tb.Type.Object(properties, { ...options, additionalProperties: false }); + SetErrorFunction((error) => { if (error?.schema?.errorMessage) { return error.schema.errorMessage; } - if (error?.schema?.[Kind] === "StringEnum") { + if (error?.schema?.[tb.Kind] === "StringEnum") { return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`; } return DefaultErrorFunction(error); }); -export { - Type, - type Static, - type StaticDecode, - type TSchema, - Kind, - type TObject, - type ValueError, - type SchemaOptions, - Value, - Default, - Errors, - Check, -}; +export type { Static, StaticDecode, TSchema, TObject, ValueError, SchemaOptions }; + +export { Value, Default, Errors, Check }; diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index 0819b08..6103220 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -1,5 +1,6 @@ import type { DB } from "core"; import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; +import type { Insertable, Selectable, Updateable } from "kysely"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; @@ -25,21 +26,23 @@ export class DataApi extends ModuleApi { } } - readOne( + readOne( entity: E, id: PrimaryFieldType, query: Omit = {}, ) { + type Data = E extends keyof DB ? Selectable : EntityData; return this.get, "meta" | "data">>( ["entity", entity as any, id], query, ); } - readOneBy( + readOneBy( entity: E, query: Omit = {}, ) { + type Data = E extends keyof DB ? Selectable : EntityData; type T = Pick, "meta" | "data">; return this.readMany(entity, { ...query, @@ -48,10 +51,8 @@ export class DataApi extends ModuleApi { }).refine((data) => data[0]) as unknown as FetchPromise>; } - readMany( - entity: E, - query: RepoQueryIn = {}, - ) { + readMany(entity: E, query: RepoQueryIn = {}) { + type Data = E extends keyof DB ? Selectable : EntityData; type T = Pick, "meta" | "data">; const input = query ?? this.options.defaultQuery; @@ -64,68 +65,70 @@ export class DataApi extends ModuleApi { return this.post(["entity", entity as any, "query"], input); } - readManyByReference< - E extends keyof DB | string, - R extends keyof DB | string, - Data = R extends keyof DB ? DB[R] : EntityData, - >(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) { + readManyByReference( + entity: E, + id: PrimaryFieldType, + reference: R, + query: RepoQueryIn = {}, + ) { + type Data = R extends keyof DB ? Selectable : EntityData; return this.get, "meta" | "data">>( ["entity", entity as any, id, reference], query ?? this.options.defaultQuery, ); } - createOne( + createOne( entity: E, - input: Omit, + input: Insertable, ) { + type Data = E extends keyof DB ? Selectable : EntityData; return this.post>(["entity", entity as any], input); } - createMany( + createMany( entity: E, - input: Omit[], + input: Insertable[], ) { if (!input || !Array.isArray(input) || input.length === 0) { throw new Error("input is required"); } + type Data = E extends keyof DB ? Selectable : EntityData; return this.post>(["entity", entity as any], input); } - updateOne( + updateOne( entity: E, id: PrimaryFieldType, - input: Partial>, + input: Updateable, ) { if (!id) throw new Error("ID is required"); + type Data = E extends keyof DB ? Selectable : EntityData; return this.patch>(["entity", entity as any, id], input); } - updateMany( + updateMany( entity: E, where: RepoQueryIn["where"], - update: Partial>, + update: Updateable, ) { this.requireObjectSet(where); + type Data = E extends keyof DB ? Selectable : EntityData; return this.patch>(["entity", entity as any], { update, where, }); } - deleteOne( - entity: E, - id: PrimaryFieldType, - ) { + deleteOne(entity: E, id: PrimaryFieldType) { if (!id) throw new Error("ID is required"); + type Data = E extends keyof DB ? Selectable : EntityData; return this.delete>(["entity", entity as any, id]); } - deleteMany( - entity: E, - where: RepoQueryIn["where"], - ) { + deleteMany(entity: E, where: RepoQueryIn["where"]) { this.requireObjectSet(where); + type Data = E extends keyof DB ? Selectable : EntityData; return this.delete>(["entity", entity as any], where); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 5aae943..333b9a3 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,5 +1,6 @@ -import { isDebug, tbValidator as tb } from "core"; -import { StringEnum, Type } from "core/utils"; +import { $console, isDebug, tbValidator as tb } from "core"; +import { StringEnum } from "core/utils"; +import * as tbbox from "@sinclair/typebox"; import { DataPermissions, type EntityData, @@ -14,6 +15,7 @@ import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; +const { Type } = tbbox; export class DataController extends Controller { constructor( @@ -45,7 +47,6 @@ export class DataController extends Controller { const template = { data: res.data, meta }; // @todo: this works but it breaks in FE (need to improve DataTable) - //return objectCleanEmpty(template) as any; // filter empty return Object.fromEntries( Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null), @@ -56,7 +57,6 @@ export class DataController extends Controller { const template = { data: res.data }; // filter empty - //return objectCleanEmpty(template); return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined)); } @@ -72,11 +72,6 @@ export class DataController extends Controller { const { permission, auth } = this.middlewares; const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); - const definedEntities = this.em.entities.map((e) => e.name); - const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) - .Decode(Number.parseInt) - .Encode(String); - // @todo: sample implementation how to augment handler with additional info function handler(name: string, h: HH): any { const func = h; @@ -141,10 +136,8 @@ export class DataController extends Controller { }), ), async (c) => { - //console.log("request", c.req.raw); const { entity, context } = c.req.param(); if (!this.entityExists(entity)) { - console.warn("not found:", entity, definedEntities); return this.notFound(c); } const _entity = this.em.entity(entity); @@ -254,7 +247,6 @@ export class DataController extends Controller { async (c) => { const { entity } = c.req.param(); if (!this.entityExists(entity)) { - console.warn("not found:", entity, definedEntities); return this.notFound(c); } const options = c.req.valid("query") as RepoQuery; @@ -328,7 +320,6 @@ export class DataController extends Controller { return this.notFound(c); } const options = (await c.req.valid("json")) as RepoQuery; - //console.log("options", options); const result = await this.em.repository(entity).findMany(options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); diff --git a/app/src/data/connection/sqlite/LibsqlConnection.ts b/app/src/data/connection/sqlite/LibsqlConnection.ts index 7518357..c8b4441 100644 --- a/app/src/data/connection/sqlite/LibsqlConnection.ts +++ b/app/src/data/connection/sqlite/LibsqlConnection.ts @@ -38,7 +38,7 @@ export class LibsqlConnection extends SqliteConnection { if (clientOrCredentials && "url" in clientOrCredentials) { let { url, authToken, protocol } = clientOrCredentials; if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) { - console.log("changing protocol to", protocol); + $console.log("changing protocol to", protocol); const [, rest] = url.split("://"); url = `${protocol}://${rest}`; } diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index cec6b61..38f272f 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -1,4 +1,5 @@ -import { type Static, StringRecord, Type, objectTransform } from "core/utils"; +import { type Static, StringRecord, objectTransform } from "core/utils"; +import * as tb from "@sinclair/typebox"; import { FieldClassMap, RelationClassMap, @@ -18,36 +19,37 @@ export type FieldType = keyof typeof FIELDS; export const RELATIONS = RelationClassMap; export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => { - return Type.Object( + return tb.Type.Object( { - type: Type.Const(name, { default: name, readOnly: true }), - config: Type.Optional(field.schema), + type: tb.Type.Const(name, { default: name, readOnly: true }), + config: tb.Type.Optional(field.schema), }, { title: name, }, ); }); -export const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject)); +export const fieldsSchema = tb.Type.Union(Object.values(fieldsSchemaObject)); export const entityFields = StringRecord(fieldsSchema); export type TAppDataField = Static; export type TAppDataEntityFields = Static; -export const entitiesSchema = Type.Object({ - //name: Type.String(), - type: Type.Optional(Type.String({ enum: entityTypes, default: "regular", readOnly: true })), - config: Type.Optional(entityConfigSchema), - fields: Type.Optional(entityFields), +export const entitiesSchema = tb.Type.Object({ + type: tb.Type.Optional( + tb.Type.String({ enum: entityTypes, default: "regular", readOnly: true }), + ), + config: tb.Type.Optional(entityConfigSchema), + fields: tb.Type.Optional(entityFields), }); export type TAppDataEntity = Static; export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => { - return Type.Object( + return tb.Type.Object( { - type: Type.Const(name, { default: name, readOnly: true }), - source: Type.String(), - target: Type.String(), - config: Type.Optional(relationClass.schema), + type: tb.Type.Const(name, { default: name, readOnly: true }), + source: tb.Type.String(), + target: tb.Type.String(), + config: tb.Type.Optional(relationClass.schema), }, { title: name, @@ -56,24 +58,23 @@ export const relationsSchema = Object.entries(RelationClassMap).map(([name, rela }); export type TAppDataRelation = Static<(typeof relationsSchema)[number]>; -export const indicesSchema = Type.Object( +export const indicesSchema = tb.Type.Object( { - entity: Type.String(), - fields: Type.Array(Type.String(), { minItems: 1 }), - //name: Type.Optional(Type.String()), - unique: Type.Optional(Type.Boolean({ default: false })), + entity: tb.Type.String(), + fields: tb.Type.Array(tb.Type.String(), { minItems: 1 }), + unique: tb.Type.Optional(tb.Type.Boolean({ default: false })), }, { additionalProperties: false, }, ); -export const dataConfigSchema = Type.Object( +export const dataConfigSchema = tb.Type.Object( { - basepath: Type.Optional(Type.String({ default: "/api/data" })), - entities: Type.Optional(StringRecord(entitiesSchema, { default: {} })), - relations: Type.Optional(StringRecord(Type.Union(relationsSchema), { default: {} })), - indices: Type.Optional(StringRecord(indicesSchema, { default: {} })), + basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })), + entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })), + relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })), + indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })), }, { additionalProperties: false, diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 5706832..b097557 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -1,13 +1,14 @@ -import { config } from "core"; +import { $console, config } from "core"; import { type Static, StringEnum, - Type, parse, snakeToPascalWithSpaces, transformObject, } from "core/utils"; import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; // @todo: entity must be migrated to typebox export const entityConfigSchema = Type.Object( @@ -183,9 +184,9 @@ export class Entity< if (existing) { // @todo: for now adding a graceful method if (JSON.stringify(existing) === JSON.stringify(field)) { - /*console.warn( + $console.warn( `Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`, - );*/ + ); return; } @@ -231,8 +232,14 @@ export class Entity< } for (const field of fields) { - if (!field.isValid(data[field.name], context)) { - console.log("Entity.isValidData:invalid", context, field.name, data[field.name]); + if (!field.isValid(data?.[field.name], context)) { + $console.warn( + "invalid data given for", + this.name, + context, + field.name, + data[field.name], + ); if (options?.explain) { throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`); } @@ -258,7 +265,6 @@ export class Entity< const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); const schema = Type.Object( transformObject(_fields, (field) => { - //const hidden = field.isHidden(options?.context); const fillable = field.isFillable(options?.context); return { title: field.config.label, @@ -274,11 +280,18 @@ export class Entity< return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema; } + toTypes() { + return { + name: this.name, + type: this.type, + comment: this.config.description, + fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])), + }; + } + toJSON() { return { - //name: this.name, type: this.type, - //fields: transformObject(this.fields, (field) => field.toJSON()), fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])), config: this.config, }; diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 8d36cf3..e4e71c2 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -1,4 +1,4 @@ -import type { DB as DefaultDB } from "core"; +import { $console, type DB as DefaultDB } from "core"; import { EventManager } from "core/events"; import { sql } from "kysely"; import { Connection } from "../connection/Connection"; @@ -55,7 +55,6 @@ export class EntityManager { this.connection = connection; this.emgr = emgr ?? new EventManager(); - //console.log("registering events", EntityManager.Events); this.emgr.registerEvents(EntityManager.Events); } @@ -90,7 +89,9 @@ export class EntityManager { if (existing) { // @todo: for now adding a graceful method if (JSON.stringify(existing) === JSON.stringify(entity)) { - //console.warn(`Entity "${entity.name}" already exists, but it's the same, so skipping.`); + $console.warn( + `Entity "${entity.name}" already exists, but it's the same, skipping adding it.`, + ); return; } @@ -108,7 +109,6 @@ export class EntityManager { } this._entities[entityIndex] = entity; - // caused issues because this.entity() was using a reference (for when initial config was given) } @@ -295,7 +295,6 @@ export class EntityManager { return { entities: Object.fromEntries(this.entities.map((e) => [e.name, e.toJSON()])), relations: Object.fromEntries(this.relations.all.map((r) => [r.getName(), r.toJSON()])), - //relations: this.relations.all.map((r) => r.toJSON()), indices: Object.fromEntries(this.indices.map((i) => [i.name, i.toJSON()])), }; } diff --git a/app/src/data/entities/EntityTypescript.ts b/app/src/data/entities/EntityTypescript.ts new file mode 100644 index 0000000..26c1df9 --- /dev/null +++ b/app/src/data/entities/EntityTypescript.ts @@ -0,0 +1,228 @@ +import type { Entity, EntityManager, EntityRelation, TEntityType } from "data"; +import { autoFormatString } from "core/utils"; +import { AppAuth, AppMedia } from "modules"; + +export type TEntityTSType = { + name: string; + type: TEntityType; + comment?: string; + fields: Record; +}; + +// [select, insert, update] +type TFieldContextType = boolean | [boolean, boolean, boolean]; + +export type TFieldTSType = { + required?: TFieldContextType; + fillable?: TFieldContextType; + type: "PrimaryFieldType" | string; + comment?: string; + import?: { + package: string; + name: string; + }[]; +}; + +export type EntityTypescriptOptions = { + indentWidth?: number; + indentChar?: string; + entityCommentMultiline?: boolean; + fieldCommentMultiline?: boolean; +}; + +// keep a local copy here until properties have a type +const systemEntities = { + users: AppAuth.usersFields, + media: AppMedia.mediaFields, +}; + +export class EntityTypescript { + constructor( + protected em: EntityManager, + protected _options: EntityTypescriptOptions = {}, + ) {} + + get options() { + return { + ...this._options, + indentWidth: 2, + indentChar: " ", + entityCommentMultiline: true, + fieldCommentMultiline: false, + }; + } + + toTypes() { + return this.em.entities.map((e) => e.toTypes()); + } + + protected getTab(count = 1) { + return this.options.indentChar.repeat(this.options.indentWidth).repeat(count); + } + + collectImports( + type: TEntityTSType, + imports: Record = {}, + ): Record { + for (const [, entity_type] of Object.entries(type.fields)) { + for (const imp of entity_type.import ?? []) { + const name = imp.name; + const pkg = imp.package; + if (!imports[pkg]) { + imports[pkg] = []; + } + if (!imports[pkg].includes(name)) { + imports[pkg].push(name); + } + } + } + return imports; + } + + typeName(name: string) { + return autoFormatString(name); + } + + fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) { + let string = ""; + const coment_multiline = this.options.fieldCommentMultiline; + const indent = opts?.indent ?? 1; + for (const [field_name, field_type] of Object.entries(type.fields)) { + if (opts?.ignore_fields?.includes(field_name)) continue; + + let f = ""; + f += this.commentString(field_type.comment, indent, coment_multiline); + f += `${this.getTab(indent)}${field_name}${field_type.required ? "" : "?"}: `; + f += field_type.type + ";"; + f += "\n"; + string += f; + } + + return string; + } + + relationToFieldType(relation: EntityRelation, entity: Entity) { + const other = relation.other(entity); + const listable = relation.isListableFor(entity); + const name = this.typeName(other.entity.name); + + let type = name; + if (other.entity.type === "system") { + type = `DB["${other.entity.name}"]`; + } + + return { + fields: { + [other.reference]: { + required: false, + type: `${type}${listable ? "[]" : ""}`, + }, + }, + }; + } + + importsToString(imports: Record) { + const strings: string[] = []; + for (const [pkg, names] of Object.entries(imports)) { + strings.push(`import type { ${names.join(", ")} } from "${pkg}";`); + } + return strings; + } + + commentString(comment?: string, indents = 0, multiline = true) { + if (!comment) return ""; + const indent = this.getTab(indents); + if (!multiline) return `${indent}// ${comment}\n`; + return `${indent}/**\n${indent} * ${comment}\n${indent} */\n`; + } + + entityToTypeString( + entity: Entity, + opts?: { ignore_fields?: string[]; indent?: number; export?: boolean }, + ) { + const type = entity.toTypes(); + const name = this.typeName(type.name); + const indent = opts?.indent ?? 1; + const min_indent = Math.max(0, indent - 1); + + let s = this.commentString(type.comment, min_indent, this.options.entityCommentMultiline); + s += `${opts?.export ? "export " : ""}interface ${name} {\n`; + s += this.fieldTypesToString(type, opts); + + // add listable relations + const relations = this.em.relations.relationsOf(entity); + const rel_types = relations.map((r) => + this.relationToFieldType(r, entity), + ) as TEntityTSType[]; + for (const rel_type of rel_types) { + s += this.fieldTypesToString(rel_type, { + indent, + }); + } + s += `${this.getTab(min_indent)}}`; + + return s; + } + + toString() { + const strings: string[] = []; + const tables: Record = {}; + const imports: Record = { + "bknd/core": ["DB"], + kysely: ["Insertable", "Selectable", "Updateable", "Generated"], + }; + + // add global types + let g = "declare global {\n"; + g += `${this.getTab(1)}type BkndEntity = Selectable;\n`; + g += `${this.getTab(1)}type BkndEntityCreate = Insertable;\n`; + g += `${this.getTab(1)}type BkndEntityUpdate = Updateable;\n`; + g += "}"; + strings.push(g); + + const system_entities = this.em.entities.filter((e) => e.type === "system"); + + for (const entity of this.em.entities) { + // skip system entities, declare addtional props in the DB interface + if (system_entities.includes(entity)) continue; + + const type = entity.toTypes(); + if (!type) continue; + this.collectImports(type, imports); + tables[type.name] = this.typeName(type.name); + const s = this.entityToTypeString(entity, { + export: true, + }); + strings.push(s); + } + + // write tables + let tables_string = "interface Database {\n"; + for (const [name, type] of Object.entries(tables)) { + tables_string += `${this.getTab(1)}${name}: ${type};\n`; + } + tables_string += "}"; + strings.push(tables_string); + + // merge + let merge = `declare module "bknd/core" {\n`; + for (const systemEntity of system_entities) { + const system_fields = Object.keys(systemEntities[systemEntity.name]); + const additional_fields = systemEntity.fields + .filter((f) => !system_fields.includes(f.name) && f.type !== "primary") + .map((f) => f.name); + if (additional_fields.length === 0) continue; + + merge += `${this.getTab(1)}${this.entityToTypeString(systemEntity, { + ignore_fields: ["id", ...system_fields], + indent: 2, + })}\n\n`; + } + + merge += `${this.getTab(1)}interface DB extends Database {}\n}`; + strings.push(merge); + + const final = [this.importsToString(imports).join("\n"), strings.join("\n\n")]; + return final.join("\n\n"); + } +} diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index ce6330a..0e4dae4 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -1,4 +1,4 @@ -import type { DB as DefaultDB, PrimaryFieldType } from "core"; +import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core"; import { type EmitsEvents, EventManager } from "core/events"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import { type TActionContext, WhereBuilder } from ".."; @@ -72,7 +72,6 @@ export class Mutator< // if relation field (include key and value in validatedData) if (Array.isArray(result)) { - //console.log("--- (instructions)", result); const [relation_key, relation_value] = result; validatedData[relation_key] = relation_value; } @@ -122,7 +121,7 @@ export class Mutator< }; } catch (e) { // @todo: redact - console.log("[Error in query]", sql); + $console.error("[Error in query]", sql); throw e; } } diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index d16b7b1..1d2897a 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -302,24 +302,38 @@ export class Repository { + const event = + options.limit === 1 + ? Repository.Events.RepositoryFindOneBefore + : Repository.Events.RepositoryFindManyBefore; + await this.emgr.emit(new event({ entity, options })); + } + + private async triggerFindAfter( + entity: Entity, + options: RepoQuery, + data: EntityData[], + ): Promise { + if (options.limit === 1) { + await this.emgr.emit( + new Repository.Events.RepositoryFindOneAfter({ entity, options, data: data[0]! }), + ); + } else { + await this.emgr.emit( + new Repository.Events.RepositoryFindManyAfter({ entity, options, data }), + ); + } + } + protected async single( qb: RepositoryQB, options: RepoQuery, ): Promise> { - await this.emgr.emit( - new Repository.Events.RepositoryFindOneBefore({ entity: this.entity, options }), - ); - + await this.triggerFindBefore(this.entity, options); const { data, ...response } = await this.performQuery(qb); - await this.emgr.emit( - new Repository.Events.RepositoryFindOneAfter({ - entity: this.entity, - options, - data: data[0]!, - }), - ); - + await this.triggerFindAfter(this.entity, options, data); return { ...response, data: data[0]! }; } @@ -420,26 +434,16 @@ export class Repository): Promise> { const { qb, options } = this.buildQuery(_options); - - await this.emgr.emit( - new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options }), - ); + await this.triggerFindBefore(this.entity, options); const res = await this.performQuery(qb); - await this.emgr.emit( - new Repository.Events.RepositoryFindManyAfter({ - entity: this.entity, - options, - data: res.data, - }), - ); - + await this.triggerFindAfter(this.entity, options, res.data); return res as any; } diff --git a/app/src/data/errors.ts b/app/src/data/errors.ts index 9f159f7..12912a0 100644 --- a/app/src/data/errors.ts +++ b/app/src/data/errors.ts @@ -1,26 +1,26 @@ import { Exception } from "core"; -import type { TypeInvalidError } from "core/utils"; +import { HttpStatus, type TypeInvalidError } from "core/utils"; import type { Entity } from "./entities"; import type { Field } from "./fields"; export class UnableToConnectException extends Exception { override name = "UnableToConnectException"; - override code = 500; + override code = HttpStatus.INTERNAL_SERVER_ERROR; } export class InvalidSearchParamsException extends Exception { override name = "InvalidSearchParamsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformRetrieveFailedException extends Exception { override name = "TransformRetrieveFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformPersistFailedException extends Exception { override name = "TransformPersistFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; static invalidType(property: string, expected: string, given: any) { const givenValue = typeof given === "object" ? JSON.stringify(given) : given; @@ -37,7 +37,7 @@ export class TransformPersistFailedException extends Exception { export class InvalidFieldConfigException extends Exception { override name = "InvalidFieldConfigException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor( field: Field, @@ -54,7 +54,7 @@ export class InvalidFieldConfigException extends Exception { export class EntityNotDefinedException extends Exception { override name = "EntityNotDefinedException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor(entity?: Entity | string) { if (!entity) { @@ -67,7 +67,7 @@ export class EntityNotDefinedException extends Exception { export class EntityNotFoundException extends Exception { override name = "EntityNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor(entity: Entity | string, id: any) { super( diff --git a/app/src/data/events/index.ts b/app/src/data/events/index.ts index b9d7559..8549750 100644 --- a/app/src/data/events/index.ts +++ b/app/src/data/events/index.ts @@ -1,4 +1,4 @@ -import type { PrimaryFieldType } from "core"; +import { $console, type PrimaryFieldType } from "core"; import { Event, InvalidEventReturn } from "core/events"; import type { Entity, EntityData } from "../entities"; import type { RepoQuery } from "../server/data-query-impl"; @@ -9,6 +9,10 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat override validate(data: EntityData) { const { entity } = this.params; if (!entity.isValidData(data, "create")) { + $console.warn("MutatorInsertBefore.validate: invalid", { + entity: entity.name, + data, + }); throw new InvalidEventReturn("EntityData", "invalid"); } @@ -36,13 +40,18 @@ export class MutatorUpdateBefore extends Event< static override slug = "mutator-update-before"; override validate(data: EntityData) { - const { entity, ...rest } = this.params; + const { entity, entityId } = this.params; if (!entity.isValidData(data, "update")) { + $console.warn("MutatorUpdateBefore.validate: invalid", { + entity: entity.name, + entityId, + data, + }); throw new InvalidEventReturn("EntityData", "invalid"); } return this.clone({ - ...rest, + entityId, entity, data, }); diff --git a/app/src/data/fields/BooleanField.ts b/app/src/data/fields/BooleanField.ts index 19d2978..35dfa2d 100644 --- a/app/src/data/fields/BooleanField.ts +++ b/app/src/data/fields/BooleanField.ts @@ -1,7 +1,9 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export const booleanFieldConfigSchema = Type.Composite([ Type.Object({ @@ -47,7 +49,6 @@ export class BooleanField extends Field< } override transformRetrieve(value: unknown): boolean | null { - //console.log("Boolean:transformRetrieve:value", value); if (typeof value === "undefined" || value === null) { if (this.isRequired()) return false; if (this.hasDefault()) return this.getDefault(); @@ -87,4 +88,11 @@ export class BooleanField extends Field< override toJsonSchema() { return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() })); } + + override toType() { + return { + ...super.toType(), + type: "boolean", + }; + } } diff --git a/app/src/data/fields/DateField.ts b/app/src/data/fields/DateField.ts index c7ba901..0fdf91e 100644 --- a/app/src/data/fields/DateField.ts +++ b/app/src/data/fields/DateField.ts @@ -1,11 +1,14 @@ -import { type Static, StringEnum, Type, dayjs } from "core/utils"; +import { type Static, StringEnum, dayjs } from "core/utils"; import type { EntityManager } from "../entities"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import { $console } from "core"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const dateFieldConfigSchema = Type.Composite( [ Type.Object({ - //default_value: Type.Optional(Type.Date()), type: StringEnum(["date", "datetime", "week"] as const, { default: "date" }), timezone: Type.Optional(Type.String()), min_date: Type.Optional(Type.String()), @@ -51,13 +54,11 @@ export class DateField extends Field< } private parseDateFromString(value: string): Date { - //console.log("parseDateFromString", value); if (this.config.type === "week" && value.includes("-W")) { const [year, week] = value.split("-W").map((n) => Number.parseInt(n, 10)) as [ number, number, ]; - //console.log({ year, week }); // @ts-ignore causes errors on build? return dayjs().year(year).week(week).toDate(); } @@ -67,15 +68,12 @@ export class DateField extends Field< override getValue(value: string, context?: TRenderContext): string | undefined { if (value === null || !value) return; - //console.log("getValue", { value, context }); const date = this.parseDateFromString(value); - //console.log("getValue.date", date); if (context === "submit") { try { return date.toISOString(); } catch (e) { - //console.warn("DateField.getValue:value/submit", value, e); return undefined; } } @@ -84,7 +82,7 @@ export class DateField extends Field< try { return `${date.getFullYear()}-W${dayjs(date).week()}`; } catch (e) { - console.warn("error - DateField.getValue:week", value, e); + $console.warn("DateField.getValue:week error", value, String(e)); return; } } @@ -97,8 +95,7 @@ export class DateField extends Field< return this.formatDate(local); } catch (e) { - console.warn("DateField.getValue:value", value); - console.warn("DateField.getValue:e", e); + $console.warn("DateField.getValue error", this.config.type, value, String(e)); return; } } @@ -117,7 +114,6 @@ export class DateField extends Field< } override transformRetrieve(_value: string): Date | null { - //console.log("transformRetrieve DateField", _value); const value = super.transformRetrieve(_value); if (value === null) return null; @@ -136,7 +132,6 @@ export class DateField extends Field< const value = await super.transformPersist(_value, em, context); if (this.nullish(value)) return value; - //console.log("transformPersist DateField", value); switch (this.config.type) { case "date": case "week": @@ -150,4 +145,11 @@ export class DateField extends Field< override toJsonSchema() { return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() })); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + type: "Date | string", + }; + } } diff --git a/app/src/data/fields/EnumField.ts b/app/src/data/fields/EnumField.ts index e8e8772..2bfad08 100644 --- a/app/src/data/fields/EnumField.ts +++ b/app/src/data/fields/EnumField.ts @@ -1,7 +1,10 @@ -import { Const, type Static, StringEnum, StringRecord, Type } from "core/utils"; +import { Const, type Static, StringEnum } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; -import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const enumFieldConfigSchema = Type.Composite( [ @@ -53,10 +56,6 @@ export class EnumField) { super(name, config); - /*if (this.config.options.values.length === 0) { - throw new Error(`Enum field "${this.name}" requires at least one option`); - }*/ - if (this.config.default_value && !this.isValidValue(this.config.default_value)) { throw new Error(`Default value "${this.config.default_value}" is not a valid option`); } @@ -69,10 +68,6 @@ export class EnumField ({ label: option, value: option })); } @@ -146,4 +141,14 @@ export class EnumField + typeof value === "string" ? `"${value}"` : value, + ); + return { + ...super.toType(), + type: union.length > 0 ? union.join(" | ") : "string", + }; + } } diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index d8eba4f..5c787eb 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -4,13 +4,15 @@ import { type Static, StringEnum, type TSchema, - Type, TypeInvalidError, } from "core/utils"; import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import type { EntityManager } from "../entities"; import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors"; import type { FieldSpec } from "data/connection/Connection"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; // @todo: contexts need to be reworked // e.g. "table" is irrelevant, because if read is not given, it fails @@ -184,12 +186,14 @@ export abstract class Field< }; } + // @todo: add field level validation isValid(value: any, context: TActionContext): boolean { - if (value) { + if (typeof value !== "undefined") { return this.isFillable(context); - } else { + } else if (context === "create") { return !this.isRequired(); } + return true; } /** @@ -232,6 +236,14 @@ export abstract class Field< return this.toSchemaWrapIfRequired(Type.Any()); } + toType(): TFieldTSType { + return { + required: this.isRequired(), + comment: this.getDescription(), + type: "any", + }; + } + toJSON() { return { // @todo: current workaround because of fixed string type diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index b25df60..db1a6f6 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -1,7 +1,10 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]); @@ -82,7 +85,6 @@ export class JsonField { const value = await super.transformPersist(_value, em, context); - //console.log("value", value); if (this.nullish(value)) return value; if (!this.isSerializable(value)) { @@ -97,4 +99,11 @@ export class JsonField { const value = await super.transformPersist(_value, em, context); if (this.nullish(value)) return value; - //console.log("jsonschema:transformPersist", this.name, _value, context); if (!this.isValid(value)) { - //console.error("jsonschema:transformPersist:invalid", this.name, value); throw new TransformPersistFailedException(this.name, value); - } else { - //console.log("jsonschema:transformPersist:valid", this.name, value); } if (!value || typeof value !== "object") return this.getDefault(); @@ -130,4 +122,12 @@ export class JsonSchemaField< }), ); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + import: [{ package: "json-schema-to-ts", name: "FromSchema" }], + type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`, + }; + } } diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index e0468a8..4184560 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -1,7 +1,10 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const numberFieldConfigSchema = Type.Composite( [ @@ -100,4 +103,11 @@ export class NumberField extends Field< }), ); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + type: "number", + }; + } } diff --git a/app/src/data/fields/PrimaryField.ts b/app/src/data/fields/PrimaryField.ts index dd3463f..2cc2983 100644 --- a/app/src/data/fields/PrimaryField.ts +++ b/app/src/data/fields/PrimaryField.ts @@ -1,6 +1,9 @@ import { config } from "core"; -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const primaryFieldConfigSchema = Type.Composite([ Type.Omit(baseFieldConfigSchema, ["required"]), @@ -46,4 +49,13 @@ export class PrimaryField extends Field< override toJsonSchema() { return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined })); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + required: true, + import: [{ package: "kysely", name: "Generated" }], + type: "Generated", + }; + } } diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 8c318ec..5a439ad 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -1,7 +1,9 @@ -import { type Static, Type } from "core/utils"; import type { EntityManager } from "data"; +import type { Static } from "core/utils"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, baseFieldConfigSchema } from "./Field"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export const textFieldConfigSchema = Type.Composite( [ @@ -119,4 +121,11 @@ export class TextField extends Field< }), ); } + + override toType() { + return { + ...super.toType(), + type: "string", + }; + } } diff --git a/app/src/data/fields/VirtualField.ts b/app/src/data/fields/VirtualField.ts index c03db19..ec59ca3 100644 --- a/app/src/data/fields/VirtualField.ts +++ b/app/src/data/fields/VirtualField.ts @@ -1,5 +1,7 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const virtualFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]); diff --git a/app/src/data/fields/field-test-suite.ts b/app/src/data/fields/field-test-suite.ts index 4a2394d..f32e58c 100644 --- a/app/src/data/fields/field-test-suite.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -98,12 +98,9 @@ export function fieldTestSuite( test("toJSON", async () => { const _config = { ..._requiredConfig, - //order: 1, fillable: true, required: false, hidden: false, - //virtual: false, - //default_value: undefined }; function fieldJson(field: Field) { @@ -115,19 +112,16 @@ export function fieldTestSuite( } expect(fieldJson(noConfigField)).toEqual({ - //name: "no_config", type: noConfigField.type, config: _config, }); expect(fieldJson(fillable)).toEqual({ - //name: "fillable", type: noConfigField.type, config: _config, }); expect(fieldJson(required)).toEqual({ - //name: "required", type: required.type, config: { ..._config, @@ -136,7 +130,6 @@ export function fieldTestSuite( }); expect(fieldJson(hidden)).toEqual({ - //name: "hidden", type: required.type, config: { ..._config, @@ -145,7 +138,6 @@ export function fieldTestSuite( }); expect(fieldJson(dflt)).toEqual({ - //name: "dflt", type: dflt.type, config: { ..._config, @@ -154,7 +146,6 @@ export function fieldTestSuite( }); expect(fieldJson(requiredAndDefault)).toEqual({ - //name: "full", type: requiredAndDefault.type, config: { ..._config, diff --git a/app/src/data/fields/indices/EntityIndex.ts b/app/src/data/fields/indices/EntityIndex.ts index 239510b..e8af2e6 100644 --- a/app/src/data/fields/indices/EntityIndex.ts +++ b/app/src/data/fields/indices/EntityIndex.ts @@ -39,7 +39,6 @@ export class EntityIndex { return { entity: this.entity.name, fields: this.fields.map((f) => f.name), - //name: this.name, unique: this.unique, }; } diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 838b3b0..79dfeb0 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -18,7 +18,6 @@ export function getChangeSet( data: EntityData, fields: Field[], ): EntityData { - //console.log("getChangeSet", formData, data); return transform( formData, (acc, _value, key) => { @@ -32,17 +31,6 @@ export function getChangeSet( // @todo: add typing for "action" if (action === "create" || newValue !== data[key]) { acc[key] = newValue; - /*console.log("changed", { - key, - value, - valueType: typeof value, - prev: data[key], - newValue, - new: value, - sent: acc[key] - });*/ - } else { - //console.log("no change", key, value, data[key]); } }, {} as typeof formData, diff --git a/app/src/data/relations/EntityRelation.ts b/app/src/data/relations/EntityRelation.ts index 884fb23..232476a 100644 --- a/app/src/data/relations/EntityRelation.ts +++ b/app/src/data/relations/EntityRelation.ts @@ -1,4 +1,4 @@ -import { type Static, Type, parse } from "core/utils"; +import { type Static, parse } from "core/utils"; import type { ExpressionBuilder, SelectQueryBuilder } from "kysely"; import type { Entity, EntityData, EntityManager } from "../entities"; import { @@ -8,19 +8,15 @@ import { } from "../relations"; import type { RepoQuery } from "../server/data-query-impl"; import type { RelationType } from "./relation-types"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; + +const directions = ["source", "target"] as const; +export type TDirection = (typeof directions)[number]; export type KyselyJsonFrom = any; export type KyselyQueryBuilder = SelectQueryBuilder; -/*export type RelationConfig = { - mappedBy?: string; - inversedBy?: string; - sourceCardinality?: number; - connectionTable?: string; - connectionTableMappedName?: string; - required?: boolean; -};*/ - export type BaseRelationConfig = Static; // @todo: add generic type for relation config @@ -34,7 +30,7 @@ export abstract class EntityRelation< // @todo: add unit tests // allowed directions, used in RelationAccessor for visibility - directions: ("source" | "target")[] = ["source", "target"]; + directions: TDirection[] = ["source", "target"]; static schema = Type.Object({ mappedBy: Type.Optional(Type.String()), @@ -109,6 +105,10 @@ export abstract class EntityRelation< ); } + self(entity: Entity | string): EntityRelationAnchor { + return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source; + } + ref(reference: string): EntityRelationAnchor { return this.source.reference === reference ? this.source : this.target; } @@ -165,7 +165,6 @@ export abstract class EntityRelation< * @param entity */ isListableFor(entity: Entity): boolean { - //console.log("isListableFor", entity.name, this.source.entity.name, this.target.entity.name); return this.target.entity.name === entity.name; } diff --git a/app/src/data/relations/ManyToManyRelation.ts b/app/src/data/relations/ManyToManyRelation.ts index aa7732a..4767432 100644 --- a/app/src/data/relations/ManyToManyRelation.ts +++ b/app/src/data/relations/ManyToManyRelation.ts @@ -1,12 +1,14 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { ExpressionBuilder } from "kysely"; import { Entity, type EntityManager } from "../entities"; -import { type Field, PrimaryField, VirtualField } from "../fields"; +import { type Field, PrimaryField } from "../fields"; import type { RepoQuery } from "../server/data-query-impl"; -import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; +import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { RelationField } from "./RelationField"; import { type RelationType, RelationTypes } from "./relation-types"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export type ManyToManyRelationConfig = Static; @@ -46,7 +48,6 @@ export class ManyToManyRelation extends EntityRelation; diff --git a/app/src/data/relations/RelationField.ts b/app/src/data/relations/RelationField.ts index 6868797..0623987 100644 --- a/app/src/data/relations/RelationField.ts +++ b/app/src/data/relations/RelationField.ts @@ -1,8 +1,11 @@ -import { type Static, StringEnum, Type } from "core/utils"; +import { type Static, StringEnum } from "core/utils"; import type { EntityManager } from "../entities"; import { Field, baseFieldConfigSchema } from "../fields"; import type { EntityRelation } from "./EntityRelation"; import type { EntityRelationAnchor } from "./EntityRelationAnchor"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const; @@ -15,11 +18,6 @@ export const relationFieldConfigSchema = Type.Composite([ on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })), }), ]); -/*export const relationFieldConfigSchema = baseFieldConfigSchema.extend({ - reference: z.string(), - target: z.string(), - target_field: z.string().catch("id"), -});*/ export type RelationFieldConfig = Static; export type RelationFieldBaseConfig = { label?: string }; @@ -31,16 +29,6 @@ export class RelationField extends Field { return relationFieldConfigSchema; } - /*constructor(name: string, config?: Partial) { - //relation_name = relation_name || target.name; - //const name = [relation_name, target.getPrimaryField().name].join("_"); - super(name, config); - - //console.log(this.config); - //this.relation.target = target; - //this.relation.name = relation_name; - }*/ - static create( relation: EntityRelation, target: EntityRelationAnchor, @@ -50,7 +38,7 @@ export class RelationField extends Field { target.reference ?? target.entity.name, target.entity.getPrimaryField().name, ].join("_"); - //console.log('name', name); + return new RelationField(name, { ...config, required: relation.required, @@ -96,4 +84,11 @@ export class RelationField extends Field { }), ); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + type: "number", + }; + } } diff --git a/app/src/data/relations/RelationMutator.ts b/app/src/data/relations/RelationMutator.ts index 60366e9..225885b 100644 --- a/app/src/data/relations/RelationMutator.ts +++ b/app/src/data/relations/RelationMutator.ts @@ -63,7 +63,6 @@ export class RelationMutator { // make sure it's a primitive value // @todo: this is not a good way of checking primitives. Null is also an object if (typeof value === "object") { - console.log("value", value); throw new Error(`Invalid value for relation field "${key}" given, expected primitive.`); } diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index 581d91e..72ef50f 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -1,14 +1,8 @@ import type { TThis } from "@sinclair/typebox"; -import { - type SchemaOptions, - type Static, - type StaticDecode, - StringEnum, - Type, - Value, - isObject, -} from "core/utils"; +import { type SchemaOptions, type StaticDecode, StringEnum, Value, isObject } from "core/utils"; import { WhereBuilder, type WhereQuery } from "../entities"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const NumberOrString = (options: SchemaOptions = {}) => Type.Transform(Type.Union([Type.Number(), Type.String()], options)) diff --git a/app/src/data/test-types.ts b/app/src/data/test-types.ts deleted file mode 100644 index 1c23fb9..0000000 --- a/app/src/data/test-types.ts +++ /dev/null @@ -1,78 +0,0 @@ -type Field = { - _type: Type; - _required: Required; -}; -type TextField = Field & { - _type: string; - required: () => TextField; -}; -type NumberField = Field & { - _type: number; - required: () => NumberField; -}; - -type Entity> = {}> = { name: string; fields: Fields }; - -function entity>>( - name: string, - fields: Fields, -): Entity { - return { name, fields }; -} - -function text(): TextField { - return {} as any; -} -function number(): NumberField { - return {} as any; -} - -const field1 = text(); -const field1_req = text().required(); -const field2 = number(); -const user = entity("users", { - name: text().required(), - bio: text(), - age: number(), - some: number().required(), -}); - -type InferEntityFields = T extends Entity - ? { - [K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required } - ? Required extends true - ? Type - : Type | undefined - : never; - } - : never; - -type Prettify = { - [K in keyof T]: T[K]; -}; -export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; - -// from https://github.com/type-challenges/type-challenges/issues/28200 -type Merge = { - [K in keyof T]: T[K]; -}; -type OptionalUndefined< - T, - Props extends keyof T = keyof T, - OptionsProps extends keyof T = Props extends keyof T - ? undefined extends T[Props] - ? Props - : never - : never, -> = Merge< - { - [K in OptionsProps]?: T[K]; - } & { - [K in Exclude]: T[K]; - } ->; - -type UserFields = InferEntityFields; -type UserFields2 = Simplify>; - -const obj: UserFields2 = { name: "h", age: 1, some: 1 }; diff --git a/app/src/flows/AppFlows.ts b/app/src/flows/AppFlows.ts index a75defe..6e9ac36 100644 --- a/app/src/flows/AppFlows.ts +++ b/app/src/flows/AppFlows.ts @@ -25,7 +25,6 @@ export class AppFlows extends Module { } override async build() { - //console.log("building flows", this.config); const flows = transformObject(this.config.flows, (flowConfig, name) => { return Flow.fromObject(name, flowConfig as any, TASKS); }); diff --git a/app/src/flows/flows-schema.ts b/app/src/flows/flows-schema.ts index 15365c7..e5d029b 100644 --- a/app/src/flows/flows-schema.ts +++ b/app/src/flows/flows-schema.ts @@ -1,5 +1,7 @@ -import { Const, type Static, StringRecord, Type, transformObject } from "core/utils"; +import { Const, type Static, StringRecord, transformObject } from "core/utils"; import { TaskMap, TriggerMap } from "flows"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const TASKS = { ...TaskMap, diff --git a/app/src/flows/flows/Execution.ts b/app/src/flows/flows/Execution.ts index fc69f74..61bf05e 100644 --- a/app/src/flows/flows/Execution.ts +++ b/app/src/flows/flows/Execution.ts @@ -2,6 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events"; import type { EmitsEvents } from "core/events"; import type { Task, TaskResult } from "../tasks/Task"; import type { Flow } from "./Flow"; +import { $console } from "core"; export type TaskLog = TaskResult & { task: Task; @@ -185,10 +186,9 @@ export class Execution implements EmitsEvents { await Promise.all(promises); return this.run(); } catch (e) { - console.log("RuntimeExecutor: error", e); + $console.error("RuntimeExecutor: error", e); // for now just throw - // biome-ignore lint/complexity/noUselessCatch: @todo: add error task on flow throw e; } } diff --git a/app/src/flows/flows/Flow.ts b/app/src/flows/flows/Flow.ts index 287a46e..c924756 100644 --- a/app/src/flows/flows/Flow.ts +++ b/app/src/flows/flows/Flow.ts @@ -5,6 +5,7 @@ import { Condition, TaskConnection } from "../tasks/TaskConnection"; import { Execution } from "./Execution"; import { FlowTaskConnector } from "./FlowTaskConnector"; import { Trigger } from "./triggers/Trigger"; +import { $console } from "core"; type Jsoned object }> = ReturnType; @@ -53,8 +54,6 @@ export class Flow { } getSequence(sequence: Task[][] = []): Task[][] { - //console.log("queue", queue.map((step) => step.map((t) => t.name))); - // start task if (sequence.length === 0) { sequence.push([this.startTask]); @@ -69,7 +68,6 @@ export class Flow { // check if task already in one of queue steps // this is when we have a circle back if (sequence.some((step) => step.includes(outTask))) { - //console.log("Task already in queue", outTask.name); return; } nextStep.push(outTask); @@ -110,14 +108,6 @@ export class Flow { return this; } - /*getResponse() { - if (!this.respondingTask) { - return; - } - - return this.respondingTask.log.output; - }*/ - // @todo: check for existence addConnection(connection: TaskConnection) { // check if connection already exists @@ -179,7 +169,7 @@ export class Flow { // @ts-ignore return new cls(name, obj.params); } catch (e: any) { - console.log("Error creating task", name, obj.type, obj, taskClass); + $console.error("Error creating task", name, obj.type, obj, taskClass); throw new Error(`Error creating task ${obj.type}: ${e.message}`); } }); diff --git a/app/src/flows/flows/FlowTaskConnector.ts b/app/src/flows/flows/FlowTaskConnector.ts index cd11ff5..0622db1 100644 --- a/app/src/flows/flows/FlowTaskConnector.ts +++ b/app/src/flows/flows/FlowTaskConnector.ts @@ -31,37 +31,11 @@ export class FlowTaskConnector { } } - /*const targetDepth = this.task(target).getDepth(); - console.log("depth", ownDepth, targetDepth); - - // if target has a lower depth - if (targetDepth > 0 && ownDepth >= targetDepth) { - // check for unique out conditions - console.log( - "out conditions", - this.source.name, - this.getOutConnections().map((c) => [c.target.name, c.condition]) - ); - if ( - this.getOutConnections().some( - (c) => - c.condition[0] === condition[0] && - c.condition[1] === condition[1] - ) - ) { - throw new Error( - "Task cannot be connected to a deeper task with the same condition" - ); - } - }*/ - this.flow.addConnection(new TaskConnection(this.source, target, { condition, max_retries })); } asOutputFor(target: Task, condition?: Condition) { this.task(target).asInputFor(this.source, condition); - //new FlowTaskConnector(this.flow, target).asInputFor(this.source); - //this.flow.addConnection(new TaskConnection(target, this.source)); } getNext() { @@ -107,12 +81,4 @@ export class FlowTaskConnector { getOutTasks(result?: TaskResult): Task[] { return this.getOutConnections(result).map((c) => c.target); } - - /*getNextRunnableConnections() { - return this.getOutConnections().filter((c) => c.source.log.success); - } - - getNextRunnableTasks() { - return this.getNextRunnableConnections().map((c) => c.target); - }*/ } diff --git a/app/src/flows/flows/executors/RuntimeExecutor.ts b/app/src/flows/flows/executors/RuntimeExecutor.ts index 4f8ec31..46ea105 100644 --- a/app/src/flows/flows/executors/RuntimeExecutor.ts +++ b/app/src/flows/flows/executors/RuntimeExecutor.ts @@ -1,4 +1,5 @@ import type { Task } from "../../tasks/Task"; +import { $console } from "core"; export class RuntimeExecutor { async run( @@ -10,7 +11,6 @@ export class RuntimeExecutor { return; } - //const promises = tasks.map((t) => t.run()); const promises = tasks.map(async (t) => { const result = await t.run(); onDone?.(t, result); @@ -20,7 +20,7 @@ export class RuntimeExecutor { try { await Promise.all(promises); } catch (e) { - console.log("RuntimeExecutor: error", e); + $console.error("RuntimeExecutor: error", e); } return this.run(nextTasks, onDone); diff --git a/app/src/flows/flows/triggers/EventTrigger.ts b/app/src/flows/flows/triggers/EventTrigger.ts index 7426075..3924840 100644 --- a/app/src/flows/flows/triggers/EventTrigger.ts +++ b/app/src/flows/flows/triggers/EventTrigger.ts @@ -1,7 +1,9 @@ import type { EventManager } from "core/events"; -import { Type } from "core/utils"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; +import { $console } from "core"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class EventTrigger extends Trigger { override type = "event"; @@ -22,17 +24,13 @@ export class EventTrigger extends Trigger { emgr.on( this.config.event, async (event) => { - console.log("event", event); - /*if (!this.match(event)) { - return; - }*/ const execution = flow.createExecution(); this.executions.push(execution); try { await execution.start(event.params); } catch (e) { - console.error(e); + $console.error(e); } }, this.config.mode, diff --git a/app/src/flows/flows/triggers/HttpTrigger.ts b/app/src/flows/flows/triggers/HttpTrigger.ts index 442f9de..6dc66d9 100644 --- a/app/src/flows/flows/triggers/HttpTrigger.ts +++ b/app/src/flows/flows/triggers/HttpTrigger.ts @@ -1,7 +1,9 @@ -import { StringEnum, Type } from "core/utils"; +import { StringEnum } from "core/utils"; import type { Context, Hono } from "hono"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const httpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; @@ -10,14 +12,11 @@ export class HttpTrigger extends Trigger { static override schema = Type.Composite([ Trigger.schema, - Type.Object( - { - path: Type.String({ pattern: "^/.*$" }), - method: StringEnum(httpMethods, { default: "GET" }), - response_type: StringEnum(["json", "text", "html"], { default: "json" }), - }, - //{ additionalProperties: false } - ), + Type.Object({ + path: Type.String({ pattern: "^/.*$" }), + method: StringEnum(httpMethods, { default: "GET" }), + response_type: StringEnum(["json", "text", "html"], { default: "json" }), + }), ]); override async register(flow: Flow, hono: Hono) { @@ -43,7 +42,5 @@ export class HttpTrigger extends Trigger { execution.start(params); return c.json({ success: true }); }); - - //console.log("--registered flow", flow.name, "on", method, this.config.path); } } diff --git a/app/src/flows/flows/triggers/Trigger.ts b/app/src/flows/flows/triggers/Trigger.ts index 6879ccc..a8e2215 100644 --- a/app/src/flows/flows/triggers/Trigger.ts +++ b/app/src/flows/flows/triggers/Trigger.ts @@ -1,6 +1,8 @@ -import { type Static, StringEnum, Type, parse } from "core/utils"; +import { type Static, StringEnum, parse } from "core/utils"; import type { Execution } from "../Execution"; import type { Flow } from "../Flow"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class Trigger { // @todo: remove this @@ -8,12 +10,9 @@ export class Trigger; - static schema = Type.Object( - { - mode: StringEnum(["sync", "async"], { default: "async" }), - }, - //{ additionalProperties: false } - ); + static schema = Type.Object({ + mode: StringEnum(["sync", "async"], { default: "async" }), + }); constructor(config?: Partial>) { const schema = (this.constructor as typeof Trigger).schema; diff --git a/app/src/flows/flows/triggers/index.ts b/app/src/flows/flows/triggers/index.ts index bdd8f19..6ae8ddc 100644 --- a/app/src/flows/flows/triggers/index.ts +++ b/app/src/flows/flows/triggers/index.ts @@ -4,7 +4,6 @@ import { Trigger } from "./Trigger"; export { Trigger, EventTrigger, HttpTrigger }; -//export type TriggerMapType = { [key: string]: { cls: typeof Trigger } }; export const TriggerMap = { manual: { cls: Trigger }, event: { cls: EventTrigger }, diff --git a/app/src/flows/index.ts b/app/src/flows/index.ts index 4ca0de4..9f7546b 100644 --- a/app/src/flows/index.ts +++ b/app/src/flows/index.ts @@ -23,13 +23,9 @@ export { } from "./flows/triggers"; import { Task } from "./tasks/Task"; -export { type TaskResult, type TaskRenderProps } from "./tasks/Task"; +export type { TaskResult, TaskRenderProps } from "./tasks/Task"; export { TaskConnection, Condition } from "./tasks/TaskConnection"; -// test -//export { simpleFetch } from "./examples/simple-fetch"; - -//export type TaskMapType = { [key: string]: { cls: typeof Task } }; export const TaskMap = { fetch: { cls: FetchTask }, log: { cls: LogTask }, diff --git a/app/src/flows/tasks/Task.tsx b/app/src/flows/tasks/Task.tsx index 314d1a7..e035af9 100644 --- a/app/src/flows/tasks/Task.tsx +++ b/app/src/flows/tasks/Task.tsx @@ -1,8 +1,9 @@ import type { StaticDecode, TSchema } from "@sinclair/typebox"; -import type { NodeProps } from "@xyflow/react"; import { BkndError, SimpleRenderer } from "core"; -import { type Static, type TObject, Type, Value, parse, ucFirst } from "core/utils"; -import type { ExecutionEvent, InputsMap } from "../flows/Execution"; +import { type Static, type TObject, Value, parse, ucFirst } from "core/utils"; +import type { InputsMap } from "../flows/Execution"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; //type InstanceOf = T extends new (...args: any) => infer R ? R : never; export type TaskResult = { @@ -13,10 +14,6 @@ export type TaskResult = { params: any; }; -/*export type TaskRenderProps = NodeProps<{ - task: T; - state: { i: number; isStartTask: boolean; isRespondingTask; event: ExecutionEvent | undefined }; -}>;*/ export type TaskRenderProps = any; export function dynamic( @@ -93,21 +90,6 @@ export abstract class Task { // @todo: string enums fail to validate this._params = parse(schema, params || {}); - - /*const validator = new Validator(schema as any); - const _params = Default(schema, params || {}); - const result = validator.validate(_params); - if (!result.valid) { - //console.log("---errors", result, { params, _params }); - const error = result.errors[0]!; - throw new Error( - `Invalid params for task "${name}.${error.keyword}": "${ - error.error - }". Params given: ${JSON.stringify(params)}` - ); - } - - this._params = _params as Static;*/ } get params() { @@ -124,13 +106,10 @@ export abstract class Task { inputs: object = {}, ): Promise> { const newParams: any = {}; - const renderer = new SimpleRenderer(inputs, { strictVariables: true, renderKeys: true }); - - //console.log("--resolveParams", params); + const renderer = new SimpleRenderer(inputs, { renderKeys: true }); for (const [key, value] of Object.entries(params)) { if (value && SimpleRenderer.hasMarkup(value)) { - //console.log("--- has markup", value); try { newParams[key] = await renderer.render(value as string); } catch (e: any) { @@ -150,29 +129,21 @@ export abstract class Task { throw e; } continue; - } else { - //console.log("-- no markup", key, value); } newParams[key] = value; } - //console.log("--beforeDecode", newParams); - const v = Value.Decode(schema, newParams); - //console.log("--afterDecode", v); - //process.exit(); - return v; + return Value.Decode(schema, newParams); } private async cloneWithResolvedParams(_inputs: Map) { const inputs = Object.fromEntries(_inputs.entries()); - //console.log("--clone:inputs", inputs, this.params); const newParams = await Task.resolveParams( (this.constructor as any).schema, this._params, inputs, ); - //console.log("--clone:newParams", this.name, newParams); return this.clone(this.name, newParams as any); } @@ -200,7 +171,6 @@ export abstract class Task { success = true; } catch (e: any) { success = false; - //status.output = undefined; if (e instanceof BkndError) { error = e.toJSON(); diff --git a/app/src/flows/tasks/TaskConnection.ts b/app/src/flows/tasks/TaskConnection.ts index 54efa3e..186eb28 100644 --- a/app/src/flows/tasks/TaskConnection.ts +++ b/app/src/flows/tasks/TaskConnection.ts @@ -80,7 +80,6 @@ export class Condition { return result.success === false; case "matches": return get(result.output, this.path) === this.value; - //return this.value === output[this.path]; } } diff --git a/app/src/flows/tasks/presets/FetchTask.ts b/app/src/flows/tasks/presets/FetchTask.ts index b590c26..17b17a3 100644 --- a/app/src/flows/tasks/presets/FetchTask.ts +++ b/app/src/flows/tasks/presets/FetchTask.ts @@ -1,6 +1,7 @@ -import { StringEnum, Type } from "core/utils"; -import type { InputsMap } from "../../flows/Execution"; +import { StringEnum } from "core/utils"; import { Task, dynamic } from "../Task"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const FetchMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; @@ -14,8 +15,6 @@ export class FetchTask> extends Task< url: Type.String({ pattern: "^(http|https)://", }), - //method: Type.Optional(Type.Enum(FetchMethodsEnum)), - //method: Type.Optional(dynamic(Type.String({ enum: FetchMethods, default: "GET" }))), method: Type.Optional(dynamic(StringEnum(FetchMethods, { default: "GET" }))), headers: Type.Optional( dynamic( @@ -42,7 +41,6 @@ export class FetchTask> extends Task< } async execute() { - //console.log(`method: (${this.params.method})`); if (!FetchMethods.includes(this.params.method ?? "GET")) { throw this.error("Invalid method", { given: this.params.method, @@ -53,19 +51,12 @@ export class FetchTask> extends Task< const body = this.getBody(); const headers = new Headers(this.params.headers?.map((h) => [h.key, h.value])); - /*console.log("[FETCH]", { - url: this.params.url, - method: this.params.method ?? "GET", - headers, - body - });*/ const result = await fetch(this.params.url, { method: this.params.method ?? "GET", headers, body, }); - //console.log("fetch:response", result); if (!result.ok) { throw this.error("Failed to fetch", { status: result.status, @@ -74,8 +65,6 @@ export class FetchTask> extends Task< } const data = (await result.json()) as Output; - //console.log("fetch:response:data", data); - return data; } } diff --git a/app/src/flows/tasks/presets/LogTask.ts b/app/src/flows/tasks/presets/LogTask.ts index fec93b7..fda6d12 100644 --- a/app/src/flows/tasks/presets/LogTask.ts +++ b/app/src/flows/tasks/presets/LogTask.ts @@ -1,5 +1,7 @@ -import { Type } from "core/utils"; import { Task } from "../Task"; +import { $console } from "core"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class LogTask extends Task { type = "log"; @@ -10,7 +12,7 @@ export class LogTask extends Task { async execute() { await new Promise((resolve) => setTimeout(resolve, this.params.delay)); - console.log(`[DONE] LogTask: ${this.name}`); + $console.log(`[DONE] LogTask: ${this.name}`); return true; } } diff --git a/app/src/flows/tasks/presets/RenderTask.ts b/app/src/flows/tasks/presets/RenderTask.ts index fd3801b..4ab0a23 100644 --- a/app/src/flows/tasks/presets/RenderTask.ts +++ b/app/src/flows/tasks/presets/RenderTask.ts @@ -1,5 +1,6 @@ -import { Type } from "core/utils"; import { Task } from "../Task"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class RenderTask> extends Task< typeof RenderTask.schema, diff --git a/app/src/flows/tasks/presets/SubFlowTask.ts b/app/src/flows/tasks/presets/SubFlowTask.ts index 813ab3d..7832d48 100644 --- a/app/src/flows/tasks/presets/SubFlowTask.ts +++ b/app/src/flows/tasks/presets/SubFlowTask.ts @@ -1,6 +1,7 @@ -import { Type } from "core/utils"; import { Flow } from "../../flows/Flow"; import { Task, dynamic } from "../Task"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class SubFlowTask> extends Task< typeof SubFlowTask.schema, diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 4c73b9d..51c7586 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,6 +1,6 @@ -import type { PrimaryFieldType } from "core"; -import { type Entity, EntityIndex, type EntityManager } from "data"; -import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; +import { $console, type AppEntity } from "core"; +import type { Entity, EntityManager } from "data"; +import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media"; import { Module } from "modules/Module"; import { type FieldSchema, @@ -13,12 +13,13 @@ import { text, } from "../data/prototype"; import { MediaController } from "./api/MediaController"; -import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; +import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; export type MediaFieldSchema = FieldSchema; declare module "core" { + interface Media extends AppEntity, MediaFieldSchema {} interface DB { - media: { id: PrimaryFieldType } & MediaFieldSchema; + media: Media; } } @@ -46,6 +47,7 @@ export class AppMedia extends Module { this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this.setBuilt(); this.setupListeners(); + this.ctx.guard.registerPermissions(MediaPermissions); this.ctx.server.route(this.basepath, new MediaController(this).getController()); const media = this.getMediaEntity(true); @@ -145,11 +147,10 @@ export class AppMedia extends Module { // simple file deletion sync const { data } = await em.repo(media).findOne({ path: e.params.name }); if (data) { - console.log("item.data", data); await em.mutator(media).deleteOne(data.id); } - console.log("App:storage:file deleted", e); + $console.log("App:storage:file deleted", e.params); }, { mode: "sync", id: "delete-data-media" }, ); diff --git a/app/src/media/MediaField.ts b/app/src/media/MediaField.ts index 66fd0b6..d7760d3 100644 --- a/app/src/media/MediaField.ts +++ b/app/src/media/MediaField.ts @@ -1,5 +1,7 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "data"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const mediaFieldConfigSchema = Type.Composite([ Type.Object({ diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 2c3bc7e..956f2aa 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -6,13 +6,17 @@ import { type TInput, } from "modules/ModuleApi"; import type { FileWithPath } from "ui/elements/media/file-selector"; +import type { ApiFetcher } from "Api"; -export type MediaApiOptions = BaseModuleApiOptions & {}; +export type MediaApiOptions = BaseModuleApiOptions & { + upload_fetcher: ApiFetcher; +}; export class MediaApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/media", + upload_fetcher: fetch, }; } @@ -44,7 +48,7 @@ export class MediaApi extends ModuleApi { return (await res.blob()) as File; } - getFileUploadUrl(file?: FileWithPath): string { + getFileUploadUrl(file?: { path: string }): string { if (!file) return this.getUrl("/upload"); return this.getUrl(`/upload/${file.path}`); } @@ -54,9 +58,12 @@ export class MediaApi extends ModuleApi { } getUploadHeaders(): Headers { - return new Headers({ - Authorization: `Bearer ${this.options.token}`, - }); + if (this.options.token_transport === "header" && this.options.token) { + return new Headers({ + Authorization: `Bearer ${this.options.token}`, + }); + } + return new Headers(); } protected uploadFile( @@ -106,10 +113,12 @@ export class MediaApi extends ModuleApi { filename?: string; _init?: Omit; path?: TInput; + fetcher?: ApiFetcher; } = {}, ) { if (item instanceof Request || typeof item === "string") { - const res = await this.fetcher(item); + const fetcher = opts.fetcher ?? this.options.upload_fetcher; + const res = await fetcher(item); if (!res.ok || !res.body) { throw new Error("Failed to fetch file"); } @@ -140,6 +149,7 @@ export class MediaApi extends ModuleApi { item: Request | Response | string | File | ReadableStream, opts?: { _init?: Omit; + fetcher?: typeof fetch; }, ) { return this.upload(item, { diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 415b3a3..4783be9 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,10 +1,13 @@ import { isDebug, tbValidator as tb } from "core"; -import { HttpStatus, Type, getFileFromContext, headersToObject } from "core/utils"; +import { HttpStatus, getFileFromContext } from "core/utils"; import type { StorageAdapter } from "media"; -import { StorageEvents, getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename, MediaPermissions } from "media"; +import { DataPermissions } from "data"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const booleanLike = Type.Transform(Type.String()) .Decode((v) => v === "1") @@ -26,18 +29,18 @@ export class MediaController extends Controller { override getController() { // @todo: multiple providers? // @todo: implement range requests - const { auth } = this.middlewares; + const { auth, permission } = this.middlewares; const hono = this.create().use(auth()); // get files list (temporary) - hono.get("/files", async (c) => { + hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); }); // get file by name // @todo: implement more aggressive cache? (configurable) - hono.get("/file/:filename", async (c) => { + hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -57,7 +60,7 @@ export class MediaController extends Controller { }); // delete a file by name - hono.delete("/file/:filename", async (c) => { + hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -82,7 +85,7 @@ export class MediaController extends Controller { // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename?", async (c) => { + hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { const reqname = c.req.param("filename"); const body = await getFileFromContext(c); @@ -112,6 +115,7 @@ export class MediaController extends Controller { overwrite: Type.Optional(booleanLike), }), ), + permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), async (c) => { const entity_name = c.req.param("entity"); const field_name = c.req.param("field"); diff --git a/app/src/media/index.ts b/app/src/media/index.ts index a49a4f9..d9451ad 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,7 +1,6 @@ import type { TObject } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; -//export { MIME_TYPES } from "./storage/mime-types"; export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, @@ -22,6 +21,7 @@ export { StorageAdapter }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; +export * as MediaPermissions from "./media-permissions"; export type { FileUploadedEventData } from "./storage/events"; export * from "./utils"; diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts new file mode 100644 index 0000000..714cc2d --- /dev/null +++ b/app/src/media/media-permissions.ts @@ -0,0 +1,6 @@ +import { Permission } from "core"; + +export const readFile = new Permission("media.file.read"); +export const listFiles = new Permission("media.file.list"); +export const uploadFile = new Permission("media.file.upload"); +export const deleteFile = new Permission("media.file.delete"); diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 7716077..f02e2a6 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,6 +1,8 @@ -import { Const, type Static, Type, objectTransform } from "core/utils"; +import { Const, type Static, objectTransform } from "core/utils"; import { Adapters } from "media"; import { registries } from "modules/registries"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const ADAPTERS = { ...Adapters, diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 71ee9e6..6335150 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -1,7 +1,9 @@ import { hash, pickHeaders } from "core/utils"; -import { type Static, Type, parse } from "core/utils"; +import { type Static, parse } from "core/utils"; import type { FileBody, FileListObject, FileMeta } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const cloudinaryAdapterConfig = Type.Object( { diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts index 4154126..6462e83 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts @@ -7,10 +7,12 @@ import type { PutObjectRequest, } from "@aws-sdk/client-s3"; import { AwsClient, isDebug } from "core"; -import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; +import { type Static, isFile, parse, pickHeaders2 } from "core/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const s3AdapterConfig = Type.Object( { diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 6c453af..ebb6403 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -1,6 +1,7 @@ -import type { PrimaryFieldType } from "core"; +import { $console, type PrimaryFieldType } from "core"; import { isDebug } from "core/env"; import { encodeSearch } from "core/utils/reqres"; +import type { ApiFetcher } from "Api"; export type { PrimaryFieldType }; export type BaseModuleApiOptions = { @@ -24,11 +25,11 @@ export type ApiResponse = { export type TInput = string | (string | number | PrimaryFieldType)[]; export abstract class ModuleApi { - protected fetcher: typeof fetch; + protected fetcher: ApiFetcher; constructor( protected readonly _options: Partial = {}, - fetcher?: typeof fetch, + fetcher?: ApiFetcher, ) { this.fetcher = fetcher ?? fetch; } @@ -87,7 +88,6 @@ export abstract class ModuleApi> implements Promise { constructor( public request: Request, protected options?: { - fetcher?: typeof fetch; + fetcher?: ApiFetcher; verbose?: boolean; }, // keep "any" here, it gets inferred correctly with the "refine" fn @@ -245,7 +245,7 @@ export class FetchPromise> implements Promise { const fetcher = this.options?.fetcher ?? fetch; if (this.verbose) { - console.log("[FetchPromise] Request", { + $console.debug("[FetchPromise] Request", { method: this.request.method, url: this.request.url, }); @@ -253,7 +253,7 @@ export class FetchPromise> implements Promise { const res = await fetcher(this.request); if (this.verbose) { - console.log("[FetchPromise] Response", { + $console.debug("[FetchPromise] Response", { res: res, ok: res.ok, status: res.status, diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index fafc54f..8c1b554 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -6,7 +6,6 @@ import { Default, type Static, StringEnum, - Type, mark, objectEach, stripMark, @@ -34,6 +33,8 @@ import { AppFlows } from "../flows/AppFlows"; import { AppMedia } from "../media/AppMedia"; import type { ServerEnv } from "./Controller"; import { Module, type ModuleBuildContext } from "./Module"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export type { ModuleBuildContext }; @@ -644,7 +645,7 @@ export class ModuleManager { // revert to previous config & rebuild using original listener this.revertModules(); await this.onModuleConfigUpdated(name, module.config as any); - $console.log(`[Safe Mutate] reverted "${name}":`); + $console.warn(`[Safe Mutate] reverted "${name}":`); // make sure to throw the error throw e; diff --git a/app/src/modules/middlewares.ts b/app/src/modules/middlewares.ts deleted file mode 100644 index be1ad59..0000000 --- a/app/src/modules/middlewares.ts +++ /dev/null @@ -1 +0,0 @@ -export { auth, permission } from "auth/middlewares"; diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 59911b1..3ce4ffb 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,7 +1,6 @@ -import { _jsonp, transformObject } from "core/utils"; -import { type Kysely, sql } from "kysely"; +import { transformObject } from "core/utils"; +import type { Kysely } from "kysely"; import { set } from "lodash-es"; -import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -17,7 +16,6 @@ export type Migration = { export const migrations: Migration[] = [ { version: 1, - //schema: true, up: async (config) => config, }, { @@ -28,7 +26,6 @@ export const migrations: Migration[] = [ }, { version: 3, - //schema: true, up: async (config) => config, }, { @@ -46,7 +43,6 @@ export const migrations: Migration[] = [ { version: 5, up: async (config, { db }) => { - //console.log("config", _jsonp(config)); const cors = config.server.cors?.allow_methods ?? []; set(config.server, "cors.allow_methods", [...new Set([...cors, "PATCH"])]); return config; @@ -114,15 +110,12 @@ export async function migrateTo( config: GenericConfigObject, ctx: MigrationContext, ): Promise<[number, GenericConfigObject]> { - //console.log("migrating from", current, "to", CURRENT_VERSION, config); const todo = migrations.filter((m) => m.version > current && m.version <= to); - //console.log("todo", todo.length); let updated = Object.assign({}, config); let i = 0; let version = current; for (const migration of todo) { - //console.log("-- running migration", i + 1, "of", todo.length, { version: migration.version }); try { updated = await migration.up(updated, ctx); version = migration.version; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 6f2752f..6105a4b 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -1,16 +1,23 @@ /** @jsxImportSource hono/jsx */ import type { App } from "App"; -import { config, isDebug } from "core"; +import { $console, config, isDebug } from "core"; import { addFlashMessage } from "core/server/flash"; import { html } from "hono/html"; import { Fragment } from "hono/jsx"; import { css, Style } from "hono/css"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; +import type { TApiUser } from "Api"; const htmlBkndContextReplace = ""; +export type AdminBkndWindowContext = { + user?: TApiUser; + logout_route: string; + admin_basepath: string; +}; + // @todo: add migration to remove admin path from config export type AdminControllerOptions = { basepath?: string; @@ -80,6 +87,7 @@ export class AdminController extends Controller { const obj = { user: c.get("auth")?.user, logout_route: this.withAdminBasePath(authRoutes.logout), + admin_basepath: this.options.adminBasepath, }; const html = await this.getHtml(obj); if (!html) { @@ -99,7 +107,7 @@ export class AdminController extends Controller { onGranted: async (c) => { // @todo: add strict test to permissions middleware? if (c.get("auth")?.user) { - console.log("redirecting to success"); + $console.log("redirecting to success"); return c.redirect(authRoutes.success); } }, @@ -125,7 +133,7 @@ export class AdminController extends Controller { onDenied: async (c) => { addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); - console.log("redirecting"); + $console.log("redirecting"); return c.redirect(authRoutes.login); }, }), @@ -142,7 +150,7 @@ export class AdminController extends Controller { return hono; } - private async getHtml(obj: any = {}) { + private async getHtml(obj: AdminBkndWindowContext) { const bknd_context = `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`; if (this.options.html) { @@ -153,7 +161,7 @@ export class AdminController extends Controller { ); } - console.warn( + $console.warn( `Custom HTML needs to include '${htmlBkndContextReplace}' to inject BKND context`, ); return this.options.html as string; diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 2ccb208..ba1c566 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -1,7 +1,10 @@ -import { Exception, isDebug } from "core"; -import { type Static, StringEnum, Type } from "core/utils"; +import { Exception, isDebug, $console } from "core"; +import { type Static, StringEnum } from "core/utils"; import { cors } from "hono/cors"; import { Module } from "modules/Module"; +import * as tbbox from "@sinclair/typebox"; +import { AuthException } from "auth/errors"; +const { Type } = tbbox; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; @@ -29,8 +32,6 @@ export const serverConfigSchema = Type.Object( export type AppServerConfig = Static; export class AppServer extends Module { - //private admin_html?: string; - override getRestrictedPaths() { return []; } @@ -70,14 +71,17 @@ export class AppServer extends Module { this.client.onError((err, c) => { //throw err; - console.error(err); + $console.error("[AppServer:onError]", err); if (err instanceof Response) { return err; } + if (err instanceof AuthException) { + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); + } + if (err instanceof Exception) { - console.log("---is exception", err.code); return c.json(err.toJSON(), err.code as any); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index d02b55d..7ade0e8 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -4,7 +4,6 @@ import type { App } from "App"; import { $console, tbValidator as tb } from "core"; import { StringEnum, - Type, TypeInvalidError, datetimeStringLocal, datetimeStringUTC, @@ -14,6 +13,8 @@ import { import { getRuntimeKey } from "core/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; import { MODULE_NAMES, @@ -99,7 +100,7 @@ export class SystemController extends Controller { try { return c.json(await cb(), { status: 202 }); } catch (e) { - console.error(e); + $console.error("config update error", e); if (e instanceof TypeInvalidError) { return c.json( diff --git a/app/src/modules/server/openapi.ts b/app/src/modules/server/openapi.ts index 12d59ef..f551467 100644 --- a/app/src/modules/server/openapi.ts +++ b/app/src/modules/server/openapi.ts @@ -1,6 +1,7 @@ -import { Type } from "core/utils"; import type { ModuleConfigs } from "modules/ModuleManager"; import type { OpenAPIV3 as OAS } from "openapi-types"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; function prefixPaths(paths: OAS.PathsObject, prefix: string): OAS.PathsObject { const result: OAS.PathsObject = {}; diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index d69738f..1c73a58 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -7,7 +7,6 @@ import { Logo } from "ui/components/display/Logo"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { ClientProvider, type ClientProviderProps } from "./client"; import { createMantineTheme } from "./lib/mantine/theme"; -import { BkndModalsProvider } from "./modals"; import { Routes } from "./routes"; export type BkndAdminProps = { diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 20eb9a1..18926eb 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,6 +1,7 @@ import { Api, type ApiOptions, type TApiUser } from "Api"; import { isDebug } from "core"; import { createContext, type ReactNode, useContext } from "react"; +import type { AdminBkndWindowContext } from "modules/server/AdminController"; const ClientContext = createContext<{ baseUrl: string; api: Api }>({ baseUrl: undefined, @@ -68,16 +69,18 @@ export const useBaseUrl = () => { return context.baseUrl; }; -type BkndWindowContext = { - user?: TApiUser; - logout_route: string; -}; -export function useBkndWindowContext(): BkndWindowContext { +export function useBkndWindowContext(): AdminBkndWindowContext { + const defaults = { + logout_route: "/api/auth/logout", + admin_basepath: "", + }; + if (typeof window !== "undefined" && window.__BKND__) { - return window.__BKND__ as any; - } else { return { - logout_route: "/api/auth/logout", + ...defaults, + ...window.__BKND__, }; } + + return defaults; } diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index b7e253d..3995e56 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -1,9 +1,10 @@ import type { DB, PrimaryFieldType } from "core"; import { objectTransform } from "core/utils/objects"; import { encodeSearch } from "core/utils/reqres"; -import type { EntityData, RepoQueryIn } from "data"; -import type { ModuleApi, ResponseObject } from "modules/ModuleApi"; -import useSWR, { type SWRConfiguration, mutate } from "swr"; +import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; +import type { Insertable, Selectable, Updateable } from "kysely"; +import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi"; +import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import { type Api, useApi } from "ui/client"; export class UseEntityApiError extends Error { @@ -23,6 +24,26 @@ export class UseEntityApiError extends Error { } } +interface UseEntityReturn< + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined, + Data = Entity extends keyof DB ? DB[Entity] : EntityData, + Response = ResponseObject>>, +> { + create: (input: Insertable) => Promise; + read: ( + query?: RepoQueryIn, + ) => Promise< + ResponseObject< + RepositoryResponse[] : Selectable> + > + >; + update: Id extends undefined + ? (input: Updateable, id: Id) => Promise + : (input: Updateable) => Promise; + _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; +} + export const useEntity = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, @@ -30,28 +51,26 @@ export const useEntity = < >( entity: Entity, id?: Id, -) => { +): UseEntityReturn => { const api = useApi().data; return { - create: async (input: Omit) => { - const res = await api.createOne(entity, input); + create: async (input: Insertable) => { + const res = await api.createOne(entity, input as any); if (!res.ok) { throw new UseEntityApiError(res, `Failed to create entity "${entity}"`); } - return res; + return res as any; }, - read: async (query: RepoQueryIn = {}) => { + read: async (query?: RepoQueryIn) => { const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); if (!res.ok) { throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`); } - // must be manually typed - return res as unknown as Id extends undefined - ? ResponseObject - : ResponseObject; + return res as any; }, - update: async (input: Partial>, _id: PrimaryFieldType | undefined = id) => { + // @ts-ignore + update: async (input: Updateable, _id: PrimaryFieldType | undefined = id) => { if (!_id) { throw new Error("id is required"); } @@ -59,8 +78,9 @@ export const useEntity = < if (!res.ok) { throw new UseEntityApiError(res, `Failed to update entity "${entity}"`); } - return res; + return res as any; }, + // @ts-ignore _delete: async (_id: PrimaryFieldType | undefined = id) => { if (!_id) { throw new Error("id is required"); @@ -70,7 +90,7 @@ export const useEntity = < if (!res.ok) { throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`); } - return res; + return res as any; }, }; }; @@ -91,6 +111,19 @@ export function makeKey( ); } +interface UseEntityQueryReturn< + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined = undefined, + Data = Entity extends keyof DB ? Selectable : EntityData, + Return = Id extends undefined ? ResponseObject : ResponseObject, +> extends Omit, "mutate">, + Omit>, "read"> { + mutate: (id?: PrimaryFieldType) => Promise; + mutateRaw: SWRResponse["mutate"]; + api: Api["data"]; + key: string; +} + export const useEntityQuery = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, @@ -99,11 +132,11 @@ export const useEntityQuery = < id?: Id, query?: RepoQueryIn, options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }, -) => { +): UseEntityQueryReturn => { const api = useApi().data; const key = makeKey(api, entity as string, id, query); const { read, ...actions } = useEntity(entity, id); - const fetcher = () => read(query); + const fetcher = () => read(query ?? {}); type T = Awaited>; const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, { @@ -112,8 +145,8 @@ export const useEntityQuery = < ...options, }); - const mutateAll = async () => { - const entityKey = makeKey(api, entity as string); + const mutateFn = async (id?: PrimaryFieldType) => { + const entityKey = makeKey(api, entity as string, id); return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { revalidate: true, }); @@ -126,7 +159,7 @@ export const useEntityQuery = < // mutate all keys of entity by default if (options?.revalidateOnMutate !== false) { - await mutateAll(); + await mutateFn(); } return res; }; @@ -135,7 +168,8 @@ export const useEntityQuery = < return { ...swr, ...mapped, - mutate: mutateAll, + mutate: mutateFn, + // @ts-ignore mutateRaw: swr.mutate, api, key, @@ -144,8 +178,8 @@ export const useEntityQuery = < export async function mutateEntityCache< Entity extends keyof DB | string, - Data = Entity extends keyof DB ? Omit : EntityData, ->(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial) { + Data = Entity extends keyof DB ? DB[Entity] : EntityData, +>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial>) { function update(prev: any, partialNext: any) { if ( typeof prev !== "undefined" && @@ -176,28 +210,37 @@ export async function mutateEntityCache< ); } +interface UseEntityMutateReturn< + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined = undefined, + Data = Entity extends keyof DB ? DB[Entity] : EntityData, +> extends Omit>, "mutate"> { + mutate: Id extends undefined + ? (id: PrimaryFieldType, data: Partial>) => Promise + : (data: Partial>) => Promise; +} + export const useEntityMutate = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, - Data = Entity extends keyof DB ? Omit : EntityData, + Data = Entity extends keyof DB ? DB[Entity] : EntityData, >( entity: Entity, id?: Id, options?: SWRConfiguration, -) => { +): UseEntityMutateReturn => { const { data, ...$q } = useEntityQuery(entity, id, undefined, { ...options, enabled: false, }); const _mutate = id - ? (data) => mutateEntityCache($q.api, entity, id, data) - : (id, data) => mutateEntityCache($q.api, entity, id, data); + ? (data: Partial>) => mutateEntityCache($q.api, entity, id, data) + : (id: PrimaryFieldType, data: Partial>) => + mutateEntityCache($q.api, entity, id, data); return { ...$q, - mutate: _mutate as unknown as Id extends undefined - ? (id: PrimaryFieldType, data: Partial) => Promise - : (data: Partial) => Promise, - }; + mutate: _mutate, + } as any; }; diff --git a/app/src/ui/client/schema/data/use-bknd-data.ts b/app/src/ui/client/schema/data/use-bknd-data.ts index 391b62a..d9c4ca0 100644 --- a/app/src/ui/client/schema/data/use-bknd-data.ts +++ b/app/src/ui/client/schema/data/use-bknd-data.ts @@ -1,4 +1,4 @@ -import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; +import { TypeInvalidError, parse, transformObject } from "core/utils"; import { constructEntity } from "data"; import { type TAppDataEntity, @@ -13,6 +13,8 @@ import { import { useBknd } from "ui/client/bknd"; import type { TSchemaActions } from "ui/client/schema/actions"; import { bkndModals } from "ui/modals"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export function useBkndData() { const { config, app, schema, actions: bkndActions } = useBknd(); diff --git a/app/src/ui/components/canvas/panels/index.tsx b/app/src/ui/components/canvas/panels/index.tsx index 8d423ff..7fedb11 100644 --- a/app/src/ui/components/canvas/panels/index.tsx +++ b/app/src/ui/components/canvas/panels/index.tsx @@ -35,16 +35,14 @@ export function Panels({ children, ...props }: PanelsProps) { )} {props.zoom && ( - <> - - - - {percent}% - - - - - + + + + {percent}% + + + + )} {props.minimap && ( <> diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx index 480cb79..6c81434 100644 --- a/app/src/ui/components/code/CodeEditor.tsx +++ b/app/src/ui/components/code/CodeEditor.tsx @@ -1,12 +1,12 @@ import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { json } from "@codemirror/lang-json"; -import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid"; +import { html } from "@codemirror/lang-html"; import { useTheme } from "ui/client/use-theme"; export type CodeEditorProps = ReactCodeMirrorProps & { _extensions?: Partial<{ json: boolean; - liquid: LiquidCompletionConfig; + html: boolean; }>; }; @@ -31,7 +31,8 @@ export default function CodeEditor({ case "json": return json(); case "liquid": - return liquid(config); + case "html": + return html(config); } return undefined; }) diff --git a/app/src/ui/components/code/HtmlEditor.tsx b/app/src/ui/components/code/HtmlEditor.tsx new file mode 100644 index 0000000..1716f1c --- /dev/null +++ b/app/src/ui/components/code/HtmlEditor.tsx @@ -0,0 +1,23 @@ +import { Suspense, lazy } from "react"; +import { twMerge } from "tailwind-merge"; + +import type { CodeEditorProps } from "./CodeEditor"; +const CodeEditor = lazy(() => import("./CodeEditor")); + +export function HtmlEditor({ editable, ...props }: CodeEditorProps) { + return ( + + + + ); +} diff --git a/app/src/ui/components/code/LiquidJsEditor.tsx b/app/src/ui/components/code/LiquidJsEditor.tsx deleted file mode 100644 index d73c6f1..0000000 --- a/app/src/ui/components/code/LiquidJsEditor.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { Suspense, lazy } from "react"; -import { twMerge } from "tailwind-merge"; - -import type { CodeEditorProps } from "./CodeEditor"; -const CodeEditor = lazy(() => import("./CodeEditor")); - -const filters = [ - { label: "abs" }, - { label: "append" }, - { label: "array_to_sentence_string" }, - { label: "at_least" }, - { label: "at_most" }, - { label: "capitalize" }, - { label: "ceil" }, - { label: "cgi_escape" }, - { label: "compact" }, - { label: "concat" }, - { label: "date" }, - { label: "date_to_long_string" }, - { label: "date_to_rfc822" }, - { label: "date_to_string" }, - { label: "date_to_xmlschema" }, - { label: "default" }, - { label: "divided_by" }, - { label: "downcase" }, - { label: "escape" }, - { label: "escape_once" }, - { label: "find" }, - { label: "find_exp" }, - { label: "first" }, - { label: "floor" }, - { label: "group_by" }, - { label: "group_by_exp" }, - { label: "inspect" }, - { label: "join" }, - { label: "json" }, - { label: "jsonify" }, - { label: "last" }, - { label: "lstrip" }, - { label: "map" }, - { label: "minus" }, - { label: "modulo" }, - { label: "newline_to_br" }, - { label: "normalize_whitespace" }, - { label: "number_of_words" }, - { label: "plus" }, - { label: "pop" }, - { label: "push" }, - { label: "prepend" }, - { label: "raw" }, - { label: "remove" }, - { label: "remove_first" }, - { label: "remove_last" }, - { label: "replace" }, - { label: "replace_first" }, - { label: "replace_last" }, - { label: "reverse" }, - { label: "round" }, - { label: "rstrip" }, - { label: "shift" }, - { label: "size" }, - { label: "slice" }, - { label: "slugify" }, - { label: "sort" }, - { label: "sort_natural" }, - { label: "split" }, - { label: "strip" }, - { label: "strip_html" }, - { label: "strip_newlines" }, - { label: "sum" }, - { label: "times" }, - { label: "to_integer" }, - { label: "truncate" }, - { label: "truncatewords" }, - { label: "uniq" }, - { label: "unshift" }, - { label: "upcase" }, - { label: "uri_escape" }, - { label: "url_decode" }, - { label: "url_encode" }, - { label: "where" }, - { label: "where_exp" }, - { label: "xml_escape" }, -]; - -const tags = [ - { label: "assign" }, - { label: "capture" }, - { label: "case" }, - { label: "comment" }, - { label: "cycle" }, - { label: "decrement" }, - { label: "echo" }, - { label: "else" }, - { label: "elsif" }, - { label: "for" }, - { label: "if" }, - { label: "include" }, - { label: "increment" }, - { label: "layout" }, - { label: "liquid" }, - { label: "raw" }, - { label: "render" }, - { label: "tablerow" }, - { label: "unless" }, - { label: "when" }, -]; - -export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) { - return ( - - - - ); -} diff --git a/app/src/ui/components/form/json-schema/fields/LiquidJsField.tsx b/app/src/ui/components/form/json-schema/fields/HtmlField.tsx similarity index 71% rename from app/src/ui/components/form/json-schema/fields/LiquidJsField.tsx rename to app/src/ui/components/form/json-schema/fields/HtmlField.tsx index 830bb6b..0bb3a46 100644 --- a/app/src/ui/components/form/json-schema/fields/LiquidJsField.tsx +++ b/app/src/ui/components/form/json-schema/fields/HtmlField.tsx @@ -1,9 +1,9 @@ import type { FieldProps } from "@rjsf/utils"; -import { LiquidJsEditor } from "../../../code/LiquidJsEditor"; import { Label } from "../templates/FieldTemplate"; +import { HtmlEditor } from "ui/components/code/HtmlEditor"; // @todo: move editor to lazy loading component -export default function LiquidJsField({ +export default function HtmlField({ formData, onChange, disabled, @@ -20,7 +20,7 @@ export default function LiquidJsField({ return (
); } diff --git a/app/src/ui/components/form/json-schema/fields/index.ts b/app/src/ui/components/form/json-schema/fields/index.ts index 6b19481..6c499c6 100644 --- a/app/src/ui/components/form/json-schema/fields/index.ts +++ b/app/src/ui/components/form/json-schema/fields/index.ts @@ -1,10 +1,10 @@ import JsonField from "./JsonField"; -import LiquidJsField from "./LiquidJsField"; import MultiSchemaField from "./MultiSchemaField"; +import HtmlField from "./HtmlField"; export const fields = { AnyOfField: MultiSchemaField, OneOfField: MultiSchemaField, JsonField, - LiquidJsField, + HtmlField, }; diff --git a/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx b/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx index cc384dd..1e5e80f 100644 --- a/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx +++ b/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx @@ -55,7 +55,7 @@ export default function BaseInputTemplate< ...getInputProps(schema, type, options), }; - let inputValue; + let inputValue: any; if (inputProps.type === "number" || inputProps.type === "integer") { inputValue = value || value === 0 ? value : ""; } else { @@ -68,11 +68,11 @@ export default function BaseInputTemplate< [onChange, options], ); const _onBlur = useCallback( - ({ target }: FocusEvent) => onBlur(id, target && target.value), + ({ target }: FocusEvent) => onBlur(id, target?.value), [onBlur, id], ); const _onFocus = useCallback( - ({ target }: FocusEvent) => onFocus(id, target && target.value), + ({ target }: FocusEvent) => onFocus(id, target?.value), [onFocus, id], ); diff --git a/app/src/ui/components/form/native-form/NativeForm.tsx b/app/src/ui/components/form/native-form/NativeForm.tsx index 066bbee..15b6d5c 100644 --- a/app/src/ui/components/form/native-form/NativeForm.tsx +++ b/app/src/ui/components/form/native-form/NativeForm.tsx @@ -28,6 +28,7 @@ export type NativeFormProps = { ctx: { event: FormEvent }, ) => Promise | void; onError?: (errors: InputError[]) => void; + disableSubmitOnError?: boolean; onChange?: ( data: any, ctx: { event: ChangeEvent; key: string; value: any; errors: InputError[] }, @@ -50,6 +51,7 @@ export function NativeForm({ onSubmitInvalid, onError, clean, + disableSubmitOnError = true, ...props }: NativeFormProps) { const formRef = useRef(null); @@ -74,7 +76,7 @@ export function NativeForm({ onError?.(errors); }, [errors]); - const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => { + const validateElement = (el: InputElement | null, opts?: { report?: boolean }) => { if (props.noValidate || !el || !("name" in el)) return; const errorElement = formRef.current?.querySelector( errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]`, @@ -104,9 +106,9 @@ export function NativeForm({ } return; - }); + }; - const validate = useEvent((opts?: { report?: boolean }) => { + const validate = (opts?: { report?: boolean }) => { if (!formRef.current || props.noValidate) return []; const errors: InputError[] = []; @@ -118,10 +120,20 @@ export function NativeForm({ } }); - return errors; - }); + if (disableSubmitOnError) { + formRef.current.querySelectorAll("[type=submit]").forEach((submit) => { + if (errors.length > 0) { + submit.setAttribute("disabled", "disabled"); + } else { + submit.removeAttribute("disabled"); + } + }); + } - const getFormValues = useEvent(() => { + return errors; + }; + + const getFormValues = () => { if (!formRef.current) return {}; const formData = new FormData(formRef.current); @@ -148,15 +160,15 @@ export function NativeForm({ if (typeof clean === "undefined") return obj; return cleanObject(obj, clean === true ? undefined : clean); - }); + }; - const handleChange = useEvent(async (e: ChangeEvent) => { + const handleChange = async (e: ChangeEvent) => { const form = formRef.current; if (!form) return; const target = getFormTarget(e); if (!target) return; - if (validateOn === "change") { + if (validateOn === "change" || errors.length > 0) { validateElement(target, { report: true }); } @@ -168,9 +180,9 @@ export function NativeForm({ errors, }); } - }); + }; - const handleSubmit = useEvent(async (e: FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); const form = formRef.current; if (!form) return; @@ -186,9 +198,9 @@ export function NativeForm({ } else { form.submit(); } - }); + }; - const handleKeyDown = useEvent((e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (!formRef.current) return; // if is enter key, submit is disabled, report errors @@ -198,7 +210,7 @@ export function NativeForm({ formRef.current.reportValidity(); } } - }); + }; return (
{ async validate(schema: TSchema, data: any) { diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 57d6c48..bc2cb08 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -1,20 +1,21 @@ import type { DB } from "core"; import { type ComponentPropsWithRef, - type ComponentPropsWithoutRef, + createContext, type ReactNode, type RefObject, - memo, + useCallback, + useContext, useEffect, + useMemo, useRef, useState, } from "react"; -import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb"; -import { twMerge } from "tailwind-merge"; -import { IconButton } from "ui/components/buttons/IconButton"; -import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { type FileWithPath, useDropzone } from "./use-dropzone"; -import { formatNumber } from "core/utils"; +import { checkMaxReached } from "./helper"; +import { DropzoneInner } from "./DropzoneInner"; +import { createDropzoneStore } from "ui/elements/media/dropzone-state"; +import { useStore } from "zustand"; export type FileState = { body: FileWithPath | string; @@ -29,27 +30,23 @@ export type FileState = { export type FileStateWithData = FileState & { data: DB["media"] }; export type DropzoneRenderProps = { + store: ReturnType; wrapperRef: RefObject; inputProps: ComponentPropsWithRef<"input">; - state: { - files: FileState[]; - isOver: boolean; - isOverAccepted: boolean; - showPlaceholder: boolean; - }; actions: { - uploadFile: (file: FileState) => Promise; - deleteFile: (file: FileState) => Promise; + uploadFile: (file: { path: string }) => Promise; + deleteFile: (file: { path: string }) => Promise; openFileInput: () => void; }; - onClick?: (file: FileState) => void; + showPlaceholder: boolean; + onClick?: (file: { path: string }) => void; footer?: ReactNode; dropzoneProps: Pick; }; export type DropzoneProps = { - getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string }; - handleDelete: (file: FileState) => Promise; + getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string }; + handleDelete: (file: { path: string }) => Promise; initialItems?: FileState[]; flow?: "start" | "end"; maxItems?: number; @@ -57,7 +54,7 @@ export type DropzoneProps = { overwrite?: boolean; autoUpload?: boolean; onRejected?: (files: FileWithPath[]) => void; - onDeleted?: (file: FileState) => void; + onDeleted?: (file: { path: string }) => void; onUploaded?: (files: FileStateWithData[]) => void; onClick?: (file: FileState) => void; placeholder?: { @@ -65,7 +62,7 @@ export type DropzoneProps = { text?: string; }; footer?: ReactNode; - children?: (props: DropzoneRenderProps) => ReactNode; + children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode); }; function handleUploadError(e: unknown) { @@ -94,30 +91,21 @@ export function Dropzone({ onClick, footer, }: DropzoneProps) { - const [files, setFiles] = useState(initialItems); - const [uploading, setUploading] = useState(false); + const store = useRef(createDropzoneStore()).current; + const files = useStore(store, (state) => state.files); + const setFiles = useStore(store, (state) => state.setFiles); + const getFilesLength = useStore(store, (state) => state.getFilesLength); + const setUploading = useStore(store, (state) => state.setUploading); + const setIsOver = useStore(store, (state) => state.setIsOver); + const uploading = useStore(store, (state) => state.uploading); + const setFileState = useStore(store, (state) => state.setFileState); + const removeFile = useStore(store, (state) => state.removeFile); const inputRef = useRef(null); - const [isOverAccepted, setIsOverAccepted] = useState(false); - function isMaxReached(added: number): boolean { - if (!maxItems) { - console.log("maxItems is undefined, never reached"); - return false; - } - - const current = files.length; - const remaining = maxItems - current; - console.log("isMaxReached", { added, current, remaining, maxItems, overwrite }); - - // if overwrite is set, but added is bigger than max items - if (overwrite) { - console.log("added > maxItems, stop?", added > maxItems); - return added > maxItems; - } - console.log("remaining > added, stop?", remaining > added); - // or remaining doesn't suffice, stop - return added > remaining; - } + useEffect(() => { + // @todo: potentially keep pending ones + setFiles(() => initialItems); + }, [initialItems.length]); function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean { const items = Array.isArray(i) ? i : [i]; @@ -135,31 +123,41 @@ export function Dropzone({ }); } - const { isOver, handleFileInputChange, ref } = useDropzone({ + const { handleFileInputChange, ref } = useDropzone({ onDropped: (newFiles: FileWithPath[]) => { + console.log("onDropped", newFiles); if (!isAllowed(newFiles)) return; - let to_drop = 0; const added = newFiles.length; - if (maxItems) { - if (isMaxReached(added)) { - if (onRejected) { - onRejected(newFiles); - } else { - console.warn("maxItems reached"); + // Check max files using the current state, not a stale closure + setFiles((currentFiles) => { + let to_drop = 0; + + if (maxItems) { + const $max = checkMaxReached({ + maxItems, + overwrite, + added, + current: currentFiles.length, + }); + + if ($max.reject) { + if (onRejected) { + onRejected(newFiles); + } else { + console.warn("maxItems reached"); + } + + // Return current state unchanged if rejected + return currentFiles; } - return; + to_drop = $max.to_drop; } - to_drop = added; - } - - console.log("files", newFiles, { to_drop }); - setFiles((prev) => { // drop amount calculated - const _prev = prev.slice(to_drop); + const _prev = currentFiles.slice(to_drop); // prep new files const currentPaths = _prev.map((f) => f.path); @@ -175,24 +173,35 @@ export function Dropzone({ progress: 0, })); - return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; - }); + const updatedFiles = + flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; - if (autoUpload) { - setUploading(true); - } + if (autoUpload && filteredFiles.length > 0) { + // Schedule upload for the next tick to ensure state is updated + setTimeout(() => setUploading(true), 0); + } + + return updatedFiles; + }); }, onOver: (items) => { if (!isAllowed(items)) { - setIsOverAccepted(false); + setIsOver(true, false); return; } - const max_reached = isMaxReached(items.length); - setIsOverAccepted(!max_reached); + const current = getFilesLength(); + const $max = checkMaxReached({ + maxItems, + overwrite, + added: items.length, + current, + }); + console.log("--files in onOver", current, $max); + setIsOver(true, !$max.reject); }, onLeave: () => { - setIsOverAccepted(false); + setIsOver(false, false); }, }); @@ -223,40 +232,6 @@ export function Dropzone({ } }, [uploading]); - function setFileState(path: string, state: FileState["state"], progress?: number) { - setFiles((prev) => - prev.map((f) => { - //console.log("compare", f.path, path, f.path === path); - if (f.path === path) { - return { - ...f, - state, - progress: progress ?? f.progress, - }; - } - return f; - }), - ); - } - - function replaceFileState(prevPath: string, newState: Partial) { - setFiles((prev) => - prev.map((f) => { - if (f.path === prevPath) { - return { - ...f, - ...newState, - }; - } - return f; - }), - ); - } - - function removeFileFromState(path: string) { - setFiles((prev) => prev.filter((f) => f.path !== path)); - } - function uploadFileProgress(file: FileState): Promise { return new Promise((resolve, reject) => { if (!file.body) { @@ -273,7 +248,7 @@ export function Dropzone({ return; } - const uploadInfo = getUploadInfo(file.body); + const uploadInfo = getUploadInfo({ path: file.body.path! }); console.log("dropzone:uploadInfo", uploadInfo); const { url, headers, method = "POST" } = uploadInfo; @@ -322,7 +297,7 @@ export function Dropzone({ state: "uploaded", }; - replaceFileState(file.path, newState); + setFileState(file.path, newState.state); resolve({ ...response, ...file, ...newState }); } catch (e) { setFileState(file.path, "uploaded", 1); @@ -349,7 +324,7 @@ export function Dropzone({ }); } - async function deleteFile(file: FileState) { + const deleteFile = useCallback(async (file: FileState) => { console.log("deleteFile", file); switch (file.state) { case "uploaded": @@ -358,232 +333,97 @@ export function Dropzone({ console.log('setting state to "deleting"', file); setFileState(file.path, "deleting"); await handleDelete(file); - removeFileFromState(file.path); + removeFile(file.path); onDeleted?.(file); } break; } - } + }, []); - async function uploadFile(file: FileState) { + const uploadFile = useCallback(async (file: FileState) => { const result = await uploadFileProgress(file); onUploaded?.([result]); - } + }, []); - const openFileInput = () => inputRef.current?.click(); - const showPlaceholder = Boolean( - placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems), + const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]); + const showPlaceholder = useMemo( + () => + Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)), + [placeholder, maxItems, files.length], ); - const renderProps: DropzoneRenderProps = { - wrapperRef: ref, - inputProps: { - ref: inputRef, - type: "file", - multiple: !maxItems || maxItems > 1, - onChange: handleFileInputChange, - }, - state: { - files, - isOver, - isOverAccepted, + const renderProps = useMemo( + () => ({ + store, + wrapperRef: ref, + inputProps: { + ref: inputRef, + type: "file", + multiple: !maxItems || maxItems > 1, + onChange: handleFileInputChange, + }, showPlaceholder, - }, - actions: { - uploadFile, - deleteFile, - openFileInput, - }, - dropzoneProps: { - maxItems, - placeholder, - autoUpload, - flow, - }, - onClick, - footer, - }; + actions: { + uploadFile, + deleteFile, + openFileInput, + }, + dropzoneProps: { + maxItems, + placeholder, + autoUpload, + flow, + }, + onClick, + footer, + }), + [maxItems, flow, placeholder, autoUpload, footer], + ) as unknown as DropzoneRenderProps; - return children ? children(renderProps) : ; + return ( + + {children ? ( + typeof children === "function" ? ( + children(renderProps) + ) : ( + children + ) + ) : ( + + )} + + ); } -const DropzoneInner = ({ - wrapperRef, - inputProps, - state: { files, isOver, isOverAccepted, showPlaceholder }, - actions: { uploadFile, deleteFile, openFileInput }, - dropzoneProps: { placeholder, flow }, - onClick, - footer, -}: DropzoneRenderProps) => { - const Placeholder = showPlaceholder && ( - - ); +const DropzoneContext = createContext(undefined!); - async function uploadHandler(file: FileState) { - try { - return await uploadFile(file); - } catch (e) { - handleUploadError(e); - } - } +export function useDropzoneContext() { + return useContext(DropzoneContext); +} - return ( -
-
- -
-
-
- {flow === "start" && Placeholder} - {files.map((file) => ( - - ))} - {flow === "end" && Placeholder} - {footer} -
-
-
- ); +export const useDropzoneState = () => { + const { store } = useDropzoneContext(); + const files = useStore(store, (state) => state.files); + const isOver = useStore(store, (state) => state.isOver); + const isOverAccepted = useStore(store, (state) => state.isOverAccepted); + + return { + files, + isOver, + isOverAccepted, + }; }; -const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { - return ( -
- {text} -
- ); -}; - -export type PreviewComponentProps = { - file: FileState; - fallback?: (props: { file: FileState }) => ReactNode; - className?: string; - onClick?: () => void; - onTouchStart?: () => void; -}; - -const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => { - if (file.type.startsWith("image/")) { - return ; - } - - if (file.type.startsWith("video/")) { - return ; - } - - return fallback ? fallback({ file }) : null; -}; -export const PreviewWrapperMemoized = memo( - Wrapper, - (prev, next) => prev.file.path === next.file.path, -); - -type PreviewProps = { - file: FileState; - handleUpload: (file: FileState) => Promise; - handleDelete: (file: FileState) => Promise; - onClick?: (file: FileState) => void; -}; -const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => { - const dropdownItems = [ - file.state === "uploaded" && - typeof file.body === "string" && { - label: "Open", - icon: TbExternalLink, - onClick: () => { - window.open(file.body as string, "_blank"); - }, - }, - ["initial", "uploaded"].includes(file.state) && { - label: "Delete", - destructive: true, - icon: TbTrash, - onClick: () => handleDelete(file), - }, - ["initial", "pending"].includes(file.state) && { - label: "Upload", - icon: TbUpload, - onClick: () => handleUpload(file), - }, - ] satisfies (DropdownItem | boolean)[]; - - return ( -
{ - if (onClick) { - onClick(file); - } - }} - > -
- - - -
- {file.state === "uploading" && ( -
-
-
- )} -
- -
-
-

{file.name}

-
- {file.type} - {formatNumber.fileSize(file.size)} -
-
-
- ); -}; - -const ImagePreview = ({ - file, - ...props -}: { file: FileState } & ComponentPropsWithoutRef<"img">) => { - const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); - return ; -}; - -const VideoPreview = ({ - file, - ...props -}: { file: FileState } & ComponentPropsWithoutRef<"video">) => { - const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); - return