Compare commits

..

9 Commits

Author SHA1 Message Date
dswbx 0ff310d6c4 e2e: added script to auto test adapters 2025-04-03 16:38:53 +02:00
dswbx 5178dbee0d e2e: replaced image 2025-04-03 11:07:10 +02:00
dswbx f0f2b571b5 e2e: added adapter configs 2025-04-03 11:04:31 +02:00
dswbx 2cff116fcf Merge remote-tracking branch 'origin/release/0.11' into feat/init-e2e 2025-04-03 09:18:09 +02:00
dswbx 53a48b4b6b e2e: overwrite webserver config with env 2025-04-03 07:56:19 +02:00
dswbx 6f92ef7b74 fix bun picking up e2e tests 2025-04-02 20:50:46 +02:00
dswbx e3628a3dc8 updated/moved vitest, finished merge 2025-04-02 20:39:08 +02:00
dswbx fd4bbccfb7 Merge remote-tracking branch 'origin/release/0.11' into feat/init-e2e
# Conflicts:
#	app/.gitignore
#	app/package.json
#	app/vite.dev.ts
#	bun.lock
2025-04-02 20:24:18 +02:00
dswbx 7ed5db5eaa init e2e 2025-03-28 15:17:04 +01:00
250 changed files with 4588 additions and 15809 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.2.14" bun-version: "1.2.5"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
-2
View File
@@ -30,5 +30,3 @@ packages/media/.env
.vscode .vscode
.git_old .git_old
docker/tmp docker/tmp
.debug
.history
-1
View File
@@ -1 +0,0 @@
22
-1
View File
@@ -1,4 +1,3 @@
playwright-report playwright-report
test-results test-results
bknd.config.* bknd.config.*
__test__/helper.d.ts
+4 -26
View File
@@ -1,8 +1,8 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "core/utils"; import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi } from "../../src/media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
const mockedBackend = new Hono() const mockedBackend = new Hono()
@@ -39,28 +39,10 @@ describe("MediaApi", () => {
// @ts-ignore tests // @ts-ignore tests
const api = new MediaApi({ const api = new MediaApi({
token: "token", token: "token",
token_transport: "header",
}); });
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); 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 () => { it("should get file: native", async () => {
const name = "image.png"; const name = "image.png";
const path = `${assetsTmpPath}/${name}`; const path = `${assetsTmpPath}/${name}`;
@@ -121,12 +103,8 @@ describe("MediaApi", () => {
}); });
it("should upload file in various ways", async () => { it("should upload file in various ways", async () => {
const api = new MediaApi( // @ts-ignore tests
{ const api = new MediaApi({}, mockedBackend.request);
upload_fetcher: mockedBackend.request,
},
mockedBackend.request,
);
const file = Bun.file(`${assetsPath}/image.png`); const file = Bun.file(`${assetsPath}/image.png`);
async function matches(req: Promise<any>, filename: string) { async function matches(req: Promise<any>, filename: string) {
+3 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type TObject, type TString, Type } from "@sinclair/typebox"; import type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "core"; import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils";
type Constructor<T> = new (...args: any[]) => T; type Constructor<T> = new (...args: any[]) => T;
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { SchemaObject } from "../../../src/core"; import { SchemaObject } from "../../../src/core";
import { Type } from "@sinclair/typebox"; import { Type } from "../../../src/core/utils";
describe("SchemaObject", async () => { describe("SchemaObject", async () => {
test("basic", async () => { test("basic", async () => {
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "core/object/query/object-query"; import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
describe("object-query", () => { describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" }; const q: ObjectQuery = { name: "Michael" };
@@ -1,7 +1,15 @@
import { describe, test, expect } from "bun:test"; import { describe, expect, test } from "bun:test";
import { getDummyConnection } from "../helper"; import { Value, _jsonp } from "../../src/core/utils";
import { type WhereQuery, WhereBuilder } from "data"; import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() { function qb() {
const c = getDummyConnection(); const c = getDummyConnection();
const kysely = c.dummyConnection.kysely; const kysely = c.dummyConnection.kysely;
@@ -12,7 +20,6 @@ function compile(q: WhereQuery) {
return { sql, parameters }; return { sql, parameters };
} }
describe("WhereBuilder", () => {
test("single validation", () => { test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [ const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]], [{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -87,4 +94,64 @@ describe("WhereBuilder", () => {
expect(keys).toEqual(expectedKeys); expect(keys).toEqual(expectedKeys);
} }
}); });
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
}); });
+11 -5
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "data"; import { Entity, NumberField, TextField } from "../../../src/data";
import * as p from "data/prototype";
describe("[data] Entity", async () => { describe("[data] Entity", async () => {
const entity = new Entity("test", [ const entity = new Entity("test", [
@@ -48,7 +47,14 @@ describe("[data] Entity", async () => {
expect(entity.getField("new_field")).toBe(field); expect(entity.getField("new_field")).toBe(field);
}); });
test.only("types", async () => { // @todo: move this to ClientApp
console.log(entity.toTypes()); /*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);
});*/
}); });
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users)); em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1); expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation); expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).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.relationsOf("posts")).toEqual([em.relations.all[0]]);
expect(em.hasRelations("users")).toBe(true); expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true); expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]); expect(em.relatedEntitiesOf("users")).toEqual([posts]);
@@ -266,12 +266,5 @@ describe("[data] Repository (Events)", async () => {
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); 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 { describe, expect, test } from "bun:test";
import { Type } from "@sinclair/typebox"; import { Type } from "../../../../src/core/utils";
import { Entity, EntityIndex, Field } from "../../../../src/data"; import { Entity, EntityIndex, Field } from "../../../../src/data";
class TestField extends Field { class TestField extends Field {
+41 -22
View File
@@ -1,23 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows"; import { Flow, LogTask, RenderTask, SubFlowTask } 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 () => { describe("SubFlowTask", async () => {
test("Simple Subflow", async () => { test("Simple Subflow", async () => {
@@ -40,6 +22,8 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution(); const execution = flow.createExecution();
await execution.start(); await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow"); expect(execution.getResponse()).toEqual("Subflow output: subflow");
}); });
@@ -56,8 +40,8 @@ describe("SubFlowTask", async () => {
loop: true, loop: true,
input: [1, 2, 3], input: [1, 2, 3],
}); });
const task3 = new StringifyTask("stringify", { const task3 = new RenderTask("render2", {
input: "{{ sub.output }}", render: `Subflow output: {{ sub.output | join: ", " }}`,
}); });
const flow = new Flow("test", [task, task2, task3], []); const flow = new Flow("test", [task, task2, task3], []);
@@ -67,6 +51,41 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution(); const execution = flow.createExecution();
await execution.start(); await execution.start();
expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"'); 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");
}); });
}); });
+59 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Type } from "@sinclair/typebox"; import { Type } from "../../src/core/utils";
import { Task } from "../../src/flows"; import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task"; import { dynamic } from "../../src/flows/tasks/Task";
@@ -51,4 +51,62 @@ describe("Task", async () => {
expect(result.test).toEqual({ key: "path", value: "1/1" }); 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 -2
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events"; import { Event, EventManager } from "../../src/core/events";
import { parse } from "../../src/core/utils"; import { type Static, type StaticDecode, Type, 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 { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task"; import { dynamic } from "../../src/flows/tasks/Task";
+1 -2
View File
@@ -1,8 +1,7 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { _jsonp, withDisabledConsole } from "../../src/core/utils"; import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
import { type Static, Type } from "@sinclair/typebox";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog); /*beforeAll(disableConsoleLog);
+1 -2
View File
@@ -43,9 +43,8 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test("accepts direct", async () => { test.only("accepts direct", async () => {
const app = await makeApp(); const app = await makeApp();
console.log("app", app);
const file = Bun.file(path); const file = Bun.file(path);
const name = makeName("png"); const name = makeName("png");
+1 -60
View File
@@ -69,7 +69,7 @@ describe("AppAuth", () => {
}, },
body: JSON.stringify({ body: JSON.stringify({
email: "some@body.com", email: "some@body.com",
password: "12345678", password: "123456",
}), }),
}); });
enableConsoleLog(); enableConsoleLog();
@@ -81,65 +81,6 @@ 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 () => { test("registers auth middleware for bknd routes only", async () => {
const app = createApp({ const app = createApp({
initialConfig: { initialConfig: {
+3 -41
View File
@@ -1,51 +1,13 @@
import { beforeEach, describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { parse } from "../../src/core/utils"; import { parse } from "../../src/core/utils";
import { fieldsSchema } from "../../src/data/data-schema"; import { fieldsSchema } from "../../src/data/data-schema";
import { AppData, type ModuleBuildContext } from "../../src/modules"; import { AppData } from "../../src/modules";
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
import * as proto from "data/prototype";
describe("AppData", () => { describe("AppData", () => {
moduleTestSuite(AppData); moduleTestSuite(AppData);
let ctx: ModuleBuildContext;
beforeEach(() => {
ctx = makeCtx();
});
test("field config construction", () => { test("field config construction", () => {
expect(parse(fieldsSchema, { type: "text" })).toBeDefined(); expect(parse(fieldsSchema, { type: "text" })).toBeDefined();
}); });
test("should prevent multi-deletion of entities in single request", async () => {
const schema = proto.em({
one: proto.entity("one", {
text: proto.text(),
}),
two: proto.entity("two", {
text: proto.text(),
}),
three: proto.entity("three", {
text: proto.text(),
}),
});
const check = () => {
const expected = ["one", "two", "three"];
const fromConfig = Object.keys(data.config.entities ?? {});
const fromEm = data.em.entities.map((e) => e.name);
expect(fromConfig).toEqual(expected);
expect(fromEm).toEqual(expected);
};
// auth must be enabled, otherwise default config is returned
const data = new AppData(schema.toJSON(), ctx);
await data.build();
check();
expect(data.schema().remove("entities")).rejects.toThrow(/more than one entity/);
check();
await data.setContext(makeCtx()).build();
check();
});
}); });
+3 -4
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { stripMark } from "../../src/core/utils"; import { type TSchema, Type, stripMark } from "../../src/core/utils";
import { type TSchema, Type } from "@sinclair/typebox";
import { EntityManager, em, entity, index, text } from "../../src/data"; import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module"; import { Module } from "../../src/modules/Module";
@@ -10,10 +9,10 @@ function createModule<Schema extends TSchema>(schema: Schema) {
getSchema() { getSchema() {
return schema; return schema;
} }
override toJSON() { toJSON() {
return this.config; return this.config;
} }
override useForceParse() { useForceParse() {
return true; return true;
} }
} }
+5 -132
View File
@@ -1,13 +1,10 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils"; import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
import { Type } from "@sinclair/typebox"; import { entity, text } from "../../src/data";
import { Connection, entity, text } from "data"; import { Module } from "../../src/modules/Module";
import { Module } from "modules/Module"; import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager"; import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
test("s1: no config, no build", async () => { test("s1: no config, no build", async () => {
@@ -383,128 +380,4 @@ describe("ModuleManager", async () => {
expect(() => f.default()).toThrow(); expect(() => f.default()).toThrow();
}); });
}); });
async function getRawConfig(c: Connection) {
return (await c.kysely
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "config")
.orderBy("version", "desc")
.executeTakeFirstOrThrow()) as unknown as ConfigTable;
}
async function getDiffs(c: Connection, opts?: { dir?: "asc" | "desc"; limit?: number }) {
return await c.kysely
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "diff")
.orderBy("version", opts?.dir ?? "desc")
.$if(!!opts?.limit, (b) => b.limit(opts!.limit!))
.execute();
}
describe("diffs", () => {
test("never empty", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new ModuleManager(c);
await mm.build();
await mm.save();
expect(await getDiffs(c)).toHaveLength(0);
});
test("has timestamps", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new ModuleManager(c);
await mm.build();
await mm.get("data").schema().patch("basepath", "/api/data2");
await mm.save();
const config = await getRawConfig(c);
const diffs = await getDiffs(c);
expect(config.json.data.basepath).toBe("/api/data2");
expect(diffs).toHaveLength(1);
expect(diffs[0]!.created_at).toBeDefined();
expect(diffs[0]!.updated_at).toBeDefined();
});
});
describe("validate & revert", () => {
const schema = Type.Object({
value: Type.Array(Type.Number(), { default: [] }),
});
type SampleSchema = Static<typeof schema>;
class Sample extends Module<typeof schema> {
getSchema() {
return schema;
}
override async build() {
this.setBuilt();
}
override async onBeforeUpdate(from: SampleSchema, to: SampleSchema) {
if (to.value.length > 3) {
throw new Error("too many values");
}
if (to.value.includes(7)) {
throw new Error("contains 7");
}
return to;
}
}
class TestModuleManager extends ModuleManager {
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
super(...args);
this.modules["module1"] = new Sample({}, this.ctx());
}
}
test("respects module onBeforeUpdate", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new TestModuleManager(c);
await mm.build();
const m = mm.get("module1" as any) as Sample;
{
expect(async () => {
await m.schema().set({ value: [1, 2, 3, 4, 5] });
return mm.save();
}).toThrow(/too many values/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 2, 3, 4, 5] });
return mm.save();
}).toThrow(/too many values/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await m.schema().set({ value: [1, 7, 5] });
return mm.save();
}).toThrow(/contains 7/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 7, 5] });
return mm.save();
}).toThrow(/contains 7/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
});
});
}); });
-23
View File
@@ -1,23 +0,0 @@
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
import { formatNumber } from "core/utils";
const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"],
target: "node",
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
for (const output of result.outputs) {
const size_ = await output.text();
console.info(
c.cyan(formatNumber.fileSize(size_.length)),
c.dim(output.path.replace(import.meta.dir + "/", "")),
);
}
+8 -17
View File
@@ -1,6 +1,5 @@
import { $ } from "bun"; import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -9,13 +8,8 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap"); const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean"); const clean = args.includes("--clean");
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) { if (clean) {
console.info("Cleaning dist (w/o static)"); console.log("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`; await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
} }
@@ -27,11 +21,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.info("Types built"); console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.info("Types aliased"); console.log("Types aliased");
types_running = false; types_running = false;
}, },
}); });
@@ -53,10 +47,10 @@ if (types && !watch) {
} }
function banner(title: string) { function banner(title: string) {
console.info(""); console.log("");
console.info("=".repeat(40)); console.log("=".repeat(40));
console.info(title.toUpperCase()); console.log(title.toUpperCase());
console.info("-".repeat(40)); console.log("-".repeat(40));
} }
// collection of always-external packages // collection of always-external packages
@@ -71,7 +65,6 @@ async function buildApi() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
define,
entry: [ entry: [
"src/index.ts", "src/index.ts",
"src/core/index.ts", "src/core/index.ts",
@@ -108,7 +101,6 @@ async function buildUi() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
define,
external: [ external: [
...external, ...external,
"react", "react",
@@ -168,7 +160,6 @@ async function buildUiElements() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
define,
entry: ["src/ui/elements/index.ts"], entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
@@ -220,7 +211,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
}, },
...overrides, ...overrides,
define: { define: {
...define, __isDev: "0",
...overrides.define, ...overrides.define,
}, },
external: [ external: [
+2 -10
View File
@@ -186,8 +186,7 @@ const adapters = {
}, },
} as const; } as const;
async function testAdapter(name: keyof typeof adapters) { for (const [name, config] of Object.entries(adapters)) {
const config = adapters[name];
console.log("adapter", c.cyan(name)); console.log("adapter", c.cyan(name));
await config.clean(); await config.clean();
@@ -203,12 +202,5 @@ async function testAdapter(name: keyof typeof adapters) {
await Bun.sleep(250); await Bun.sleep(250);
console.log("Waiting for process to exit..."); console.log("Waiting for process to exit...");
} }
} //process.exit(0);
if (process.env.TEST_ADAPTER) {
await testAdapter(process.env.TEST_ADAPTER as any);
} else {
for (const [name] of Object.entries(adapters)) {
await testAdapter(name as any);
}
} }
+1 -1
View File
@@ -13,7 +13,7 @@ test("can enable media", async ({ page }) => {
await page.goto(`${config.base_path}/media/settings`); await page.goto(`${config.base_path}/media/settings`);
// enable // enable
const enableToggle = page.getByTestId(testIds.media.switchEnabled); const enableToggle = page.locator("css=button#enabled");
if ((await enableToggle.getAttribute("aria-checked")) !== "true") { if ((await enableToggle.getAttribute("aria-checked")) !== "true") {
await expect(enableToggle).toBeVisible(); await expect(enableToggle).toBeVisible();
await enableToggle.click(); await enableToggle.click();
+11 -12
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.13.0", "version": "0.11.0-rc.2",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -14,11 +14,11 @@
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"scripts": { "scripts": {
"dev": "BKND_CLI_LOG_LEVEL=debug vite", "dev": "vite",
"build": "NODE_ENV=production bun run build.ts --minify --types", "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: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", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun run build.cli.ts", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build", "build:static": "vite build",
"watch": "bun run build.ts --types --watch", "watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit", "types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -26,7 +26,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./", "prepublishOnly": "bun run types && bun run test && bun run test:node && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md", "postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node", "test:all": "bun run test && bun run test:node",
@@ -38,7 +38,6 @@
"test:vitest:watch": "vitest", "test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage", "test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug", "test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report" "test:e2e:report": "playwright show-report"
@@ -48,29 +47,31 @@
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2", "@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "0.34.30", "@sinclair/typebox": "^0.34.30",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10", "@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8", "fast-xml-parser": "^5.0.8",
"hono": "^4.7.4", "hono": "^4.7.4",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "^10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3", "radix-ui": "^1.1.3",
"swr": "^2.3.3" "swr": "^2.3.3",
"wrangler": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
@@ -99,11 +100,9 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
-1
View File
@@ -12,7 +12,6 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: "html", reporter: "html",
timeout: 20000,
use: { use: {
baseURL: baseUrl, baseURL: baseUrl,
trace: "on-first-retry", trace: "on-first-retry",
+22 -75
View File
@@ -1,19 +1,13 @@
import type { SafeUser } from "auth"; import type { SafeUser } from "auth";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi"; import { AuthApi } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi"; import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt"; import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi"; import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils"; import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser; export type TApiUser = SafeUser;
export type ApiFetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
declare global { declare global {
interface Window { interface Window {
__BKND__: { __BKND__: {
@@ -22,24 +16,14 @@ declare global {
} }
} }
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = { export type ApiOptions = {
host?: string; host?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
storage?: { localStorage?: boolean;
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>; fetcher?: typeof fetch;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
fetcher?: ApiFetcher;
verbose?: boolean; verbose?: boolean;
verified?: boolean; verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & ( } & (
| { | {
token?: string; token?: string;
@@ -72,18 +56,18 @@ export class Api {
this.verified = options.verified === true; this.verified = options.verified === true;
// prefer request if given // prefer request if given
if ("request" in options && options.request) { if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin; this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers; this.options.headers = options.headers ?? options.request.headers;
this.extractToken(); this.extractToken();
// then check for a token // then check for a token
} else if ("token" in options && options.token) { } else if ("token" in options) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(options.token, { trigger: false }); this.updateToken(options.token);
// then check for an user object // then check for an user object
} else if ("user" in options && options.user) { } else if ("user" in options) {
this.token_transport = "none"; this.token_transport = "none";
this.user = options.user; this.user = options.user;
this.verified = options.verified !== false; this.verified = options.verified !== false;
@@ -126,30 +110,18 @@ export class Api {
this.updateToken(headerToken); this.updateToken(headerToken);
return; return;
} }
} else if (this.storage) { } else if (this.options.localStorage) {
this.storage.getItem(this.tokenKey).then((token) => { const token = localStorage.getItem(this.tokenKey);
if (token) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(token ? String(token) : undefined); this.updateToken(token);
});
} }
} }
private get storage() { //console.warn("Couldn't extract token");
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
} }
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { updateToken(token?: string, rebuild?: boolean) {
this.token = token; this.token = token;
this.verified = false; this.verified = false;
@@ -159,25 +131,17 @@ export class Api {
this.user = undefined; this.user = undefined;
} }
if (this.storage) { if (this.options.localStorage) {
const key = this.tokenKey; const key = this.tokenKey;
if (token) { if (token) {
this.storage.setItem(key, token).then(() => { localStorage.setItem(key, token);
this.options.onAuthStateChange?.(this.getAuthState());
});
} else { } else {
this.storage.removeItem(key).then(() => { localStorage.removeItem(key);
this.options.onAuthStateChange?.(this.getAuthState());
});
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
} }
} }
if (opts?.rebuild) this.buildApis(); if (rebuild) this.buildApis();
} }
private markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
@@ -247,32 +211,15 @@ export class Api {
const fetcher = this.options.fetcher; const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher); this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi( this.data = new DataApi(baseParams, fetcher);
{
...baseParams,
...this.options.data,
},
fetcher,
);
this.auth = new AuthApi( this.auth = new AuthApi(
{ {
...baseParams, ...baseParams,
credentials: this.options.storage ? "omit" : "include", onTokenUpdate: (token) => this.updateToken(token, true),
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
}, },
fetcher, fetcher,
); );
this.media = new MediaApi(baseParams, fetcher);
} }
} }
+3 -6
View File
@@ -15,7 +15,7 @@ import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be here // biome-ignore format: must be there
import { Api, type ApiOptions } from "Api"; import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
@@ -151,7 +151,7 @@ export class App {
} }
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
return this.server.fetch as any; return this.server.fetch;
} }
get module() { get module() {
@@ -180,10 +180,7 @@ export class App {
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); this.adminController = new AdminController(this, config);
this.modules.server.route( this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
this.adminController.basepath,
this.adminController.getController(),
);
return this; return this;
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
case "url": case "url":
additional.adminOptions = { additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}), ...(typeof adminOptions === "object" ? adminOptions : {}),
assetsPath: assets.url, assets_path: assets.url,
}; };
break; break;
default: default:
@@ -1,16 +1,14 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import type { RuntimeBkndConfig } from "bknd/adapter"; import type { FrameworkBkndConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import type { App } from "bknd"; import { getFresh, getWarm } from "./modes/fresh";
import { $console } from "core";
export type CloudflareEnv = object; export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & { export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => { bindings?: (args: Env) => {
kv?: KVNamespace; kv?: KVNamespace;
@@ -22,6 +20,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
keepAliveSeconds?: number; keepAliveSeconds?: number;
forceHttps?: boolean; forceHttps?: boolean;
manifest?: string; manifest?: string;
setAdminHtml?: boolean;
html?: string;
}; };
export type Context<Env = CloudflareEnv> = { export type Context<Env = CloudflareEnv> = {
@@ -38,12 +38,12 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
const url = new URL(request.url); const url = new URL(request.url);
if (config.manifest && config.static === "assets") { 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") { } else if (!config.manifest && config.static === "kv") {
throw new Error("manifest is required with static 'kv'"); throw new Error("manifest is required with static 'kv'");
} }
if (config.manifest && config.static === "kv") { if (config.manifest && config.static !== "assets") {
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(config.manifest); const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) { if (pathname && pathname in assetManifest) {
@@ -70,24 +70,18 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
const context = { request, env, ctx } as Context<Env>; const context = { request, env, ctx } as Context<Env>;
const mode = config.mode ?? "warm"; const mode = config.mode ?? "warm";
let app: App;
switch (mode) { switch (mode) {
case "fresh": case "fresh":
app = await getFresh(config, context, { force: true }); return await getFresh(config, context);
break;
case "warm": case "warm":
app = await getFresh(config, context); return await getWarm(config, context);
break;
case "cache": case "cache":
app = await getCached(config, context); return await getCached(config, context);
break;
case "durable": case "durable":
return await getDurable(config, context); return await getDurable(config, context);
default: default:
throw new Error(`Unknown mode ${mode}`); throw new Error(`Unknown mode ${mode}`);
} }
return app.fetch(request, env, ctx);
}, },
}; };
} }
+2 -3
View File
@@ -5,7 +5,6 @@ import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd"; import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter"; import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { ExecutionContext } from "hono"; import type { ExecutionContext } from "hono";
import { $console } from "core";
export const constants = { export const constants = {
exec_async_event_id: "cf_register_waituntil", exec_async_event_id: "cf_register_waituntil",
@@ -28,12 +27,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
if (!appConfig.connection) { if (!appConfig.connection) {
let db: D1Database | undefined; let db: D1Database | undefined;
if (bindings?.db) { if (bindings?.db) {
$console.log("Using database from bindings"); console.log("Using database from bindings");
db = bindings.db; db = bindings.db;
} else if (Object.keys(args).length > 0) { } else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database"); const binding = getBinding(args, "D1Database");
if (binding) { if (binding) {
$console.log(`Using database from env "${binding.key}"`); console.log(`Using database from env "${binding.key}"`);
db = binding.value; db = binding.value;
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter"; export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh"; export { makeApp, getFresh, getWarm } from "./modes/fresh";
export { getCached } from "./modes/cached"; export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable"; export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig }; export { D1Connection, type D1ConnectionConfig };
@@ -40,6 +40,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
); );
await config.beforeBuild?.(app); await config.beforeBuild?.(app);
}, },
adminOptions: { html: config.html },
}, },
{ env, ctx, ...args }, { env, ctx, ...args },
); );
+3 -3
View File
@@ -3,7 +3,6 @@ import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config"; import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>( export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>, config: CloudflareBkndConfig<Env>,
@@ -14,7 +13,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
const key = config.key ?? "app"; const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) { if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode"); console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
} }
const start = performance.now(); const start = performance.now();
@@ -26,7 +25,9 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
const res = await stub.fire(ctx.request, { const res = await stub.fire(ctx.request, {
config: create_config, config: create_config,
html: config.html,
keepAliveSeconds: config.keepAliveSeconds, keepAliveSeconds: config.keepAliveSeconds,
setAdminHtml: config.setAdminHtml,
}); });
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
@@ -109,7 +110,6 @@ export class DurableBkndApp extends DurableObject {
} }
async onBuilt(app: App) {} async onBuilt(app: App) {}
async beforeBuild(app: App) {} async beforeBuild(app: App) {}
protected keepAlive(seconds: number) { protected keepAlive(seconds: number) {
+31 -12
View File
@@ -7,7 +7,33 @@ export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions, opts?: RuntimeOptions,
) { ) {
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts); return await createRuntimeApp<Env>(
{
...makeConfig(config, args),
adminOptions: config.html ? { html: config.html } : undefined,
},
args,
opts,
);
}
export async function getWarm<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
const app = await makeApp(
{
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
config.onBuilt?.(app);
},
},
ctx.env,
opts,
);
return app.fetch(ctx.request);
} }
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>( export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
@@ -15,15 +41,8 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
ctx: Context<Env>, ctx: Context<Env>,
opts: RuntimeOptions = {}, opts: RuntimeOptions = {},
) { ) {
return await makeApp( return await getWarm(config, ctx, {
{ ...opts,
...config, force: true,
onBuilt: async (app) => { });
registerAsyncsExecutionContext(app, ctx.ctx);
await config.onBuilt?.(app);
},
},
ctx.env,
opts,
);
} }
@@ -3,7 +3,7 @@ import { test } from "node:test";
import { Miniflare } from "miniflare"; import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter"; import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media"; import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node/test"; import { nodeTestRunner } from "adapter/node";
import path from "node:path"; import path from "node:path";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 // https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
@@ -1,10 +1,8 @@
import { registries } from "bknd"; import { registries } from "bknd";
import { isDebug } from "bknd/core"; import { isDebug } from "bknd/core";
import { StringEnum } from "bknd/utils"; import { StringEnum, Type } from "bknd/utils";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media"; import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings"; import { getBindings } from "../bindings";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export function makeSchema(bindings: string[] = []) { export function makeSchema(bindings: string[] = []) {
return Type.Object( return Type.Object(
@@ -124,10 +122,12 @@ export class StorageR2Adapter extends StorageAdapter {
} }
} }
//console.log("response headers:before", headersToObject(responseHeaders));
this.writeHttpMetadata(responseHeaders, object); this.writeHttpMetadata(responseHeaders, object);
responseHeaders.set("etag", object.httpEtag); responseHeaders.set("etag", object.httpEtag);
responseHeaders.set("Content-Length", String(object.size)); responseHeaders.set("Content-Length", String(object.size));
responseHeaders.set("Last-Modified", object.uploaded.toUTCString()); responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
//console.log("response headers:after", headersToObject(responseHeaders));
return new Response(object.body, { return new Response(object.body, {
status: object.range ? 206 : 200, status: object.range ? 206 : 200,
+1
View File
@@ -3,6 +3,7 @@ import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageL
export * from "./node.adapter"; export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig }; export { StorageLocalAdapter, type LocalAdapterConfig };
export { nodeTestRunner } from "./test";
let registered = false; let registered = false;
export function registerLocalMediaAdapter() { export function registerLocalMediaAdapter() {
@@ -1,7 +1,7 @@
import { describe, before, after } from "node:test"; import { describe, before, after } from "node:test";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { nodeTestRunner } from "adapter/node/test"; import { nodeTestRunner } from "adapter/node";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog()); before(() => disableConsoleLog());
+1 -2
View File
@@ -4,7 +4,6 @@ import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index"; import { registerLocalMediaAdapter } from "adapter/node/index";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core"; import { config as $config } from "bknd/core";
import { $console } from "core";
type NodeEnv = NodeJS.ProcessEnv; type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & { export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
@@ -63,7 +62,7 @@ export function serve<Env = NodeEnv>(
fetch: createHandler(config, args, opts), fetch: createHandler(config, args, opts),
}, },
(connInfo) => { (connInfo) => {
$console.log(`Server is running on http://localhost:${connInfo.port}`); console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo); listener?.(connInfo);
}, },
); );
@@ -1,6 +1,5 @@
import { describe } from "node:test"; import { describe } from "node:test";
import { nodeTestRunner } from "adapter/node/test"; import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -1,9 +1,7 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, isFile, parse } from "bknd/utils"; import { type Static, Type, isFile, parse } from "bknd/utils";
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media"; import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
import { StorageAdapter, guessMimeType as guess } 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( export const localAdapterConfig = Type.Object(
{ {
+35 -29
View File
@@ -1,24 +1,18 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
type DevServerOptions,
default as honoViteDevServer,
} from "@hono/vite-dev-server";
import type { App } from "bknd"; import type { App } from "bknd";
import { import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
type RuntimeBkndConfig,
createRuntimeApp,
type FrameworkOptions,
} from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
export type ViteEnv = NodeJS.ProcessEnv; export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {}; mode?: "cached" | "fresh";
setAdminHtml?: boolean;
forceDev?: boolean | { mainPath: string };
html?: string;
};
export function addViteScript( export function addViteScript(html: string, addBkndContext: boolean = true) {
html: string,
addBkndContext: boolean = true,
) {
return html.replace( return html.replace(
"</head>", "</head>",
`<script type="module"> `<script type="module">
@@ -34,40 +28,52 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
); );
} }
async function createApp<ViteEnv>( async function createApp(config: ViteBkndConfig = {}, env?: any) {
config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> {
registerLocalMediaAdapter(); registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
adminOptions: config.adminOptions ?? { adminOptions:
forceDev: { config.setAdminHtml === false
? undefined
: {
html: config.html,
forceDev: config.forceDev ?? {
mainPath: "/src/main.tsx", mainPath: "/src/main.tsx",
}, },
}, },
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })], serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
}, },
env, env,
opts,
); );
} }
export function serve<ViteEnv>( export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env, opts); const app = await createApp(config, env);
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
}; };
} }
let app: App;
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) {
app = await createApp(config, env);
}
return app.fetch(request, env, ctx);
},
};
}
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
return mode === "fresh" ? serveFresh(config) : serveCached(config);
}
export function devServer(options: DevServerOptions) { export function devServer(options: DevServerOptions) {
return honoViteDevServer({ return honoViteDevServer({
...devServerConfig, ...devServerConfig,
+136 -15
View File
@@ -1,23 +1,29 @@
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth"; import {
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy,
} from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { $console, type DB } from "core"; import { $console, type DB, Exception, type PrimaryFieldType } from "core";
import { secureRandomString, transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data"; import type { Entity, EntityManager } from "data";
import { em, entity, enumm, type FieldSchema, text } from "data/prototype"; import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
import { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>; export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "core" { declare module "core" {
interface Users extends AppEntity, UserFieldSchema {}
interface DB { interface DB {
users: Users; users: { id: PrimaryFieldType } & UserFieldSchema;
} }
} }
type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> { export class AppAuth extends Module<typeof authConfigSchema> {
@@ -25,12 +31,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
cache: Record<string, any> = {}; cache: Record<string, any> = {};
_controller!: AuthController; _controller!: AuthController;
override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) { override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
if (!from.enabled && to.enabled) { if (!from.enabled && to.enabled) {
if (to.jwt.secret === defaultSecret) { 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); to.jwt.secret = secureRandomString(64);
} }
} }
@@ -74,7 +80,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} }
}); });
this._authenticator = new Authenticator(strategies, new AppUserPool(this), { this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
jwt: this.config.jwt, jwt: this.config.jwt,
cookie: this.config.cookie, cookie: this.config.cookie,
}); });
@@ -84,7 +90,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
this._controller = new AuthController(this); this._controller = new AuthController(this);
this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(AuthPermissions); this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
} }
isStrategyEnabled(strategy: Strategy | string) { isStrategyEnabled(strategy: Strategy | string) {
@@ -116,6 +122,120 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.ctx.em as any; 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> { getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
const entity_name = this.config.entity_name; const entity_name = this.config.entity_name;
if (forceCreate || !this.em.hasEntity(entity_name)) { if (forceCreate || !this.em.hasEntity(entity_name)) {
@@ -168,7 +288,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Error("Cannot create user, auth not enabled"); throw new Error("Cannot create user, auth not enabled");
} }
const strategy = "password" as const; const strategy = "password";
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(password); const strategy_value = await pw.hash(password);
const mutator = this.em.mutator(this.config.entity_name as "users"); const mutator = this.em.mutator(this.config.entity_name as "users");
@@ -195,7 +315,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
...this.authenticator.toJSON(secrets), ...this.authenticator.toJSON(secrets),
strategies: transformObject(strategies, (strategy) => ({ strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy), enabled: this.isStrategyEnabled(strategy),
...strategy.toJSON(secrets), type: strategy.getType(),
config: strategy.toJSON(secrets),
})), })),
}; };
} }
-83
View File
@@ -1,83 +0,0 @@
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?
}
}
+4 -8
View File
@@ -4,21 +4,19 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & { export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token?: string) => void | Promise<void>; onTokenUpdate?: (token: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
}; };
export class AuthApi extends ModuleApi<AuthApiOptions> { export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> { protected override getDefaultOptions(): Partial<AuthApiOptions> {
return { return {
basepath: "/api/auth", basepath: "/api/auth",
credentials: "include",
}; };
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, { const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: this.options.credentials, credentials: "include",
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -29,7 +27,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, { const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: this.options.credentials, credentials: "include",
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -70,7 +68,5 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]); return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
} }
async logout() { async logout() {}
await this.options.onTokenUpdate?.(undefined);
}
} }
+21 -49
View File
@@ -1,9 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { TypeInvalidError, parse, transformObject } from "core/utils"; import { tbValidator as tb } from "core";
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data"; import { DataPermissions } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller"; import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s } from "core/object/schema";
export type AuthActionResponse = { export type AuthActionResponse = {
success: boolean; success: boolean;
@@ -12,6 +12,10 @@ export type AuthActionResponse = {
errors?: any; errors?: any;
}; };
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller { export class AuthController extends Controller {
constructor(private auth: AppAuth) { constructor(private auth: AppAuth) {
super(); super();
@@ -50,10 +54,6 @@ export class AuthController extends Controller {
hono.post( hono.post(
"/create", "/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]), permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => { async (c) => {
try { try {
const body = await this.auth.authenticator.getBody(c); const body = await this.auth.authenticator.getBody(c);
@@ -91,16 +91,9 @@ export class AuthController extends Controller {
} }
}, },
); );
hono.get( hono.get("create/schema.json", async (c) => {
"create/schema.json",
describeRoute({
summary: "Get the schema for creating a user",
tags: ["auth"],
}),
async (c) => {
return c.json(create.schema); return c.json(create.schema);
}, });
);
} }
mainHono.route(`/${name}/actions`, hono); mainHono.route(`/${name}/actions`, hono);
@@ -109,15 +102,16 @@ export class AuthController extends Controller {
override getController() { override getController() {
const { auth } = this.middlewares; const { auth } = this.middlewares;
const hono = this.create(); const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
hono.get( for (const [name, strategy] of Object.entries(strategies)) {
"/me", if (!this.auth.isStrategyEnabled(strategy)) continue;
describeRoute({
summary: "Get the current user", hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
tags: ["auth"], this.registerStrategyActions(strategy, hono);
}), }
auth(),
async (c) => { hono.get("/me", auth(), async (c) => {
const claims = c.get("auth")?.user; const claims = c.get("auth")?.user;
if (claims) { if (claims) {
const { data: user } = await this.userRepo.findId(claims.id); const { data: user } = await this.userRepo.findId(claims.id);
@@ -125,17 +119,9 @@ export class AuthController extends Controller {
} }
return c.json({ user: null }, 403); return c.json({ user: null }, 403);
}, });
);
hono.get( hono.get("/logout", auth(), async (c) => {
"/logout",
describeRoute({
summary: "Logout the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
await this.auth.authenticator.logout(c); await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) { if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true }); return c.json({ ok: true });
@@ -147,16 +133,11 @@ export class AuthController extends Controller {
} }
return c.redirect("/"); return c.redirect("/");
}, });
);
hono.get( hono.get(
"/strategies", "/strategies",
describeRoute({ tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => { async (c) => {
const { include_disabled } = c.req.valid("query"); const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false); const { strategies, basepath } = this.auth.toJSON(false);
@@ -174,15 +155,6 @@ export class AuthController extends Controller {
}, },
); );
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return hono.all("*", (c) => c.notFound()); return hono.all("*", (c) => c.notFound());
} }
} }
+1 -3
View File
@@ -1,8 +1,6 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { type Static, StringRecord, objectTransform } from "core/utils"; import { type Static, StringRecord, Type, objectTransform } from "core/utils";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export const Strategies = { export const Strategies = {
password: { password: {
+89 -138
View File
@@ -1,22 +1,19 @@
import { $console, type DB, Exception } from "core"; import { type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { import {
type Static, type Static,
StringEnum, StringEnum,
type TObject, type TObject,
Type,
parse, parse,
runtimeSupports, runtimeSupports,
truncate, transformObject,
} from "core/utils"; } from "core/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie"; import type { CookieOptions } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller"; 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 type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; export type JWTPayload = Parameters<typeof sign>[0];
@@ -25,12 +22,11 @@ export const strategyActions = ["create", "change"] as const;
export type StrategyActionName = (typeof strategyActions)[number]; export type StrategyActionName = (typeof strategyActions)[number];
export type StrategyAction<S extends TObject = TObject> = { export type StrategyAction<S extends TObject = TObject> = {
schema: S; schema: S;
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>; preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
}; };
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>; export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference // @todo: add schema to interface to ensure proper inference
// @todo: add tests (e.g. invalid strategy_value)
export interface Strategy { export interface Strategy {
getController: (auth: Authenticator) => Hono<any>; getController: (auth: Authenticator) => Hono<any>;
getType: () => string; getType: () => string;
@@ -40,22 +36,29 @@ export interface Strategy {
getActions?: () => StrategyActions; getActions?: () => StrategyActions;
} }
export type User = DB["users"]; export type User = {
id: number;
email: string;
username: string;
password: string;
role: string;
};
export type ProfileExchange = { export type ProfileExchange = {
email?: string; email?: string;
strategy?: string; username?: string;
strategy_value?: string; sub?: string;
password?: string;
[key: string]: any; [key: string]: any;
}; };
export type SafeUser = Omit<User, "strategy_value">; export type SafeUser = Omit<User, "password">;
export type CreateUser = Pick<User, "email"> & { [key: string]: any }; export type CreateUser = Pick<User, "email"> & { [key: string]: any };
export type AuthResponse = { user: SafeUser; token: string }; export type AuthResponse = { user: SafeUser; token: string };
export interface UserPool { export interface UserPool<Fields = "id" | "email" | "username"> {
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>; findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
create: (strategy: string, user: CreateUser) => Promise<User>; create: (user: CreateUser) => Promise<User | undefined>;
} }
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
@@ -97,17 +100,12 @@ export const authenticatorConfig = Type.Object({
type AuthConfig = Static<typeof authenticatorConfig>; type AuthConfig = Static<typeof authenticatorConfig>;
export type AuthAction = "login" | "register"; export type AuthAction = "login" | "register";
export type AuthResolveOptions = {
identifier?: "email" | string;
redirect?: string;
forceJsonResponse?: boolean;
};
export type AuthUserResolver = ( export type AuthUserResolver = (
action: AuthAction, action: AuthAction,
strategy: Strategy, strategy: Strategy,
identifier: string,
profile: ProfileExchange, profile: ProfileExchange,
opts?: AuthResolveOptions, ) => Promise<SafeUser | undefined>;
) => Promise<ProfileExchange | undefined>;
type AuthClaims = SafeUser & { type AuthClaims = SafeUser & {
iat: number; iat: number;
iss?: string; iss?: string;
@@ -115,117 +113,33 @@ type AuthClaims = SafeUser & {
}; };
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> { export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig; private readonly config: AuthConfig;
private readonly userResolver: AuthUserResolver;
constructor( constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
private readonly strategies: Strategies, this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
private readonly userPool: UserPool, this.strategies = strategies as Strategies;
config?: AuthConfig,
) {
this.config = parse(authenticatorConfig, config ?? {}); this.config = parse(authenticatorConfig, config ?? {});
} }
async resolveLogin( async resolve(
c: Context, action: AuthAction,
strategy: Strategy, strategy: Strategy,
profile: Partial<SafeUser>, identifier: string,
verify: (user: User) => Promise<void>, profile: ProfileExchange,
opts?: AuthResolveOptions, ): Promise<AuthResponse> {
) { //console.log("resolve", { action, strategy: strategy.getName(), profile });
try { const user = await this.userResolver(action, strategy, identifier, profile);
// @todo: centralize identifier and checks
// @todo: check identifier value (if allowed) if (user) {
const identifier = opts?.identifier || "email"; return {
if (typeof identifier !== "string" || identifier.length === 0) { user,
throw new InvalidConditionsException("Identifier must be a string"); token: await this.jwt(user),
} };
if (!(identifier in profile)) {
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
} }
const user = await this.userPool.findBy( throw new Error("User could not be resolved");
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 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 { getStrategies(): Strategies {
@@ -244,8 +158,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
// @todo: add jwt tests // @todo: add jwt tests
async jwt(_user: SafeUser | ProfileExchange): Promise<string> { async jwt(user: Omit<User, "password">): Promise<string> {
const user = pick(_user, this.config.jwt.fields); const prohibited = ["password"];
for (const prop of prohibited) {
if (prop in user) {
throw new Error(`Property "${prop}" is prohibited`);
}
}
const payload: JWTPayload = { const payload: JWTPayload = {
...user, ...user,
@@ -270,14 +189,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); 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> { async verify(jwt: string): Promise<AuthClaims | undefined> {
try { try {
const payload = await verify( const payload = await verify(
@@ -319,7 +230,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return token; return token;
} catch (e: any) { } catch (e: any) {
if (e instanceof Error) { if (e instanceof Error) {
$console.error("[getAuthCookie]", e.message); console.error("[Error:getAuthCookie]", e.message);
} }
return undefined; return undefined;
@@ -336,13 +247,11 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
private async setAuthCookie(c: Context<ServerEnv>, token: string) { private async setAuthCookie(c: Context<ServerEnv>, token: string) {
$console.debug("setting auth cookie", truncate(token));
const secret = this.config.jwt.secret; const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions); await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
} }
private async deleteAuthCookie(c: Context) { private async deleteAuthCookie(c: Context) {
$console.debug("deleting auth cookie");
await deleteCookie(c, "auth", this.cookieOptions); await deleteCookie(c, "auth", this.cookieOptions);
} }
@@ -358,6 +267,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
// @todo: move this to a server helper // @todo: move this to a server helper
isJsonRequest(c: Context): boolean { isJsonRequest(c: Context): boolean {
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
return c.req.header("Content-Type") === "application/json"; return c.req.header("Content-Type") === "application/json";
} }
@@ -381,6 +291,37 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return p; 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 // @todo: don't extract user from token, but from the database or cache
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> { async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
let token: string | undefined; let token: string | undefined;
@@ -405,3 +346,13 @@ 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,135 +1,152 @@
import { type Authenticator, InvalidCredentialsException, type User } from "auth"; import type { Authenticator, Strategy } from "auth";
import { $console, tbValidator as tb } from "core"; import { isDebug, tbValidator as tb } from "core";
import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils"; import { type Static, StringEnum, Type, parse } from "core/utils";
import { Hono } from "hono"; import { hash } from "core/utils";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { type Context, Hono } from "hono";
import * as tbbox from "@sinclair/typebox"; import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
import { Strategy } from "./Strategy";
const { Type } = tbbox; type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any };
const schema = StrictObject({ const schema = Type.Object({
hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }), hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }),
rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
}); });
export type PasswordStrategyOptions = Static<typeof schema>; export type PasswordStrategyOptions = Static<typeof schema>;
/*export type PasswordStrategyOptions2 = {
hashing?: "plain" | "bcrypt" | "sha256";
};*/
export class PasswordStrategy extends Strategy<typeof schema> { export class PasswordStrategy implements Strategy {
constructor(config: Partial<PasswordStrategyOptions> = {}) { private options: PasswordStrategyOptions;
super(config as any, "password", "password", "form");
this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => { 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 { return {
...input, ...input,
strategy_value: await this.hash(password), strategy_value: await this.hash(password),
}; };
}); },
),
};
} }
getSchema() { getSchema() {
return schema; return schema;
} }
private getPayloadSchema() { getType() {
return Type.Object({ return "password";
email: Type.String({
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
minLength: 8, // @todo: this should be configurable
}),
});
} }
async hash(password: string) { getMode() {
switch (this.config.hashing) { return "form" as const;
case "sha256":
return hash.sha256(password);
case "bcrypt": {
const salt = await bcryptGenSalt(this.config.rounds ?? 4);
return bcryptHash(password, salt);
}
default:
return password;
}
} }
async compare(actual: string, compare: string): Promise<boolean> { getName() {
switch (this.config.hashing) { return "password" as const;
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;
} }
} }
@@ -1,63 +0,0 @@
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 * as issuers from "./oauth/issuers";
export { export {
type PasswordStrategyOptions,
PasswordStrategy, PasswordStrategy,
type PasswordStrategyOptions,
OAuthStrategy, OAuthStrategy,
OAuthCallbackException, OAuthCallbackException,
CustomOAuthStrategy, CustomOAuthStrategy,
@@ -1,24 +1,28 @@
import { type Static, StrictObject, StringEnum } from "core/utils"; import { type Static, StringEnum, Type } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import type * as oauth from "oauth4webapi"; import type * as oauth from "oauth4webapi";
import { OAuthStrategy } from "./OAuthStrategy"; import { OAuthStrategy } from "./OAuthStrategy";
const { Type } = tbbox;
type SupportedTypes = "oauth2" | "oidc"; type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>; 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 UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = StrictObject( const oauthSchemaCustom = Type.Object(
{ {
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: Type.String(), name: Type.String(),
client: StrictObject({ client: Type.Object(
{
client_id: Type.String(), client_id: Type.String(),
client_secret: Type.String(), client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"]), token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
}), },
as: StrictObject({ {
additionalProperties: false,
},
),
as: Type.Object(
{
issuer: Type.String(), issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())), scopes_supported: Type.Optional(Type.Array(Type.String())),
@@ -26,10 +30,14 @@ const oauthSchemaCustom = StrictObject(
authorization_endpoint: Type.Optional(UrlString), authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString), token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString), userinfo_endpoint: Type.Optional(UrlString),
}), },
{
additionalProperties: false,
},
),
// @todo: profile mapping // @todo: profile mapping
}, },
{ title: "Custom OAuth" }, { title: "Custom OAuth", additionalProperties: false },
); );
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>; type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
@@ -54,11 +62,6 @@ export type IssuerConfig<UserInfo = any> = {
}; };
export class CustomOAuthStrategy extends OAuthStrategy { export class CustomOAuthStrategy extends OAuthStrategy {
constructor(config: OAuthConfigCustom) {
super(config as any);
this.type = "custom_oauth";
}
override getIssuerConfig(): IssuerConfig { override getIssuerConfig(): IssuerConfig {
return { ...this.config, profile: async (info) => info } as any; return { ...this.config, profile: async (info) => info } as any;
} }
@@ -67,4 +70,8 @@ export class CustomOAuthStrategy extends OAuthStrategy {
override getSchema() { override getSchema() {
return oauthSchemaCustom; return oauthSchemaCustom;
} }
override getType() {
return "custom_oauth";
}
} }
@@ -1,13 +1,10 @@
import type { AuthAction, Authenticator } from "auth"; import type { AuthAction, Authenticator, Strategy } from "auth";
import { Exception, isDebug } from "core"; import { Exception, isDebug } from "core";
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils"; import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie"; import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi"; import * as oauth from "oauth4webapi";
import * as issuers from "./issuers"; 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 ConfiguredIssuers = keyof typeof issuers;
type SupportedTypes = "oauth2" | "oidc"; type SupportedTypes = "oauth2" | "oidc";
@@ -16,12 +13,17 @@ type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & O
const schemaProvided = Type.Object( const schemaProvided = Type.Object(
{ {
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]), name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }), client: Type.Object(
client: StrictObject({ {
client_id: Type.String(), client_id: Type.String(),
client_secret: Type.String(), client_secret: Type.String(),
}), },
{
additionalProperties: false,
},
),
}, },
{ title: "OAuth" }, { title: "OAuth" },
); );
@@ -69,13 +71,11 @@ export class OAuthCallbackException extends Exception {
} }
} }
export class OAuthStrategy extends Strategy<typeof schemaProvided> { export class OAuthStrategy implements Strategy {
constructor(config: ProvidedOAuthConfig) { constructor(private _config: OAuthConfig) {}
super(config, "oauth", config.name, "external");
}
getSchema() { get config() {
return schemaProvided; return this._config;
} }
getIssuerConfig(): IssuerConfig { getIssuerConfig(): IssuerConfig {
@@ -103,7 +103,7 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
type: info.type, type: info.type,
client: { client: {
...info.client, ...info.client,
...this.config.client, ...this._config.client,
}, },
}; };
} }
@@ -172,7 +172,8 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
) { ) {
const config = await this.getConfig(); const config = await this.getConfig();
const { client, as, type } = config; const { client, as, type } = config;
//console.log("config", config);
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse( const parameters = oauth.validateAuthResponse(
as, as,
client, // no client_secret required client, // no client_secret required
@@ -180,9 +181,13 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
oauth.expectNoState, oauth.expectNoState,
); );
if (oauth.isOAuth2Error(parameters)) { if (oauth.isOAuth2Error(parameters)) {
//console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse"); throw new OAuthCallbackException(parameters, "validateAuthResponse");
} }
/*console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);*/
const response = await oauth.authorizationCodeGrantRequest( const response = await oauth.authorizationCodeGrantRequest(
as, as,
client, client,
@@ -190,9 +195,13 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
options.redirect_uri, options.redirect_uri,
options.state, options.state,
); );
//console.log("callback.response", response);
const challenges = oauth.parseWwwAuthenticateChallenges(response); const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) { if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed // @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate"); throw new OAuthCallbackException(challenges, "www-authenticate");
} }
@@ -207,13 +216,20 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
expectedNonce, expectedNonce,
); );
if (oauth.isOAuth2Error(result)) { if (oauth.isOAuth2Error(result)) {
console.log("callback.error", result);
// @todo: Handle OAuth 2.0 response body error // @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse"); throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
} }
//console.log("callback.result", result);
const claims = oauth.getValidatedIdTokenClaims(result); const claims = oauth.getValidatedIdTokenClaims(result);
//console.log("callback.IDTokenClaims", claims);
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!); const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest); 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 return await config.profile(resultUser, config, claims); // @todo: check claims
} }
@@ -224,7 +240,8 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
) { ) {
const config = await this.getConfig(); const config = await this.getConfig();
const { client, type, as, profile } = config; const { client, type, as, profile } = config;
console.log("config", { client, as, type });
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse( const parameters = oauth.validateAuthResponse(
as, as,
client, // no client_secret required client, // no client_secret required
@@ -232,9 +249,13 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
oauth.expectNoState, oauth.expectNoState,
); );
if (oauth.isOAuth2Error(parameters)) { if (oauth.isOAuth2Error(parameters)) {
console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse"); throw new OAuthCallbackException(parameters, "validateAuthResponse");
} }
console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);
const response = await oauth.authorizationCodeGrantRequest( const response = await oauth.authorizationCodeGrantRequest(
as, as,
client, client,
@@ -245,6 +266,9 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
const challenges = oauth.parseWwwAuthenticateChallenges(response); const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) { if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed // @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate"); throw new OAuthCallbackException(challenges, "www-authenticate");
} }
@@ -255,15 +279,19 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
try { try {
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response); result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) { if (oauth.isOAuth2Error(result)) {
console.log("error", result);
throw new Error(); // Handle OAuth 2.0 response body error throw new Error(); // Handle OAuth 2.0 response body error
} }
} catch (e) { } catch (e) {
result = (await copy.json()) as any; result = (await copy.json()) as any;
console.log("failed", result);
} }
const res2 = await oauth.userInfoRequest(as, client, result.access_token!); const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
const user = await res2.json(); const user = await res2.json();
console.log("res2", res2, user);
console.log("result", result);
return await config.profile(user, config, result); return await config.profile(user, config, result);
} }
@@ -273,6 +301,7 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
): Promise<UserProfile> { ): Promise<UserProfile> {
const type = this.getIssuerConfig().type; const type = this.getIssuerConfig().type;
console.log("type", type);
switch (type) { switch (type) {
case "oidc": case "oidc":
return await this.oidc(callbackParams, options); return await this.oidc(callbackParams, options);
@@ -296,6 +325,7 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
}; };
const setState = async (c: Context, config: TState): Promise<void> => { const setState = async (c: Context, config: TState): Promise<void> => {
console.log("--- setting state", config);
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, { await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
secure: true, secure: true,
httpOnly: true, httpOnly: true,
@@ -326,6 +356,7 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
const params = new URLSearchParams(url.search); const params = new URLSearchParams(url.search);
const state = await getState(c); const state = await getState(c);
console.log("state", state);
// @todo: add config option to determine if state.action is allowed // @todo: add config option to determine if state.action is allowed
const redirect_uri = const redirect_uri =
@@ -338,28 +369,21 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
state: state.state, state: state.state,
}); });
const safeProfile = { try {
email: profile.email, const data = await auth.resolve(state.action, this, profile.sub, profile);
strategy_value: profile.sub, console.log("******** RESOLVED ********", data);
} as const;
const verify = async (user) => { if (state.mode === "cookie") {
if (user.strategy_value !== profile.sub) { return await auth.respond(c, data, state.redirect);
throw new Exception("Invalid credentials");
} }
};
const opts = {
redirect: state.redirect,
forceJsonResponse: state.mode !== "cookie",
} as const;
switch (state.action) { return c.json(data);
case "login": } catch (e) {
return auth.resolveLogin(c, this, safeProfile, verify, opts); if (state.mode === "cookie") {
case "register": return await auth.respond(c, e, state.redirect);
return auth.resolveRegister(c, this, safeProfile, verify, opts); }
default:
throw new Error("Invalid action"); throw e;
} }
}); });
@@ -388,8 +412,10 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
redirect_uri, redirect_uri,
state, state,
}); });
//console.log("_state", state);
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" }); await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
console.log("--redirecting to", response.url);
return c.redirect(response.url); return c.redirect(response.url);
}); });
@@ -430,15 +456,28 @@ export class OAuthStrategy extends Strategy<typeof schemaProvided> {
return hono; return hono;
} }
override toJSON(secrets?: boolean) { getType() {
return "oauth";
}
getMode() {
return "external" as const;
}
getName() {
return this.config.name;
}
getSchema() {
return schemaProvided;
}
toJSON(secrets?: boolean) {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]); const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return { return {
...super.toJSON(secrets),
config: {
...config,
type: this.getIssuerConfig().type, type: this.getIssuerConfig().type,
}, ...config,
}; };
} }
} }
@@ -34,6 +34,8 @@ export const github: IssuerConfig<GithubUserInfo> = {
config: Omit<IssuerConfig, "profile">, config: Omit<IssuerConfig, "profile">,
tokenResponse: any, tokenResponse: any,
) => { ) => {
console.log("github info", info, config, tokenResponse);
try { try {
const res = await fetch("https://api.github.com/user/emails", { const res = await fetch("https://api.github.com/user/emails", {
headers: { headers: {
@@ -43,6 +45,7 @@ export const github: IssuerConfig<GithubUserInfo> = {
}, },
}); });
const data = (await res.json()) as GithubUserEmailResponse; const data = (await res.json()) as GithubUserEmailResponse;
console.log("data", data);
const email = data.find((e: any) => e.primary)?.email; const email = data.find((e: any) => e.primary)?.email;
if (!email) { if (!email) {
throw new Error("No primary email found"); throw new Error("No primary email found");
+15 -17
View File
@@ -1,4 +1,4 @@
import { $console, Exception, Permission } from "core"; import { Exception, Permission } from "core";
import { objectTransform } from "core/utils"; import { objectTransform } from "core/utils";
import type { Context } from "hono"; import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
@@ -14,6 +14,8 @@ export type GuardConfig = {
}; };
export type GuardContext = Context<ServerEnv> | GuardUserContext; export type GuardContext = Context<ServerEnv> | GuardUserContext;
const debug = false;
export class Guard { export class Guard {
permissions: Permission[]; permissions: Permission[];
roles?: Role[]; roles?: Role[];
@@ -81,12 +83,8 @@ export class Guard {
return this; return this;
} }
registerPermissions(permissions: Record<string, Permission>); registerPermissions(permissions: Permission[]) {
registerPermissions(permissions: Permission[]); for (const permission of permissions) {
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
this.registerPermission(permission); this.registerPermission(permission);
} }
@@ -97,13 +95,15 @@ export class Guard {
if (user && typeof user.role === "string") { if (user && typeof user.role === "string") {
const role = this.roles?.find((role) => role.name === user?.role); const role = this.roles?.find((role) => role.name === user?.role);
if (role) { if (role) {
$console.debug(`guard: role "${user.role}" found`); debug && console.log("guard: role found", [user.role]);
return role; return role;
} }
} }
$console.debug("guard: role not found", { debug &&
user, console.log("guard: role not found", {
user: user,
role: user?.role,
}); });
return this.getDefaultRole(); return this.getDefaultRole();
} }
@@ -120,14 +120,11 @@ export class Guard {
hasPermission(name: string, user?: GuardUserContext): boolean; hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) { if (!this.isEnabled()) {
//console.log("guard not enabled, allowing");
return true; return true;
} }
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; 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); const exists = this.permissionExists(name);
if (!exists) { if (!exists) {
throw new Error(`Permission ${name} does not exist`); throw new Error(`Permission ${name} does not exist`);
@@ -136,10 +133,10 @@ export class Guard {
const role = this.getUserRole(user); const role = this.getUserRole(user);
if (!role) { if (!role) {
$console.debug("guard: user has no role, denying"); debug && console.log("guard: role not found, denying");
return false; return false;
} else if (role.implicit_allow === true) { } else if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`); debug && console.log("guard: role implicit allow, allowing");
return true; return true;
} }
@@ -147,7 +144,8 @@ export class Guard {
(rolePermission) => rolePermission.permission.name === name, (rolePermission) => rolePermission.permission.name === name,
); );
$console.debug("guard: rolePermission, allowing?", { debug &&
console.log("guard: rolePermission, allowing?", {
permission: name, permission: name,
role: role.name, role: role.name,
allowing: !!rolePermission, allowing: !!rolePermission,
+7 -45
View File
@@ -1,66 +1,28 @@
import { Exception, isDebug } from "core"; import { Exception } from "core";
import { HttpStatus } from "core/utils";
export class AuthException extends Exception { export class UserExistsException 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 name = "UserExistsException";
override code = HttpStatus.UNPROCESSABLE_ENTITY; override code = 422;
constructor() { constructor() {
super("User already exists"); super("User already exists");
} }
} }
export class UserNotFoundException extends AuthException { export class UserNotFoundException extends Exception {
override name = "UserNotFoundException"; override name = "UserNotFoundException";
override code = HttpStatus.NOT_FOUND; override code = 404;
constructor() { constructor() {
super("User not found"); super("User not found");
} }
} }
export class InvalidCredentialsException extends AuthException { export class InvalidCredentialsException extends Exception {
override name = "InvalidCredentialsException"; override name = "InvalidCredentialsException";
override code = HttpStatus.UNAUTHORIZED; override code = 401;
constructor() { constructor() {
super("Invalid credentials"); 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
View File
@@ -1,4 +1,5 @@
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors"; export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
export { sha256 } from "./utils/hash";
export { export {
type ProfileExchange, type ProfileExchange,
type Strategy, type Strategy,
+13
View File
@@ -0,0 +1,13 @@
// @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("");
}
-2
View File
@@ -8,8 +8,6 @@ export const config: CliCommand = (program) => {
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .action((options) => {
const config = getDefaultConfig(); const config = getDefaultConfig();
// biome-ignore lint/suspicious/noConsoleLog:
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
}); });
}; };
-1
View File
@@ -32,6 +32,5 @@ async function action(options: { out?: string; clean?: boolean }) {
// delete ".vite" directory in out // delete ".vite" directory in out
await fs.rm(path.resolve(out, ".vite"), { recursive: true }); await fs.rm(path.resolve(out, ".vite"), { recursive: true });
// biome-ignore lint/suspicious/noConsoleLog:
console.log(c.green(`Assets copied to: ${c.bold(out)}`)); console.log(c.green(`Assets copied to: ${c.bold(out)}`));
} }
+7 -18
View File
@@ -43,12 +43,10 @@ export const create: CliCommand = (program) => {
function errorOutro() { function errorOutro() {
$p.outro(color.red("Failed to create project.")); $p.outro(color.red("Failed to create project."));
// biome-ignore lint/suspicious/noConsoleLog:
console.log( console.log(
color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") + 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"), color.cyan("https://github.com/bknd-io/bknd/issues"),
); );
// biome-ignore lint/suspicious/noConsoleLog:
console.log(""); console.log("");
process.exit(1); process.exit(1);
} }
@@ -57,14 +55,7 @@ async function onExit() {
await flush(); await flush();
} }
async function action(options: { async function action(options: { template?: string; dir?: string; integration?: string, yes?: boolean, clean?: boolean }) {
template?: string;
dir?: string;
integration?: string;
yes?: boolean;
clean?: boolean;
}) {
// biome-ignore lint/suspicious/noConsoleLog:
console.log(""); console.log("");
const $t = createScoped("create"); const $t = createScoped("create");
$t.capture("start", { $t.capture("start", {
@@ -105,12 +96,10 @@ async function action(options: {
$t.properties.at = "dir"; $t.properties.at = "dir";
if (fs.existsSync(downloadOpts.dir)) { if (fs.existsSync(downloadOpts.dir)) {
const clean = const clean = options.clean ?? await $p.confirm({
options.clean ??
(await $p.confirm({
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
initialValue: false, initialValue: false,
})); });
if ($p.isCancel(clean)) { if ($p.isCancel(clean)) {
await onExit(); await onExit();
process.exit(1); process.exit(1);
@@ -185,6 +174,8 @@ async function action(options: {
process.exit(1); process.exit(1);
} }
//console.log("integration", { type, integration });
const choices = templates.filter((t) => t.integration === integration); const choices = templates.filter((t) => t.integration === integration);
if (choices.length === 0) { if (choices.length === 0) {
await onExit(); await onExit();
@@ -270,11 +261,9 @@ async function action(options: {
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
{ {
const install = const install = options.yes ?? await $p.confirm({
options.yes ??
(await $p.confirm({
message: "Install dependencies?", message: "Install dependencies?",
})); });
if ($p.isCancel(install)) { if ($p.isCancel(install)) {
await onExit(); await onExit();
@@ -29,9 +29,7 @@ export const cloudflare = {
{ dir: ctx.dir }, { dir: ctx.dir },
); );
const db = ctx.skip const db = ctx.skip ? "d1" : await $p.select({
? "d1"
: await $p.select({
message: "What database do you want to use?", message: "What database do you want to use?",
options: [ options: [
{ label: "Cloudflare D1", value: "d1" }, { label: "Cloudflare D1", value: "d1" },
@@ -66,9 +64,7 @@ export const cloudflare = {
async function createD1(ctx: TemplateSetupCtx) { async function createD1(ctx: TemplateSetupCtx) {
const default_db = "data"; const default_db = "data";
const name = ctx.skip const name = ctx.skip ? default_db : await $p.text({
? default_db
: await $p.text({
message: "Enter database name", message: "Enter database name",
initialValue: default_db, initialValue: default_db,
placeholder: default_db, placeholder: default_db,
@@ -157,16 +153,13 @@ async function createLibsql(ctx: TemplateSetupCtx) {
} }
async function createR2(ctx: TemplateSetupCtx) { async function createR2(ctx: TemplateSetupCtx) {
const create = ctx.skip const create = ctx.skip ?? await $p.confirm({
? false
: await $p.confirm({
message: "Do you want to use a R2 bucket?", message: "Do you want to use a R2 bucket?",
initialValue: true, initialValue: true,
}); });
if ($p.isCancel(create)) { if ($p.isCancel(create)) {
process.exit(1); process.exit(1);
} }
if (!create) { if (!create) {
await overrideJson( await overrideJson(
WRANGLER_FILE, WRANGLER_FILE,
@@ -180,9 +173,7 @@ async function createR2(ctx: TemplateSetupCtx) {
} }
const default_bucket = "bucket"; const default_bucket = "bucket";
const name = ctx.skip const name = ctx.skip ? default_bucket : await $p.text({
? default_bucket
: await $p.text({
message: "Enter bucket name", message: "Enter bucket name",
initialValue: default_bucket, initialValue: default_bucket,
placeholder: default_bucket, placeholder: default_bucket,
-2
View File
@@ -17,7 +17,6 @@ export const debug: CliCommand = (program) => {
const subjects = { const subjects = {
paths: async () => { paths: async () => {
// biome-ignore lint/suspicious/noConsoleLog:
console.log("[PATHS]", { console.log("[PATHS]", {
rootpath: getRootPath(), rootpath: getRootPath(),
distPath: getDistPath(), distPath: getDistPath(),
@@ -28,7 +27,6 @@ const subjects = {
}); });
}, },
routes: async () => { routes: async () => {
// biome-ignore lint/suspicious/noConsoleLog:
console.log("[APP ROUTES]"); console.log("[APP ROUTES]");
const credentials = getConnectionCredentialsFromEnv(); const credentials = getConnectionCredentialsFromEnv();
const app = createApp({ connection: credentials }); const app = createApp({ connection: credentials });
-1
View File
@@ -5,4 +5,3 @@ export { debug } from "./debug";
export { user } from "./user"; export { user } from "./user";
export { create } from "./create"; export { create } from "./create";
export { copyAssets } from "./copy-assets"; export { copyAssets } from "./copy-assets";
export { types } from "./types";
+4 -6
View File
@@ -1,10 +1,9 @@
import path from "node:path"; import path from "node:path";
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { $console, config } from "core"; import { config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import open from "open"; import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys"; import { fileExists, getRelativeDistPath } from "../../utils/sys";
import type { App } from "App";
export const PLATFORMS = ["node", "bun"] as const; export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number]; export type Platform = (typeof PLATFORMS)[number];
@@ -33,11 +32,11 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer( export async function startServer(
server: Platform, server: Platform,
app: App, app: any,
options: { port: number; open?: boolean }, options: { port: number; open?: boolean },
) { ) {
const port = options.port; const port = options.port;
$console.log(`Using ${server} serve`); console.log(`Using ${server} serve`);
switch (server) { switch (server) {
case "node": { case "node": {
@@ -59,8 +58,7 @@ export async function startServer(
} }
const url = `http://localhost:${port}`; const url = `http://localhost:${port}`;
$console.info("Server listening on", url); console.info("Server listening on", url);
if (options.open) { if (options.open) {
await open(url); await open(url);
} }
+9 -18
View File
@@ -17,13 +17,12 @@ import {
startServer, startServer,
} from "./platform"; } from "./platform";
import { makeConfig } from "adapter"; import { makeConfig } from "adapter";
import { isBun as $isBun } from "cli/utils/sys";
const env_files = [".env", ".dev.vars"]; const env_files = [".env", ".dev.vars"];
dotenv.config({ dotenv.config({
path: env_files.map((file) => path.resolve(process.cwd(), file)), path: env_files.map((file) => path.resolve(process.cwd(), file)),
}); });
const isBun = $isBun(); const isBun = typeof Bun !== "undefined";
export const run: CliCommand = (program) => { export const run: CliCommand = (program) => {
program program
@@ -77,12 +76,12 @@ async function makeApp(config: MakeAppConfig) {
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { async () => {
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
if (config.onBuilt) { if (config.onBuilt) {
await config.onBuilt(app); await config.onBuilt(app);
} }
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
}, },
"sync", "sync",
); );
@@ -92,14 +91,14 @@ async function makeApp(config: MakeAppConfig) {
} }
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
const config = makeConfig(_config, process.env); const config = makeConfig(_config, { env: process.env });
return makeApp({ return makeApp({
...config, ...config,
server: { platform }, server: { platform },
}); });
} }
type RunOptions = { async function action(options: {
port: number; port: number;
memory?: boolean; memory?: boolean;
config?: string; config?: string;
@@ -107,9 +106,8 @@ type RunOptions = {
dbToken?: string; dbToken?: string;
server: Platform; server: Platform;
open?: boolean; open?: boolean;
}; }) {
colorizeConsole(console);
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
let app: App | undefined = undefined; let app: App | undefined = undefined;
@@ -149,19 +147,12 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
// if nothing helps, create a file based app // if nothing helps, create a file based app
if (!app) { if (!app) {
const connection = { url: "file:data.db" } as Config; const connection = { url: "file:data.db" } as Config;
console.info("Using fallback connection", c.cyan(connection.url)); console.info("Using connection", c.cyan(connection.url));
app = await makeApp({ app = await makeApp({
connection, connection,
server: { platform: options.server }, server: { platform: options.server },
}); });
} }
return app;
}
async function action(options: RunOptions) {
colorizeConsole(console);
const app = await makeAppFromEnv(options);
await startServer(options.server, app, { port: options.port, open: options.open }); await startServer(options.server, app, { port: options.port, open: options.open });
} }
-1
View File
@@ -8,7 +8,6 @@ export const schema: CliCommand = (program) => {
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .action((options) => {
const schema = getDefaultSchema(); const schema = getDefaultSchema();
// biome-ignore lint/suspicious/noConsoleLog:
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
}); });
}; };
-1
View File
@@ -1 +0,0 @@
export * from "./types";
-37
View File
@@ -1,37 +0,0 @@
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());
}
}
+36 -71
View File
@@ -1,32 +1,28 @@
import { import { password as $password, text as $text } from "@clack/prompts";
isCancel as $isCancel,
log as $log,
password as $password,
text as $text,
} from "@clack/prompts";
import type { App } from "App"; import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { makeAppFromEnv } from "cli/commands/run"; import { makeConfigApp } from "cli/commands/run";
import type { CliCommand } from "cli/types"; import { getConfigPath } from "cli/commands/run/platform";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Argument } from "commander"; import { Argument } from "commander";
import { $console } from "core";
import c from "picocolors";
import { isBun } from "cli/utils/sys";
export const user: CliCommand = (program) => { export const user: CliCommand = (program) => {
program program
.command("user") .command("user")
.description("create/update users, or generate a token (auth)") .description("create and update user (auth)")
.addArgument( .addArgument(new Argument("<action>", "action to perform").choices(["create", "update"]))
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
)
.action(action); .action(action);
}; };
async function action(action: "create" | "update" | "token", options: any) { async function action(action: "create" | "update", options: any) {
const app = await makeAppFromEnv({ const configFilePath = await getConfigPath();
server: "node", if (!configFilePath) {
}); console.error("config file not found");
return;
}
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
const app = await makeConfigApp(config, options.server);
switch (action) { switch (action) {
case "create": case "create":
@@ -35,9 +31,6 @@ async function action(action: "create" | "update" | "token", options: any) {
case "update": case "update":
await update(app, options); await update(app, options);
break; break;
case "token":
await token(app, options);
break;
} }
} }
@@ -45,8 +38,7 @@ async function create(app: App, options: any) {
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
if (!strategy) { if (!strategy) {
$log.error("Password strategy not configured"); throw new Error("Password strategy not configured");
process.exit(1);
} }
const email = await $text({ const email = await $text({
@@ -58,7 +50,6 @@ async function create(app: App, options: any) {
return; return;
}, },
}); });
if ($isCancel(email)) process.exit(1);
const password = await $password({ const password = await $password({
message: "Enter password", message: "Enter password",
@@ -69,17 +60,20 @@ async function create(app: App, options: any) {
return; return;
}, },
}); });
if ($isCancel(password)) process.exit(1);
if (typeof email !== "string" || typeof password !== "string") {
console.log("Cancelled");
process.exit(0);
}
try { try {
const created = await app.createUser({ const created = await app.createUser({
email, email,
password: await strategy.hash(password as string), password: await strategy.hash(password as string),
}); });
$log.success(`Created user: ${c.cyan(created.email)}`); console.log("Created:", created);
} catch (e) { } catch (e) {
$log.error("Error creating user"); console.error("Error", e);
$console.error(e);
} }
} }
@@ -98,14 +92,17 @@ async function update(app: App, options: any) {
return; return;
}, },
})) as string; })) as string;
if ($isCancel(email)) process.exit(1); if (typeof email !== "string") {
console.log("Cancelled");
process.exit(0);
}
const { data: user } = await em.repository(users_entity).findOne({ email }); const { data: user } = await em.repository(users_entity).findOne({ email });
if (!user) { if (!user) {
$log.error("User not found"); console.log("User not found");
process.exit(1); process.exit(0);
} }
$log.info(`User found: ${c.cyan(user.email)}`); console.log("User found:", user);
const password = await $password({ const password = await $password({
message: "New Password?", message: "New Password?",
@@ -116,7 +113,10 @@ async function update(app: App, options: any) {
return; return;
}, },
}); });
if ($isCancel(password)) process.exit(1); if (typeof password !== "string") {
console.log("Cancelled");
process.exit(0);
}
try { try {
function togglePw(visible: boolean) { function togglePw(visible: boolean) {
@@ -134,43 +134,8 @@ async function update(app: App, options: any) {
}); });
togglePw(false); togglePw(false);
$log.success(`Updated user: ${c.cyan(user.email)}`); console.log("Updated:", user);
} catch (e) { } catch (e) {
$log.error("Error updating user"); console.error("Error", e);
$console.error(e);
} }
} }
async function token(app: App, options: any) {
if (isBun()) {
$log.error("Please use node to generate tokens");
process.exit(1);
}
const config = app.module.auth.toJSON(true);
const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em;
const email = (await $text({
message: "Which user? Enter email",
validate: (v) => {
if (!v.includes("@")) {
return "Invalid email";
}
return;
},
})) as string;
if ($isCancel(email)) process.exit(1);
const { data: user } = await em.repository(users_entity).findOne({ email });
if (!user) {
$log.error("User not found");
process.exit(1);
}
$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 -20
View File
@@ -1,17 +1,8 @@
import { $console } from "core";
import { execSync, exec as nodeExec } from "node:child_process"; import { execSync, exec as nodeExec } from "node:child_process";
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
export function isBun(): boolean {
try {
return typeof Bun !== "undefined";
} catch (e) {
return false;
}
}
export function getRootPath() { export function getRootPath() {
const _path = path.dirname(url.fileURLToPath(import.meta.url)); const _path = path.dirname(url.fileURLToPath(import.meta.url));
// because of "src", local needs one more level up // because of "src", local needs one more level up
@@ -49,16 +40,6 @@ 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> }) { export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
const stdio = opts?.silent ? "pipe" : "inherit"; const stdio = opts?.silent ? "pipe" : "inherit";
const output = execSync(command, { const output = execSync(command, {
+3
View File
@@ -29,6 +29,7 @@ export class AwsClient extends Aws4fetchClient {
} }
getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): string { getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): string {
//console.log("super:getUrl", path, searchParamsObj);
const url = new URL(path); const url = new URL(path);
const converted = this.convertParams(searchParamsObj); const converted = this.convertParams(searchParamsObj);
Object.entries(converted).forEach(([key, value]) => { Object.entries(converted).forEach(([key, value]) => {
@@ -75,6 +76,8 @@ export class AwsClient extends Aws4fetchClient {
} }
const raw = await response.text(); const raw = await response.text();
//console.log("raw", raw);
//console.log(JSON.stringify(xmlToObject(raw), null, 2));
return xmlToObject(raw) as T; return xmlToObject(raw) as T;
} }
+1 -5
View File
@@ -3,11 +3,7 @@
*/ */
import type { Generated } from "kysely"; import type { Generated } from "kysely";
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>; export type PrimaryFieldType = number | Generated<number>;
export interface AppEntity<IdType extends number = number> {
id: PrimaryFieldType<IdType>;
}
export interface DB { export interface DB {
// make sure to make unknown as "any" // make sure to make unknown as "any"
-9
View File
@@ -13,15 +13,6 @@ export function isDebug(): boolean {
} }
} }
export function getVersion(): string {
try {
// @ts-expect-error - this is a global variable in dev
return __version;
} catch (e) {
return "0.0.0";
}
}
const envs = { const envs = {
// used in $console to determine the log level // used in $console to determine the log level
cli_log_level: { cli_log_level: {
+2 -5
View File
@@ -1,12 +1,9 @@
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { HttpStatus } from "./utils/reqres";
export class Exception extends Error { export class Exception extends Error {
code: ContentfulStatusCode = HttpStatus.BAD_REQUEST; code = 400;
override name = "Exception"; override name = "Exception";
protected _context = undefined; protected _context = undefined;
constructor(message: string, code?: ContentfulStatusCode) { constructor(message: string, code?: number) {
super(message); super(message);
if (code) { if (code) {
this.code = code; this.code = code;
-4
View File
@@ -14,10 +14,6 @@ export abstract class Event<Params = any, Returning = void> {
params: Params; params: Params;
returned: boolean = false; 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 { validate(value: Returning): Event<Params, Returning> | void {
throw new EventReturnedWithoutValidation(this as any, value); throw new EventReturnedWithoutValidation(this as any, value);
} }
+8 -4
View File
@@ -1,6 +1,5 @@
import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { type Event, type EventClass, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
import { $console } from "core";
export type RegisterListenerConfig = export type RegisterListenerConfig =
| ListenerMode | ListenerMode
@@ -84,6 +83,10 @@ export class EventManager<
} else { } else {
// @ts-expect-error // @ts-expect-error
slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug; 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); return !!this.events.find((e) => slug === e.slug);
@@ -125,7 +128,8 @@ export class EventManager<
if (listener.id) { if (listener.id) {
const existing = this.listeners.find((l) => l.id === listener.id); const existing = this.listeners.find((l) => l.id === listener.id);
if (existing) { if (existing) {
$console.debug(`Listener with id "${listener.id}" already exists.`); // @todo: add a verbose option?
//console.warn(`Listener with id "${listener.id}" already exists.`);
return this; return this;
} }
} }
@@ -187,7 +191,7 @@ export class EventManager<
// @ts-expect-error slug is static // @ts-expect-error slug is static
const slug = event.constructor.slug; const slug = event.constructor.slug;
if (!this.enabled) { if (!this.enabled) {
$console.debug("EventManager disabled, not emitting", slug); console.log("EventManager disabled, not emitting", slug);
return event; return event;
} }
@@ -236,7 +240,7 @@ export class EventManager<
} catch (e) { } catch (e) {
if (e instanceof InvalidEventReturn) { if (e instanceof InvalidEventReturn) {
this.options?.onInvalidReturn?.(_event, e); 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) { } else if (this.options?.onError) {
this.options.onError(_event, e); this.options.onError(_event, e);
} else { } else {
+1 -4
View File
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator"; export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors"; export { Exception, BkndError } from "./errors";
export { isDebug, env } from "./env"; export { isDebug, env } from "./env";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config"; export { type PrimaryFieldType, config, type DB } from "./config";
export { AwsClient } from "./clients/aws/AwsClient"; export { AwsClient } from "./clients/aws/AwsClient";
export { export {
SimpleRenderer, SimpleRenderer,
@@ -25,11 +25,8 @@ export {
isBooleanLike, isBooleanLike,
} from "./object/query/query"; } from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry"; export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash";
export { s, jsc, describeRoute } from "./object/schema";
export * from "./console"; export * from "./console";
export * from "./events";
// compatibility // compatibility
export type Middleware = MiddlewareHandler<any, any, any>; export type Middleware = MiddlewareHandler<any, any, any>;
+13 -1
View File
@@ -73,7 +73,6 @@ export class SchemaObject<Schema extends TObject> {
forceParse: true, forceParse: true,
skipMark: this.isForceParse(), skipMark: this.isForceParse(),
}); });
// regardless of "noEmit" this should always be triggered // regardless of "noEmit" this should always be triggered
const updatedConfig = await this.onBeforeUpdate(this._config, valid); const updatedConfig = await this.onBeforeUpdate(this._config, valid);
@@ -123,15 +122,19 @@ export class SchemaObject<Schema extends TObject> {
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value; const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects // overwrite arrays and primitives, only deep merge objects
// @ts-ignore // @ts-ignore
//console.log("---alt:new", _jsonp(mergeObject(current, partial)));
const config = mergeObjectWith(current, partial, (objValue, srcValue) => { const config = mergeObjectWith(current, partial, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) { if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue; return srcValue;
} }
}); });
//console.log("---new", _jsonp(config));
//console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) { if (this.options?.overwritePaths) {
const keys = getFullPathKeys(value).map((k) => { const keys = getFullPathKeys(value).map((k) => {
// only prepend path if given // only prepend path if given
@@ -146,6 +149,7 @@ export class SchemaObject<Schema extends TObject> {
} }
}); });
}); });
//console.log("overwritePaths", keys, overwritePaths);
if (overwritePaths.length > 0) { if (overwritePaths.length > 0) {
// filter out less specific paths (but only if more than 1) // filter out less specific paths (but only if more than 1)
@@ -153,10 +157,12 @@ export class SchemaObject<Schema extends TObject> {
overwritePaths.length > 1 overwritePaths.length > 1
? overwritePaths.filter((k) => ? overwritePaths.filter((k) =>
overwritePaths.some((k2) => { overwritePaths.some((k2) => {
//console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
return k2 !== k && k2.startsWith(k); return k2 !== k && k2.startsWith(k);
}), }),
) )
: overwritePaths; : overwritePaths;
//console.log("specific", specific);
for (const p of specific) { for (const p of specific) {
set(config, p, get(partial, p)); set(config, p, get(partial, p));
@@ -164,6 +170,8 @@ export class SchemaObject<Schema extends TObject> {
} }
} }
//console.log("patch", _jsonp({ path, value, partial, config, current }));
const newConfig = await this.set(config); const newConfig = await this.set(config);
return [partial, newConfig]; return [partial, newConfig];
} }
@@ -173,11 +181,14 @@ export class SchemaObject<Schema extends TObject> {
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value; const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects // overwrite arrays and primitives, only deep merge objects
// @ts-ignore // @ts-ignore
const config = set(current, path, value); const config = set(current, path, value);
//console.log("overwrite", { path, value, partial, config, current });
const newConfig = await this.set(config); const newConfig = await this.set(config);
return [partial, newConfig]; return [partial, newConfig];
} }
@@ -187,6 +198,7 @@ export class SchemaObject<Schema extends TObject> {
if (p.length > 1) { if (p.length > 1) {
const parent = p.slice(0, -1).join("."); const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) { if (!has(this._config, parent)) {
//console.log("parent", parent, JSON.stringify(this._config, null, 2));
throw new Error(`Parent path "${parent}" does not exist`); throw new Error(`Parent path "${parent}" does not exist`);
} }
} }
+13 -14
View File
@@ -1,5 +1,4 @@
// biome-ignore lint/suspicious/noConstEnum: <explanation> enum Change {
export const enum DiffChange {
Add = "a", Add = "a",
Remove = "r", Remove = "r",
Edit = "e", Edit = "e",
@@ -8,8 +7,8 @@ export const enum DiffChange {
type Object = object; type Object = object;
type Primitive = string | number | boolean | null | object | any[] | undefined; type Primitive = string | number | boolean | null | object | any[] | undefined;
export interface DiffEntry { interface DiffEntry {
t: DiffChange | string; t: Change | string;
p: (string | number)[]; p: (string | number)[];
o: Primitive; o: Primitive;
n: Primitive; n: Primitive;
@@ -48,7 +47,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
if (typeof oldValue !== typeof newValue) { if (typeof oldValue !== typeof newValue) {
diffs.push({ diffs.push({
t: DiffChange.Edit, t: Change.Edit,
p: path, p: path,
o: oldValue, o: oldValue,
n: newValue, n: newValue,
@@ -58,14 +57,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
for (let i = 0; i < maxLength; i++) { for (let i = 0; i < maxLength; i++) {
if (i >= oldValue.length) { if (i >= oldValue.length) {
diffs.push({ diffs.push({
t: DiffChange.Add, t: Change.Add,
p: [...path, i], p: [...path, i],
o: undefined, o: undefined,
n: newValue[i], n: newValue[i],
}); });
} else if (i >= newValue.length) { } else if (i >= newValue.length) {
diffs.push({ diffs.push({
t: DiffChange.Remove, t: Change.Remove,
p: [...path, i], p: [...path, i],
o: oldValue[i], o: oldValue[i],
n: undefined, n: undefined,
@@ -81,14 +80,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
for (const key of allKeys) { for (const key of allKeys) {
if (!(key in oldValue)) { if (!(key in oldValue)) {
diffs.push({ diffs.push({
t: DiffChange.Add, t: Change.Add,
p: [...path, key], p: [...path, key],
o: undefined, o: undefined,
n: newValue[key], n: newValue[key],
}); });
} else if (!(key in newValue)) { } else if (!(key in newValue)) {
diffs.push({ diffs.push({
t: DiffChange.Remove, t: Change.Remove,
p: [...path, key], p: [...path, key],
o: oldValue[key], o: oldValue[key],
n: undefined, n: undefined,
@@ -99,7 +98,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
} }
} else { } else {
diffs.push({ diffs.push({
t: DiffChange.Edit, t: Change.Edit,
p: path, p: path,
o: oldValue, o: oldValue,
n: newValue, n: newValue,
@@ -137,9 +136,9 @@ function applyChange(obj: Object, diff: DiffEntry) {
const parent = getParent(obj, path.slice(0, -1)); const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!; const key = path[path.length - 1]!;
if (type === DiffChange.Add || type === DiffChange.Edit) { if (type === Change.Add || type === Change.Edit) {
parent[key] = newValue; parent[key] = newValue;
} else if (type === DiffChange.Remove) { } else if (type === Change.Remove) {
if (Array.isArray(parent)) { if (Array.isArray(parent)) {
parent.splice(key as number, 1); parent.splice(key as number, 1);
} else { } else {
@@ -153,13 +152,13 @@ function revertChange(obj: Object, diff: DiffEntry) {
const parent = getParent(obj, path.slice(0, -1)); const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!; const key = path[path.length - 1]!;
if (type === DiffChange.Add) { if (type === Change.Add) {
if (Array.isArray(parent)) { if (Array.isArray(parent)) {
parent.splice(key as number, 1); parent.splice(key as number, 1);
} else { } else {
delete parent[key]; delete parent[key];
} }
} else if (type === DiffChange.Remove || type === DiffChange.Edit) { } else if (type === Change.Remove || type === Change.Edit) {
parent[key] = oldValue; parent[key] = oldValue;
} }
} }
-4
View File
@@ -34,8 +34,6 @@ type ExpressionMap<Exps extends Expressions> = {
? E ? E
: never; : never;
}; };
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
type ExpressionCondition<Exps extends Expressions> = { type ExpressionCondition<Exps extends Expressions> = {
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] }; [K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
}[keyof ExpressionMap<Exps>]; }[keyof ExpressionMap<Exps>];
@@ -197,7 +195,5 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
const fns = _build(query, expressions, options); const fns = _build(query, expressions, options);
return _validate(fns); return _validate(fns);
}, },
expressions,
expressionKeys: expressions.map((e) => e.key) as ExpressionKeys<Exps>,
}; };
} }
-52
View File
@@ -1,52 +0,0 @@
import { mergeObject } from "core/utils";
//export { jsc, type Options, type Hook } from "./validator";
import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono";
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
export { s };
export class InvalidSchemaError extends Error {
constructor(
public schema: s.TAnySchema,
public value: unknown,
public errors: s.ErrorDetail[] = [],
) {
super(
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
`Error: ${JSON.stringify(errors[0], null, 2)}`,
);
}
}
export type ParseOptions = {
withDefaults?: boolean;
coerse?: boolean;
clone?: boolean;
};
const cloneSchema = <S extends s.TSchema>(schema: S): S => {
const json = schema.toJSON();
return s.fromSchema(json) as S;
};
export function parse<S extends s.TAnySchema>(
_schema: S,
v: unknown,
opts: ParseOptions = {},
): s.StaticCoerced<S> {
const schema = (opts.clone ? cloneSchema(_schema as any) : _schema) as s.TSchema;
const value = opts.coerse !== false ? schema.coerce(v) : v;
const result = schema.validate(value, {
shortCircuit: true,
ignoreUnsupported: true,
});
if (!result.valid) throw new InvalidSchemaError(schema, v, result.errors);
if (opts.withDefaults) {
return mergeObject(schema.template({ withOptional: true }), value) as any;
}
return value as any;
}
-63
View File
@@ -1,63 +0,0 @@
import type { Context, Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
import { validator as honoValidator } from "hono/validator";
import type { Static, StaticCoerced, TAnySchema } from "jsonv-ts";
export type Options = {
coerce?: boolean;
includeSchema?: boolean;
};
type ValidationResult = {
valid: boolean;
errors: {
keywordLocation: string;
instanceLocation: string;
error: string;
data?: unknown;
}[];
};
export type Hook<T, E extends Env, P extends string> = (
result: { result: ValidationResult; data: T },
c: Context<E, P>,
) => Response | Promise<Response> | void;
export const validator = <
// @todo: somehow hono prevents the usage of TSchema
Schema extends TAnySchema,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
Opts extends Options = Options,
Out = Opts extends { coerce: false } ? Static<Schema> : StaticCoerced<Schema>,
I extends Input = {
in: { [K in Target]: Static<Schema> };
out: { [K in Target]: Out };
},
>(
target: Target,
schema: Schema,
options?: Opts,
hook?: Hook<Out, E, P>,
): MiddlewareHandler<E, P, I> => {
// @ts-expect-error not typed well
return honoValidator(target, async (_value, c) => {
const value = options?.coerce !== false ? schema.coerce(_value) : _value;
// @ts-ignore
const result = schema.validate(value);
if (!result.valid) {
return c.json({ ...result, schema }, 400);
}
if (hook) {
const hookResult = hook({ result, data: value as Out }, c);
if (hookResult) {
return hookResult;
}
}
return value as Out;
});
};
export const jsc = validator;
-1
View File
@@ -1 +0,0 @@
export { tbValidator } from "./tbValidator";
-29
View File
@@ -1,29 +0,0 @@
import type { Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
import { validator } from "hono/validator";
import type { Static, TSchema } from "simple-jsonschema-ts";
export const honoValidator = <
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
const Schema extends TSchema = TSchema,
Out = Static<Schema>,
I extends Input = {
in: { [K in Target]: Static<Schema> };
out: { [K in Target]: Static<Schema> };
},
>(
target: Target,
schema: Schema,
): MiddlewareHandler<E, P, I> => {
// @ts-expect-error not typed well
return validator(target, async (value, c) => {
const coersed = schema.coerce(value);
const result = schema.validate(coersed);
if (!result.valid) {
return c.json({ ...result, schema }, 400);
}
return coersed as Out;
});
};
@@ -1,70 +0,0 @@
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);
}
});
});
+39 -14
View File
@@ -1,13 +1,17 @@
import { get } from "lodash-es"; import { Liquid, LiquidError } from "liquidjs";
import type { RenderOptions } from "liquidjs/dist/liquid-options";
import { BkndError } from "../errors";
export type TemplateObject = Record<string, string | Record<string, string>>; export type TemplateObject = Record<string, string | Record<string, string>>;
export type TemplateTypes = string | TemplateObject | any; export type TemplateTypes = string | TemplateObject;
export type SimpleRendererOptions = { export type SimpleRendererOptions = RenderOptions & {
renderKeys?: boolean; renderKeys?: boolean;
}; };
export class SimpleRenderer { export class SimpleRenderer {
private engine = new Liquid();
constructor( constructor(
private variables: Record<string, any> = {}, private variables: Record<string, any> = {},
private options: SimpleRendererOptions = {}, private options: SimpleRendererOptions = {},
@@ -18,6 +22,7 @@ export class SimpleRenderer {
} }
static hasMarkup(template: string | object): boolean { static hasMarkup(template: string | object): boolean {
//console.log("has markup?", template);
let flat: string = ""; let flat: string = "";
if (Array.isArray(template) || typeof template === "object") { if (Array.isArray(template) || typeof template === "object") {
@@ -29,29 +34,49 @@ export class SimpleRenderer {
flat = String(template); flat = String(template);
} }
const checks = ["{{"]; //console.log("** flat", flat);
return checks.some((check) => flat.includes(check));
const checks = ["{{", "{%", "{#", "{:"];
const hasMarkup = checks.some((check) => flat.includes(check));
//console.log("--has markup?", hasMarkup);
return hasMarkup;
} }
async render<Given extends TemplateTypes = TemplateTypes>(template: Given): Promise<Given> { async render<Given extends TemplateTypes>(template: Given): Promise<Given> {
if (typeof template === "undefined" || template === null) return template; try {
if (typeof template === "string") { if (typeof template === "string") {
return (await this.renderString(template)) as unknown as Given; return (await this.renderString(template)) as unknown as Given;
} else if (Array.isArray(template)) { } else if (Array.isArray(template)) {
return (await Promise.all(template.map((item) => this.render(item)))) as unknown as Given; return (await Promise.all(
template.map((item) => this.render(item)),
)) as unknown as Given;
} else if (typeof template === "object") { } else if (typeof template === "object") {
return (await this.renderObject(template as any)) as unknown as Given; 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,
},
};
throw new BkndError(e.message, details, "liquid");
}
throw e;
} }
throw new Error("Invalid template type"); throw new Error("Invalid template type");
} }
async renderString(template: string): Promise<string> { async renderString(template: string): Promise<string> {
return template.replace(/{{\s*([^{}]+?)\s*}}/g, (_, expr: string) => { //console.log("*** renderString", template, this.variables);
const value = get(this.variables, expr.trim()); return this.engine.parseAndRender(template, this.variables, this.options);
return value == null ? "" : String(value);
});
} }
async renderObject(template: TemplateObject): Promise<TemplateObject> { async renderObject(template: TemplateObject): Promise<TemplateObject> {
+2 -2
View File
@@ -9,11 +9,13 @@ export class DebugLogger {
} }
context(context: string) { context(context: string) {
//console.log("[ settings context ]", context, this._context);
this._context.push(context); this._context.push(context);
return this; return this;
} }
clear() { clear() {
//console.log("[ clear context ]", this._context.pop(), this._context);
this._context.pop(); this._context.pop();
return this; return this;
} }
@@ -31,8 +33,6 @@ export class DebugLogger {
const indents = " ".repeat(Math.max(this._context.length - 1, 0)); const indents = " ".repeat(Math.max(this._context.length - 1, 0));
const context = const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
// biome-ignore lint/suspicious/noConsoleLog: <explanation>
console.log(indents, context, time, ...args); console.log(indents, context, time, ...args);
this.last = now; this.last = now;
+1
View File
@@ -4,6 +4,7 @@ import weekOfYear from "dayjs/plugin/weekOfYear.js";
declare module "dayjs" { declare module "dayjs" {
interface Dayjs { interface Dayjs {
week(): number; week(): number;
week(value: number): dayjs.Dayjs; week(value: number): dayjs.Dayjs;
} }
} }
+2 -3
View File
@@ -2,7 +2,6 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
import { randomString } from "core/utils/strings"; import { randomString } from "core/utils/strings";
import type { Context } from "hono"; import type { Context } from "hono";
import { invariant } from "core/utils/runtime"; import { invariant } from "core/utils/runtime";
import { $console } from "../console";
export function getContentName(request: Request): string | undefined; export function getContentName(request: Request): string | undefined;
export function getContentName(contentDisposition: string): string | undefined; export function getContentName(contentDisposition: string): string | undefined;
@@ -131,7 +130,7 @@ export async function getFileFromContext(c: Context<any>): Promise<File> {
return await blobToFile(v); return await blobToFile(v);
} }
} catch (e) { } catch (e) {
$console.warn("Error parsing form data", e); console.warn("Error parsing form data", e);
} }
} else { } else {
try { try {
@@ -142,7 +141,7 @@ export async function getFileFromContext(c: Context<any>): Promise<File> {
return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType });
} }
} catch (e) { } catch (e) {
$console.warn("Error parsing blob", e); console.warn("Error parsing blob", e);
} }
} }
-60
View File
@@ -359,63 +359,3 @@ export function getPath(
throw new Error(`Invalid path: ${path.join(".")}`); 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}`);
}
// lodash-es compatible `pick` with perfect type inference
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce(
(acc, key) => {
if (key in obj) {
acc[key] = obj[key];
}
return acc;
},
{} as Pick<T, K>,
);
}
+8 -14
View File
@@ -1,3 +1,7 @@
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> { export function headersToObject(headers: Headers): Record<string, string> {
if (!headers) return {}; if (!headers) return {};
return { ...Object.fromEntries(headers.entries()) }; return { ...Object.fromEntries(headers.entries()) };
@@ -98,7 +102,7 @@ export function decodeSearch(str) {
export const enum HttpStatus { export const enum HttpStatus {
// Informational responses (100199) // Informational responses (100199)
CONTINUE = 100, CONTINUE = 100,
//SWITCHING_PROTOCOLS = 101, SWITCHING_PROTOCOLS = 101,
PROCESSING = 102, PROCESSING = 102,
EARLY_HINTS = 103, EARLY_HINTS = 103,
@@ -107,8 +111,8 @@ export const enum HttpStatus {
CREATED = 201, CREATED = 201,
ACCEPTED = 202, ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203, NON_AUTHORITATIVE_INFORMATION = 203,
//NO_CONTENT = 204, NO_CONTENT = 204,
//RESET_CONTENT = 205, RESET_CONTENT = 205,
PARTIAL_CONTENT = 206, PARTIAL_CONTENT = 206,
MULTI_STATUS = 207, MULTI_STATUS = 207,
ALREADY_REPORTED = 208, ALREADY_REPORTED = 208,
@@ -119,7 +123,7 @@ export const enum HttpStatus {
MOVED_PERMANENTLY = 301, MOVED_PERMANENTLY = 301,
FOUND = 302, FOUND = 302,
SEE_OTHER = 303, SEE_OTHER = 303,
//NOT_MODIFIED = 304, NOT_MODIFIED = 304,
USE_PROXY = 305, USE_PROXY = 305,
TEMPORARY_REDIRECT = 307, TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308, PERMANENT_REDIRECT = 308,
@@ -168,13 +172,3 @@ export const enum HttpStatus {
NOT_EXTENDED = 510, NOT_EXTENDED = 510,
NETWORK_AUTHENTICATION_REQUIRED = 511, NETWORK_AUTHENTICATION_REQUIRED = 511,
} }
// biome-ignore lint/suspicious/noConstEnum: <explanation>
export const enum HttpStatusEmpty {
// Informational responses (100199)
SWITCHING_PROTOCOLS = 101,
// Successful responses (200299)
NO_CONTENT = 204,
RESET_CONTENT = 205,
// Redirection messages (300399)
NOT_MODIFIED = 304,
}
-5
View File
@@ -132,8 +132,3 @@ export function slugify(str: string): string {
.replace(/-+/g, "-") // remove consecutive hyphens .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;
}
+56 -51
View File
@@ -1,11 +1,18 @@
import * as tb from "@sinclair/typebox"; import {
import type { Kind,
type ObjectOptions,
type SchemaOptions,
type Static,
type StaticDecode,
type StringOptions,
type TLiteral,
type TLiteralValue,
type TObject,
type TRecord,
type TSchema,
type TString,
Type,
TypeRegistry, TypeRegistry,
Static,
StaticDecode,
TSchema,
SchemaOptions,
TObject,
} from "@sinclair/typebox"; } from "@sinclair/typebox";
import { import {
DefaultErrorFunction, DefaultErrorFunction,
@@ -36,7 +43,7 @@ const validationSymbol = Symbol("tb-parse-validation");
export class TypeInvalidError extends Error { export class TypeInvalidError extends Error {
errors: ValueError[]; errors: ValueError[];
constructor( constructor(
public schema: tb.TSchema, public schema: TSchema,
public data: unknown, public data: unknown,
message?: string, message?: string,
) { ) {
@@ -85,28 +92,29 @@ export function mark(obj: any, validated = true) {
} }
} }
export function parse<Schema extends tb.TSchema = tb.TSchema>( export function parse<Schema extends TSchema = TSchema>(
schema: Schema, schema: Schema,
data: RecursivePartial<tb.Static<Schema>>, data: RecursivePartial<Static<Schema>>,
options?: ParseOptions, options?: ParseOptions,
): tb.Static<Schema> { ): Static<Schema> {
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) { if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
if (options?.useDefaults === false) { if (options?.useDefaults === false) {
return data as tb.Static<typeof schema>; return data as Static<typeof schema>;
} }
// this is important as defaults are expected // this is important as defaults are expected
return Default(schema, data as any) as tb.Static<Schema>; return Default(schema, data as any) as Static<Schema>;
} }
const parsed = options?.useDefaults === false ? data : Default(schema, data); const parsed = options?.useDefaults === false ? data : Default(schema, data);
if (Check(schema, parsed)) { if (Check(schema, parsed)) {
options?.skipMark !== true && mark(parsed, true); options?.skipMark !== true && mark(parsed, true);
return parsed as tb.Static<typeof schema>; return parsed as Static<typeof schema>;
} else if (options?.onError) { } else if (options?.onError) {
options.onError(Errors(schema, data)); options.onError(Errors(schema, data));
} else { } else {
//console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2));
throw new TypeInvalidError(schema, data); throw new TypeInvalidError(schema, data);
} }
@@ -114,24 +122,26 @@ export function parse<Schema extends tb.TSchema = tb.TSchema>(
return undefined as any; return undefined as any;
} }
export function parseDecode<Schema extends tb.TSchema = tb.TSchema>( export function parseDecode<Schema extends TSchema = TSchema>(
schema: Schema, schema: Schema,
data: RecursivePartial<tb.StaticDecode<Schema>>, data: RecursivePartial<StaticDecode<Schema>>,
): tb.StaticDecode<Schema> { ): StaticDecode<Schema> {
//console.log("parseDecode", schema, data);
const parsed = Default(schema, data); const parsed = Default(schema, data);
if (Check(schema, parsed)) { if (Check(schema, parsed)) {
return parsed as tb.StaticDecode<typeof schema>; return parsed as StaticDecode<typeof schema>;
} }
//console.log("errors", ...Errors(schema, data));
throw new TypeInvalidError(schema, data); throw new TypeInvalidError(schema, data);
} }
export function strictParse<Schema extends tb.TSchema = tb.TSchema>( export function strictParse<Schema extends TSchema = TSchema>(
schema: Schema, schema: Schema,
data: tb.Static<Schema>, data: Static<Schema>,
options?: ParseOptions, options?: ParseOptions,
): tb.Static<Schema> { ): Static<Schema> {
return parse(schema, data as any, options); return parse(schema, data as any, options);
} }
@@ -140,14 +150,11 @@ export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) {
return typeof value === "string" && schema.enum.includes(value); return typeof value === "string" && schema.enum.includes(value);
}); });
} }
registerCustomTypeboxKinds(tb.TypeRegistry); registerCustomTypeboxKinds(TypeRegistry);
export const StringEnum = <const T extends readonly string[]>( export const StringEnum = <const T extends readonly string[]>(values: T, options?: StringOptions) =>
values: T, Type.Unsafe<T[number]>({
options?: tb.StringOptions, [Kind]: "StringEnum",
) =>
tb.Type.Unsafe<T[number]>({
[tb.Kind]: "StringEnum",
type: "string", type: "string",
enum: values, enum: values,
...options, ...options,
@@ -155,47 +162,45 @@ export const StringEnum = <const T extends readonly string[]>(
// key value record compatible with RJSF and typebox inference // key value record compatible with RJSF and typebox inference
// acting like a Record, but using an Object with additionalProperties // acting like a Record, but using an Object with additionalProperties
export const StringRecord = <T extends tb.TSchema>(properties: T, options?: tb.ObjectOptions) => export const StringRecord = <T extends TSchema>(properties: T, options?: ObjectOptions) =>
tb.Type.Object({}, { ...options, additionalProperties: properties }) as unknown as tb.TRecord< Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord<
tb.TString, TString,
typeof properties typeof properties
>; >;
// fixed value that only be what is given + prefilled // fixed value that only be what is given + prefilled
export const Const = <T extends tb.TLiteralValue = tb.TLiteralValue>( export const Const = <T extends TLiteralValue = TLiteralValue>(value: T, options?: SchemaOptions) =>
value: T, Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral<T>;
options?: tb.SchemaOptions,
) =>
tb.Type.Literal(value, {
...options,
default: value,
const: value,
readOnly: true,
}) as tb.TLiteral<T>;
export const StringIdentifier = tb.Type.String({ export const StringIdentifier = Type.String({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$", pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
minLength: 2, minLength: 2,
maxLength: 150, 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) => { SetErrorFunction((error) => {
if (error?.schema?.errorMessage) { if (error?.schema?.errorMessage) {
return error.schema.errorMessage; return error.schema.errorMessage;
} }
if (error?.schema?.[tb.Kind] === "StringEnum") { if (error?.schema?.[Kind] === "StringEnum") {
return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`; return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`;
} }
return DefaultErrorFunction(error); return DefaultErrorFunction(error);
}); });
export type { Static, StaticDecode, TSchema, TObject, ValueError, SchemaOptions }; export {
Type,
export { Value, Default, Errors, Check }; type Static,
type StaticDecode,
type TSchema,
Kind,
type TObject,
type ValueError,
type SchemaOptions,
Value,
Default,
Errors,
Check,
};
-13
View File
@@ -61,19 +61,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
this.setBuilt(); this.setBuilt();
} }
override async onBeforeUpdate(from: AppDataConfig, to: AppDataConfig): Promise<AppDataConfig> {
// this is not 100% yet, since it could be legit
const entities = {
from: Object.keys(from.entities ?? {}),
to: Object.keys(to.entities ?? {}),
};
if (entities.from.length - entities.to.length > 1) {
throw new Error("Cannot remove more than one entity at a time");
}
return to;
}
getSchema() { getSchema() {
return dataConfigSchema; return dataConfigSchema;
} }
+27 -30
View File
@@ -1,6 +1,5 @@
import type { DB } from "core"; import type { DB } from "core";
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
import type { Insertable, Selectable, Updateable } from "kysely";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
@@ -26,23 +25,21 @@ export class DataApi extends ModuleApi<DataApiOptions> {
} }
} }
readOne<E extends keyof DB | string>( readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
id: PrimaryFieldType, id: PrimaryFieldType,
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}, query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>( return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
["entity", entity as any, id], ["entity", entity as any, id],
query, query,
); );
} }
readOneBy<E extends keyof DB | string>( readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {}, query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data>, "meta" | "data">; type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
return this.readMany(entity, { return this.readMany(entity, {
...query, ...query,
@@ -51,8 +48,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>; }).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
} }
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) { readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; entity: E,
query: RepoQueryIn = {},
) {
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">; type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
const input = query ?? this.options.defaultQuery; const input = query ?? this.options.defaultQuery;
@@ -65,70 +64,68 @@ export class DataApi extends ModuleApi<DataApiOptions> {
return this.post<T>(["entity", entity as any, "query"], input); return this.post<T>(["entity", entity as any, "query"], input);
} }
readManyByReference<E extends keyof DB | string, R extends keyof DB | string>( readManyByReference<
entity: E, E extends keyof DB | string,
id: PrimaryFieldType, R extends keyof DB | string,
reference: R, Data = R extends keyof DB ? DB[R] : EntityData,
query: RepoQueryIn = {}, >(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">>( return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
["entity", entity as any, id, reference], ["entity", entity as any, id, reference],
query ?? this.options.defaultQuery, query ?? this.options.defaultQuery,
); );
} }
createOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
input: Insertable<Input>, input: Omit<Data, "id">,
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input); return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
} }
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
input: Insertable<Input>[], input: Omit<Data, "id">[],
) { ) {
if (!input || !Array.isArray(input) || input.length === 0) { if (!input || !Array.isArray(input) || input.length === 0) {
throw new Error("input is required"); 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); return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
} }
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
id: PrimaryFieldType, id: PrimaryFieldType,
input: Updateable<Input>, input: Partial<Omit<Data, "id">>,
) { ) {
if (!id) throw new Error("ID is required"); 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); return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
} }
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
where: RepoQueryIn["where"], where: RepoQueryIn["where"],
update: Updateable<Input>, update: Partial<Omit<Data, "id">>,
) { ) {
this.requireObjectSet(where); this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], { return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
update, update,
where, where,
}); });
} }
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) { deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
) {
if (!id) throw new Error("ID is required"); 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]); return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
} }
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) { deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
) {
this.requireObjectSet(where); this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where); return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
} }
+70 -172
View File
@@ -1,4 +1,5 @@
import { $console, isDebug } from "core"; import { isDebug, tbValidator as tb } from "core";
import { StringEnum, Type } from "core/utils";
import { import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
@@ -6,15 +7,13 @@ import {
type MutatorResponse, type MutatorResponse,
type RepoQuery, type RepoQuery,
type RepositoryResponse, type RepositoryResponse,
repoQuery, querySchema,
} from "data"; } from "data";
import type { Handler } from "hono/types"; import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules"; import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema"; import type { AppDataConfig } from "../data-schema";
import { omitKeys } from "core/utils";
export class DataController extends Controller { export class DataController extends Controller {
constructor( constructor(
@@ -46,6 +45,7 @@ export class DataController extends Controller {
const template = { data: res.data, meta }; const template = { data: res.data, meta };
// @todo: this works but it breaks in FE (need to improve DataTable) // @todo: this works but it breaks in FE (need to improve DataTable)
//return objectCleanEmpty(template) as any;
// filter empty // filter empty
return Object.fromEntries( return Object.fromEntries(
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null), Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null),
@@ -56,6 +56,7 @@ export class DataController extends Controller {
const template = { data: res.data }; const template = { data: res.data };
// filter empty // filter empty
//return objectCleanEmpty(template);
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined)); return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
} }
@@ -70,7 +71,11 @@ export class DataController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const entitiesEnum = this.getEntitiesEnum(this.em);
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 // @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any { function handler<HH extends Handler>(name: string, h: HH): any {
@@ -83,10 +88,6 @@ export class DataController extends Controller {
// info // info
hono.get( hono.get(
"/", "/",
describeRoute({
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => { handler("data info", (c) => {
// sample implementation // sample implementation
return c.json(this.em.toJSON()); return c.json(this.em.toJSON());
@@ -94,22 +95,9 @@ export class DataController extends Controller {
); );
// sync endpoint // sync endpoint
hono.get( hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
"/sync", const force = c.req.query("force") === "1";
permission(DataPermissions.databaseSync), const drop = c.req.query("drop") === "1";
describeRoute({
summary: "Sync database schema",
tags: ["data"],
}),
jsc(
"query",
s.partialObject({
force: s.boolean(),
drop: s.boolean(),
}),
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force); //console.log("force", force);
const tables = await this.em.schema().introspect(); const tables = await this.em.schema().introspect();
//console.log("tables", tables); //console.log("tables", tables);
@@ -118,21 +106,13 @@ export class DataController extends Controller {
drop, drop,
}); });
return c.json({ tables: tables.map((t) => t.name), changes }); return c.json({ tables: tables.map((t) => t.name), changes });
}, });
);
/** /**
* Schema endpoints * Schema endpoints
*/ */
// read entity schema // read entity schema
hono.get( hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
"/schema.json",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
}),
async (c) => {
const $id = `${this.config.basepath}/schema.json`; const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries( const schemas = Object.fromEntries(
this.em.entities.map((e) => [ this.em.entities.map((e) => [
@@ -147,27 +127,24 @@ export class DataController extends Controller {
$id, $id,
properties: schemas, properties: schemas,
}); });
}, });
);
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
describeRoute({ tb(
summary: "Retrieve entity schema",
tags: ["data"],
}),
jsc(
"param", "param",
s.object({ Type.Object({
entity: entitiesEnum, entity: Type.String(),
context: s.string({ enum: ["create", "update"], default: "create" }).optional(), context: Type.Optional(StringEnum(["create", "update"])),
}), }),
), ),
async (c) => { async (c) => {
//console.log("request", c.req.raw);
const { entity, context } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.warn("not found:", entity, definedEntities);
return this.notFound(c); return this.notFound(c);
} }
const _entity = this.em.entity(entity); const _entity = this.em.entity(entity);
@@ -191,15 +168,7 @@ export class DataController extends Controller {
/** /**
* Info endpoints * Info endpoints
*/ */
hono.get( hono.get("/info/:entity", async (c) => {
"/info/:entity",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
@@ -222,8 +191,7 @@ export class DataController extends Controller {
target: $rels(this.em.relations.targetRelationsOf(_entity)), target: $rels(this.em.relations.targetRelationsOf(_entity)),
}, },
}); });
}, });
);
return hono.all("*", (c) => c.notFound()); return hono.all("*", (c) => c.notFound());
} }
@@ -232,7 +200,10 @@ export class DataController extends Controller {
const { permission } = this.middlewares; const { permission } = this.middlewares;
const hono = this.create(); const hono = this.create();
const entitiesEnum = this.getEntitiesEnum(this.em); 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);
/** /**
* Function endpoints * Function endpoints
@@ -241,19 +212,14 @@ export class DataController extends Controller {
hono.post( hono.post(
"/:entity/fn/count", "/:entity/fn/count",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
describeRoute({ tb("param", Type.Object({ entity: Type.String() })),
summary: "Count entities",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const where = c.req.valid("json") as any; const where = (await c.req.json()) as any;
const result = await this.em.repository(entity).count(where); const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count }); return c.json({ entity, count: result.count });
}, },
@@ -263,19 +229,14 @@ export class DataController extends Controller {
hono.post( hono.post(
"/:entity/fn/exists", "/:entity/fn/exists",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
describeRoute({ tb("param", Type.Object({ entity: Type.String() })),
summary: "Check if entity exists",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const where = c.req.valid("json") as any; const where = c.req.json() as any;
const result = await this.em.repository(entity).exists(where); const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists }); return c.json({ entity, exists: result.exists });
}, },
@@ -285,32 +246,15 @@ export class DataController extends Controller {
* Read endpoints * Read endpoints
*/ */
// read many // read many
const saveRepoQuery = s.partialObject({
...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }),
select: s.array(s.string()),
join: s.array(s.string()),
});
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
// @ts-ignore
(p) => pick.includes(p.name),
) as any),
];
hono.get( hono.get(
"/:entity", "/:entity",
describeRoute({
summary: "Read many",
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })), tb("param", Type.Object({ entity: Type.String() })),
jsc("query", repoQuery, { skipOpenAPI: true }), tb("query", querySchema),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.warn("not found:", entity, definedEntities);
return this.notFound(c); return this.notFound(c);
} }
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
@@ -323,22 +267,17 @@ export class DataController extends Controller {
// read one // read one
hono.get( hono.get(
"/:entity/:id", "/:entity/:id",
describeRoute({
summary: "Read one",
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
jsc( tb(
"param", "param",
s.object({ Type.Object({
entity: entitiesEnum, entity: Type.String(),
id: s.string(), id: tbNumber,
}), }),
), ),
jsc("query", repoQuery, { skipOpenAPI: true }), tb("query", querySchema),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -352,23 +291,18 @@ export class DataController extends Controller {
// read many by reference // read many by reference
hono.get( hono.get(
"/:entity/:id/:reference", "/:entity/:id/:reference",
describeRoute({
summary: "Read many by reference",
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
jsc( tb(
"param", "param",
s.object({ Type.Object({
entity: entitiesEnum, entity: Type.String(),
id: s.string(), id: tbNumber,
reference: s.string(), reference: Type.String(),
}), }),
), ),
jsc("query", repoQuery, { skipOpenAPI: true }), tb("query", querySchema),
async (c) => { async (c) => {
const { entity, id, reference } = c.req.valid("param"); const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -383,33 +317,18 @@ export class DataController extends Controller {
); );
// func query // func query
const fnQuery = s.partialObject({
...saveRepoQuery.properties,
with: s.object({}),
});
hono.post( hono.post(
"/:entity/query", "/:entity/query",
describeRoute({
summary: "Query entities",
requestBody: {
content: {
"application/json": {
schema: fnQuery.toJSON(),
example: fnQuery.template({ withOptional: true }),
},
},
},
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })), tb("param", Type.Object({ entity: Type.String() })),
jsc("json", repoQuery, { skipOpenAPI: true }), tb("json", querySchema),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const options = (await c.req.json()) as RepoQuery; const options = (await c.req.valid("json")) as RepoQuery;
//console.log("options", options);
const result = await this.em.repository(entity).findMany(options); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
@@ -422,15 +341,11 @@ export class DataController extends Controller {
// insert one // insert one
hono.post( hono.post(
"/:entity", "/:entity",
describeRoute({
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate), permission(DataPermissions.entityCreate),
jsc("param", s.object({ entity: entitiesEnum })), tb("param", Type.Object({ entity: Type.String() })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -449,17 +364,13 @@ export class DataController extends Controller {
// update many // update many
hono.patch( hono.patch(
"/:entity", "/:entity",
describeRoute({
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum })), tb("param", Type.Object({ entity: Type.String() })),
jsc( tb(
"json", "json",
s.object({ Type.Object({
update: s.object({}), update: Type.Object({}),
where: repoQuery.properties.where, where: querySchema.properties.where,
}), }),
), ),
async (c) => { async (c) => {
@@ -480,15 +391,10 @@ export class DataController extends Controller {
// update one // update one
hono.patch( hono.patch(
"/:entity/:id", "/:entity/:id",
describeRoute({
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
jsc("json", s.object({})),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -502,14 +408,10 @@ export class DataController extends Controller {
// delete one // delete one
hono.delete( hono.delete(
"/:entity/:id", "/:entity/:id",
describeRoute({
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -522,19 +424,15 @@ export class DataController extends Controller {
// delete many // delete many
hono.delete( hono.delete(
"/:entity", "/:entity",
describeRoute({
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum })), tb("param", Type.Object({ entity: Type.String() })),
jsc("json", repoQuery.properties.where), tb("json", querySchema.properties.where),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const where = (await c.req.json()) as RepoQuery["where"]; const where = c.req.valid("json") as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where); const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));

Some files were not shown because too many files have changed in this diff Show More