mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Release 0.12 (#143)
* changed tb imports * cleanup: replace console.log/warn with $console, remove commented-out code Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage. * ts: enable incremental * fix imports in test files reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes. * added media permissions (#142) * added permissions support for media module introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic. * fix: handle token absence in getUploadHeaders and add tests for transport modes ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options. * remove console.log on DropzoneContainer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add bcrypt and refactored auth resolve (#147) * reworked auth architecture with improved password handling and claims Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly. * fix strategy forms handling, add register route and hidden fields Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components. * refactored auth handling to support bcrypt, extracted user pool * update email regex to allow '+' and '_' characters * update test stub password for AppAuth spec * update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool * rework strategies to extend a base class instead of interface * added simple bcrypt test * add validation logs and improve data validation handling (#157) Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation. * modify MediaApi to support custom fetch implementation, defaults to native fetch (#158) * modify MediaApi to support custom fetch implementation, defaults to native fetch added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided. * fix tests and improve api fetcher types * update admin basepath handling and window context integration (#155) Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context. * trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160) * refactor error handling in authenticator and password strategy (#161) made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency. * add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162) Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage. * update dependencies in package.json (#156) moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries. * update imports to adjust nodeTestRunner path and remove unused export (#163) updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now. * fix sync events not awaited (#164) * refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165) Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability. * replace LiquidJs rendering with simplified renderer (#167) * replace LiquidJs rendering with simplified renderer Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags. * remove liquid js from package json * feat/cli-generate-types (#166) * init types generation * update type generation for entities and fields Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files. * update type generation code and CLI option description removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description. * fix json schema field type generation * reworked system entities to prevent recursive types * reworked system entities to prevent recursive types * remove unused object function * types: use number instead of Generated * update data hooks and api types * update data hooks and api types * update data hooks and api types * update data hooks and api types --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/// <reference types="@types/bun" />
|
||||
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<any>, filename: string) {
|
||||
|
||||
@@ -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<T> = new (...args: any[]) => T;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Output extends string> 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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 extends TSchema>(schema: Schema) {
|
||||
getSchema() {
|
||||
return schema;
|
||||
}
|
||||
toJSON() {
|
||||
override toJSON() {
|
||||
return this.config;
|
||||
}
|
||||
useForceParse() {
|
||||
override useForceParse() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,11 @@ import { omitKeys } from "core/utils";
|
||||
|
||||
export type TApiUser = SafeUser;
|
||||
|
||||
export type ApiFetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Response | Promise<Response>;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||
@@ -37,7 +38,7 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
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'");
|
||||
}
|
||||
|
||||
@@ -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<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
@@ -13,7 +14,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
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();
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
...config,
|
||||
onBuilt: async (app) => {
|
||||
registerAsyncsExecutionContext(app, ctx.ctx);
|
||||
config.onBuilt?.(app);
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
},
|
||||
ctx.env,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||
@@ -62,7 +63,7 @@ export function serve<Env = NodeEnv>(
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<typeof AppAuth.usersFields>;
|
||||
declare module "core" {
|
||||
interface Users extends AppEntity, UserFieldSchema {}
|
||||
interface DB {
|
||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
||||
users: Users;
|
||||
}
|
||||
}
|
||||
|
||||
type AuthSchema = Static<typeof authConfigSchema>;
|
||||
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
@@ -31,12 +25,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
cache: Record<string, any> = {};
|
||||
_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<typeof authConfigSchema> {
|
||||
}
|
||||
});
|
||||
|
||||
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<typeof authConfigSchema> {
|
||||
|
||||
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<typeof authConfigSchema> {
|
||||
return this.ctx.em as any;
|
||||
}
|
||||
|
||||
private async resolveUser(
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange,
|
||||
): Promise<any> {
|
||||
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<typeof authConfigSchema> {
|
||||
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<typeof authConfigSchema> {
|
||||
...this.authenticator.toJSON(secrets),
|
||||
strategies: transformObject(strategies, (strategy) => ({
|
||||
enabled: this.isStrategyEnabled(strategy),
|
||||
type: strategy.getType(),
|
||||
config: strategy.toJSON(secrets),
|
||||
...strategy.toJSON(secrets),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
83
app/src/auth/AppUserPool.ts
Normal file
83
app/src/auth/AppUserPool.ts
Normal file
@@ -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<Omit<User, "id">>) {
|
||||
$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<User, "id"> = {
|
||||
...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?
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof sign>[0];
|
||||
@@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const;
|
||||
export type StrategyActionName = (typeof strategyActions)[number];
|
||||
export type StrategyAction<S extends TObject = TObject> = {
|
||||
schema: S;
|
||||
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||
};
|
||||
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
|
||||
|
||||
// @todo: add schema to interface to ensure proper inference
|
||||
// @todo: add tests (e.g. invalid strategy_value)
|
||||
export interface Strategy {
|
||||
getController: (auth: Authenticator) => Hono<any>;
|
||||
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<User, "password">;
|
||||
export type SafeUser = Omit<User, "strategy_value">;
|
||||
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
||||
export type AuthResponse = { user: SafeUser; token: string };
|
||||
|
||||
export interface UserPool<Fields = "id" | "email" | "username"> {
|
||||
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
|
||||
create: (user: CreateUser) => Promise<User | undefined>;
|
||||
export interface UserPool {
|
||||
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>;
|
||||
create: (strategy: string, user: CreateUser) => Promise<User>;
|
||||
}
|
||||
|
||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||
@@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({
|
||||
|
||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||
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<SafeUser | undefined>;
|
||||
opts?: AuthResolveOptions,
|
||||
) => Promise<ProfileExchange | undefined>;
|
||||
type AuthClaims = SafeUser & {
|
||||
iat: number;
|
||||
iss?: string;
|
||||
@@ -113,33 +115,117 @@ type AuthClaims = SafeUser & {
|
||||
};
|
||||
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
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<AuthResponse> {
|
||||
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
||||
const user = await this.userResolver(action, strategy, identifier, profile);
|
||||
profile: Partial<SafeUser>,
|
||||
verify: (user: User) => Promise<void>,
|
||||
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<void>,
|
||||
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<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
// @todo: add jwt tests
|
||||
async jwt(_user: Omit<User, "password">): Promise<string> {
|
||||
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
|
||||
const user = pick(_user, this.config.jwt.fields);
|
||||
|
||||
const payload: JWTPayload = {
|
||||
@@ -184,6 +270,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
|
||||
}
|
||||
|
||||
async safeAuthResponse(_user: User): Promise<AuthResponse> {
|
||||
const user = pick(_user, this.config.jwt.fields) as SafeUser;
|
||||
return {
|
||||
user,
|
||||
token: await this.jwt(user),
|
||||
};
|
||||
}
|
||||
|
||||
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
||||
try {
|
||||
const payload = await verify(
|
||||
@@ -225,7 +319,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = 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<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
private async setAuthCookie(c: Context<ServerEnv>, 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<Strategies extends Record<string, Strategy> = 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<Strategies extends Record<string, Strategy> = 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<SafeUser | undefined> {
|
||||
let token: string | undefined;
|
||||
@@ -341,13 +405,3 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createStrategyAction<S extends TObject>(
|
||||
schema: S,
|
||||
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>,
|
||||
) {
|
||||
return {
|
||||
schema,
|
||||
preprocess,
|
||||
} as StrategyAction<S>;
|
||||
}
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
/*export type PasswordStrategyOptions2 = {
|
||||
hashing?: "plain" | "bcrypt" | "sha256";
|
||||
};*/
|
||||
|
||||
export class PasswordStrategy implements Strategy {
|
||||
private options: PasswordStrategyOptions;
|
||||
export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
constructor(config: Partial<PasswordStrategyOptions> = {}) {
|
||||
super(config as any, "password", "password", "form");
|
||||
|
||||
constructor(options: Partial<PasswordStrategyOptions> = {}) {
|
||||
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<any> {
|
||||
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<boolean> {
|
||||
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<any> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
63
app/src/auth/authenticate/strategies/Strategy.ts
Normal file
63
app/src/auth/authenticate/strategies/Strategy.ts
Normal file
@@ -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<Schema extends TSchema = TSchema> {
|
||||
protected actions: StrategyActions = {};
|
||||
|
||||
constructor(
|
||||
protected config: Static<Schema>,
|
||||
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<Schema>;
|
||||
}
|
||||
|
||||
protected registerAction<S extends TObject = TObject>(
|
||||
name: StrategyActionName,
|
||||
schema: S,
|
||||
preprocess: StrategyAction<S>["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<Schema> | {} | undefined } {
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: secrets ? this.config : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getActions(): StrategyActions {
|
||||
return this.actions;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
|
||||
export * as issuers from "./oauth/issuers";
|
||||
|
||||
export {
|
||||
PasswordStrategy,
|
||||
type PasswordStrategyOptions,
|
||||
PasswordStrategy,
|
||||
OAuthStrategy,
|
||||
OAuthCallbackException,
|
||||
CustomOAuthStrategy,
|
||||
|
||||
@@ -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<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
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<typeof oauthSchemaCustom>;
|
||||
@@ -62,6 +54,11 @@ export type IssuerConfig<UserInfo = any> = {
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T extends object, K extends keyof T> = Required<Pick<T, K>> & 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<typeof schemaProvided> {
|
||||
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<UserProfile> {
|
||||
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<void> => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
|
||||
config: Omit<IssuerConfig, "profile">,
|
||||
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<GithubUserInfo> = {
|
||||
},
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -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<ServerEnv> | 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<string, Permission>);
|
||||
registerPermissions(permissions: Permission[]);
|
||||
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
|
||||
export { sha256 } from "./utils/hash";
|
||||
export {
|
||||
type ProfileExchange,
|
||||
type Strategy,
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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)}`));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
1
app/src/cli/commands/types/index.ts
Normal file
1
app/src/cli/commands/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
37
app/src/cli/commands/types/types.ts
Normal file
37
app/src/cli/commands/types/types.ts
Normal file
@@ -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 <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());
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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<string, string> }) {
|
||||
const stdio = opts?.silent ? "pipe" : "inherit";
|
||||
const output = execSync(command, {
|
||||
|
||||
@@ -29,7 +29,6 @@ export class AwsClient extends Aws4fetchClient {
|
||||
}
|
||||
|
||||
getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
|
||||
|
||||
export interface AppEntity<IdType extends number = number> {
|
||||
id: PrimaryFieldType<IdType>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
// make sure to make unknown as "any"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,10 @@ export abstract class Event<Params = any, Returning = void> {
|
||||
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<Params, Returning> | void {
|
||||
throw new EventReturnedWithoutValidation(this as any, value);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<any, any, any>;
|
||||
|
||||
@@ -73,6 +73,7 @@ export class SchemaObject<Schema extends TObject> {
|
||||
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<Schema extends TObject> {
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : 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<Schema extends TObject> {
|
||||
}
|
||||
});
|
||||
});
|
||||
//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<Schema extends TObject> {
|
||||
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<Schema extends TObject> {
|
||||
}
|
||||
}
|
||||
|
||||
//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<Schema extends TObject> {
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : 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<Schema extends TObject> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
70
app/src/core/template/SimpleRenderer.spec.ts
Normal file
70
app/src/core/template/SimpleRenderer.spec.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | Record<string, string>>;
|
||||
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<string, any> = {},
|
||||
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<Given extends TemplateTypes>(template: Given): Promise<Given> {
|
||||
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<Given extends TemplateTypes = TemplateTypes>(template: Given): Promise<Given> {
|
||||
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<string> {
|
||||
//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<TemplateObject> {
|
||||
|
||||
@@ -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: <explanation>
|
||||
console.log(indents, context, time, ...args);
|
||||
|
||||
this.last = now;
|
||||
|
||||
@@ -4,7 +4,6 @@ import weekOfYear from "dayjs/plugin/weekOfYear.js";
|
||||
declare module "dayjs" {
|
||||
interface Dayjs {
|
||||
week(): number;
|
||||
|
||||
week(value: number): dayjs.Dayjs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<any>): Promise<File> {
|
||||
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<any>): Promise<File> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
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: <explanation>
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Schema extends TSchema = TSchema>(
|
||||
export function parse<Schema extends tb.TSchema = tb.TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<Static<Schema>>,
|
||||
data: RecursivePartial<tb.Static<Schema>>,
|
||||
options?: ParseOptions,
|
||||
): Static<Schema> {
|
||||
): tb.Static<Schema> {
|
||||
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
|
||||
if (options?.useDefaults === false) {
|
||||
return data as Static<typeof schema>;
|
||||
return data as tb.Static<typeof schema>;
|
||||
}
|
||||
|
||||
// this is important as defaults are expected
|
||||
return Default(schema, data as any) as Static<Schema>;
|
||||
return Default(schema, data as any) as tb.Static<Schema>;
|
||||
}
|
||||
|
||||
const parsed = options?.useDefaults === false ? data : Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
options?.skipMark !== true && mark(parsed, true);
|
||||
return parsed as Static<typeof schema>;
|
||||
return parsed as tb.Static<typeof schema>;
|
||||
} 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<Schema extends TSchema = TSchema>(
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
export function parseDecode<Schema extends TSchema = TSchema>(
|
||||
export function parseDecode<Schema extends tb.TSchema = tb.TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<StaticDecode<Schema>>,
|
||||
): StaticDecode<Schema> {
|
||||
//console.log("parseDecode", schema, data);
|
||||
data: RecursivePartial<tb.StaticDecode<Schema>>,
|
||||
): tb.StaticDecode<Schema> {
|
||||
const parsed = Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
return parsed as StaticDecode<typeof schema>;
|
||||
return parsed as tb.StaticDecode<typeof schema>;
|
||||
}
|
||||
//console.log("errors", ...Errors(schema, data));
|
||||
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
export function strictParse<Schema extends TSchema = TSchema>(
|
||||
export function strictParse<Schema extends tb.TSchema = tb.TSchema>(
|
||||
schema: Schema,
|
||||
data: Static<Schema>,
|
||||
data: tb.Static<Schema>,
|
||||
options?: ParseOptions,
|
||||
): Static<Schema> {
|
||||
): tb.Static<Schema> {
|
||||
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 = <const T extends readonly string[]>(values: T, options?: StringOptions) =>
|
||||
Type.Unsafe<T[number]>({
|
||||
[Kind]: "StringEnum",
|
||||
export const StringEnum = <const T extends readonly string[]>(
|
||||
values: T,
|
||||
options?: tb.StringOptions,
|
||||
) =>
|
||||
tb.Type.Unsafe<T[number]>({
|
||||
[tb.Kind]: "StringEnum",
|
||||
type: "string",
|
||||
enum: values,
|
||||
...options,
|
||||
@@ -162,45 +155,47 @@ export const StringEnum = <const T extends readonly string[]>(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 = <T extends TSchema>(properties: T, options?: ObjectOptions) =>
|
||||
Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord<
|
||||
TString,
|
||||
export const StringRecord = <T extends tb.TSchema>(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 = <T extends TLiteralValue = TLiteralValue>(value: T, options?: SchemaOptions) =>
|
||||
Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral<T>;
|
||||
export const Const = <T extends tb.TLiteralValue = tb.TLiteralValue>(
|
||||
value: T,
|
||||
options?: tb.SchemaOptions,
|
||||
) =>
|
||||
tb.Type.Literal(value, {
|
||||
...options,
|
||||
default: value,
|
||||
const: value,
|
||||
readOnly: true,
|
||||
}) as tb.TLiteral<T>;
|
||||
|
||||
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 = <T extends tb.TProperties>(
|
||||
properties: T,
|
||||
options?: tb.ObjectOptions,
|
||||
): tb.TObject<T> => 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 };
|
||||
|
||||
@@ -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<DataApiOptions> {
|
||||
}
|
||||
}
|
||||
|
||||
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
readOne<E extends keyof DB | string>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
||||
["entity", entity as any, id],
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
readOneBy<E extends keyof DB | string>(
|
||||
entity: E,
|
||||
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
|
||||
return this.readMany(entity, {
|
||||
...query,
|
||||
@@ -48,10 +51,8 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
|
||||
}
|
||||
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: RepoQueryIn = {},
|
||||
) {
|
||||
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||
|
||||
const input = query ?? this.options.defaultQuery;
|
||||
@@ -64,68 +65,70 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
return this.post<T>(["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<E extends keyof DB | string, R extends keyof DB | string>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
reference: R,
|
||||
query: RepoQueryIn = {},
|
||||
) {
|
||||
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
["entity", entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery,
|
||||
);
|
||||
}
|
||||
|
||||
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
createOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Omit<Data, "id">,
|
||||
input: Insertable<Input>,
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Omit<Data, "id">[],
|
||||
input: Insertable<Input>[],
|
||||
) {
|
||||
if (!input || !Array.isArray(input) || input.length === 0) {
|
||||
throw new Error("input is required");
|
||||
}
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
input: Partial<Omit<Data, "id">>,
|
||||
input: Updateable<Input>,
|
||||
) {
|
||||
if (!id) throw new Error("ID is required");
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
|
||||
}
|
||||
|
||||
updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
where: RepoQueryIn["where"],
|
||||
update: Partial<Omit<Data, "id">>,
|
||||
update: Updateable<Input>,
|
||||
) {
|
||||
this.requireObjectSet(where);
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
|
||||
update,
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
) {
|
||||
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
|
||||
if (!id) throw new Error("ID is required");
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
|
||||
}
|
||||
|
||||
deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
where: RepoQueryIn["where"],
|
||||
) {
|
||||
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
|
||||
this.requireObjectSet(where);
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HH extends 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 });
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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<typeof fieldsSchema>;
|
||||
export type TAppDataEntityFields = Static<typeof entityFields>;
|
||||
|
||||
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<typeof entitiesSchema>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<TBD extends object = DefaultDB> {
|
||||
|
||||
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<TBD extends object = DefaultDB> {
|
||||
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<TBD extends object = DefaultDB> {
|
||||
}
|
||||
|
||||
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<TBD extends object = DefaultDB> {
|
||||
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()])),
|
||||
};
|
||||
}
|
||||
|
||||
228
app/src/data/entities/EntityTypescript.ts
Normal file
228
app/src/data/entities/EntityTypescript.ts
Normal file
@@ -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<string, TFieldTSType>;
|
||||
};
|
||||
|
||||
// [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<string, string[]> = {},
|
||||
): Record<string, string[]> {
|
||||
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<string, string[]>) {
|
||||
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<string, string> = {};
|
||||
const imports: Record<string, string[]> = {
|
||||
"bknd/core": ["DB"],
|
||||
kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
|
||||
};
|
||||
|
||||
// add global types
|
||||
let g = "declare global {\n";
|
||||
g += `${this.getTab(1)}type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;\n`;
|
||||
g += `${this.getTab(1)}type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;\n`;
|
||||
g += `${this.getTab(1)}type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;\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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,24 +302,38 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise<void> {
|
||||
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<void> {
|
||||
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<RepositoryResponse<EntityData>> {
|
||||
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<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return this.single(qb, options) as any;
|
||||
return (await this.single(qb, options)) as any;
|
||||
}
|
||||
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any, any, any>,
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<Required extends true | false = false> 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<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType() {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "boolean",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Required extends true | false = false> 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<Required extends true | false = false> 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<Required extends true | false = false> 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<Required extends true | false = false> 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<Required extends true | false = false> 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<Required extends true | false = false> 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<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "Date | string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Required extends true | false = false, TypeOverride = str
|
||||
constructor(name: string, config: Partial<EnumFieldConfig>) {
|
||||
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<Required extends true | false = false, TypeOverride = str
|
||||
getOptions(): { label: string; value: string }[] {
|
||||
const options = this.config?.options ?? { type: "strings", values: [] };
|
||||
|
||||
/*if (options.values?.length === 0) {
|
||||
throw new Error(`Enum field "${this.name}" requires at least one option`);
|
||||
}*/
|
||||
|
||||
if (options.type === "strings") {
|
||||
return options.values?.map((option) => ({ label: option, value: option }));
|
||||
}
|
||||
@@ -146,4 +141,14 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
const union = this.getOptions().map(({ value }) =>
|
||||
typeof value === "string" ? `"${value}"` : value,
|
||||
);
|
||||
return {
|
||||
...super.toType(),
|
||||
type: union.length > 0 ? union.join(" | ") : "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Required extends true | false = false, TypeOverride = obj
|
||||
context: TActionContext,
|
||||
): Promise<string | undefined> {
|
||||
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<Required extends true | false = false, TypeOverride = obj
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "any",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
||||
import { Default, FromSchema, type Static, Type } from "core/utils";
|
||||
import { Default, FromSchema, objectToJsLiteral, 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 jsonSchemaFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
@@ -46,22 +49,16 @@ export class JsonSchemaField<
|
||||
|
||||
override isValid(value: any, context: TActionContext = "update"): boolean {
|
||||
const parentValid = super.isValid(value, context);
|
||||
//console.log("jsonSchemaField:isValid", this.getJsonSchema(), this.name, value, parentValid);
|
||||
|
||||
if (parentValid) {
|
||||
// already checked in parent
|
||||
if (!this.isRequired() && (!value || typeof value !== "object")) {
|
||||
//console.log("jsonschema:valid: not checking", this.name, value, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = this.validator.validate(value);
|
||||
//console.log("jsonschema:errors", this.name, result.errors);
|
||||
return result.valid;
|
||||
} else {
|
||||
//console.log("jsonschema:invalid", this.name, value, context);
|
||||
}
|
||||
//console.log("jsonschema:invalid:fromParent", this.name, value, context);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -89,7 +86,6 @@ export class JsonSchemaField<
|
||||
try {
|
||||
return Default(FromSchema(this.getJsonSchema()), {});
|
||||
} catch (e) {
|
||||
//console.error("jsonschema:transformRetrieve", e);
|
||||
return null;
|
||||
}
|
||||
} else if (this.hasDefault()) {
|
||||
@@ -107,13 +103,9 @@ export class JsonSchemaField<
|
||||
): Promise<string | undefined> {
|
||||
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)}>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Required extends true | false = false> extends Field<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Required extends true | false = false> 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<number>",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Required extends true | false = false> extends Field<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType() {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({})]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<any, any, any>;
|
||||
|
||||
/*export type RelationConfig = {
|
||||
mappedBy?: string;
|
||||
inversedBy?: string;
|
||||
sourceCardinality?: number;
|
||||
connectionTable?: string;
|
||||
connectionTableMappedName?: string;
|
||||
required?: boolean;
|
||||
};*/
|
||||
|
||||
export type BaseRelationConfig = Static<typeof EntityRelation.schema>;
|
||||
|
||||
// @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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof ManyToManyRelation.schema>;
|
||||
|
||||
@@ -46,7 +48,6 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
|
||||
this.connectionTableMappedName = config?.connectionTableMappedName || connectionTable;
|
||||
this.additionalFields = additionalFields || [];
|
||||
//this.connectionTable = connectionTable;
|
||||
}
|
||||
|
||||
static defaultConnectionTable(source: Entity, target: Entity) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
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, type RelationFieldBaseConfig } from "./RelationField";
|
||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
/**
|
||||
* Source entity receives the mapping field
|
||||
@@ -125,7 +127,6 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
|
||||
}
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
//console.log("queryInfo", entity.name, { reference, side, relationRef, entityRef, otherRef });
|
||||
|
||||
return {
|
||||
other,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
@@ -6,6 +6,8 @@ import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export type PolymorphicRelationConfig = Static<typeof PolymorphicRelation.schema>;
|
||||
|
||||
|
||||
@@ -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<typeof relationFieldConfigSchema>;
|
||||
export type RelationFieldBaseConfig = { label?: string };
|
||||
@@ -31,16 +29,6 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
return relationFieldConfigSchema;
|
||||
}
|
||||
|
||||
/*constructor(name: string, config?: Partial<RelationFieldConfig>) {
|
||||
//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<RelationFieldConfig> {
|
||||
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<RelationFieldConfig> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user