mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
27 Commits
feat/init-e2e
...
v0.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
| af6cb0c8f0 | |||
| 5a693c0370 | |||
| 262588decc | |||
| 17ab35e245 | |||
| db795ec050 | |||
| 773df544dd | |||
| 0ac7d1fd6e | |||
| 6694c63990 | |||
| b3f95f9552 | |||
| 372f94d22a | |||
| d6f94a2ce1 | |||
| 89a39a7dc6 | |||
| 88cf65f792 | |||
| 07723ce6ae | |||
| 9010401af6 | |||
| 4c11789ea8 | |||
| 2988e4c3bd | |||
| ad7926db4c | |||
| 53a3dcdee7 | |||
| 53467d6750 | |||
| a80a731498 | |||
| 7e1757b7f4 | |||
| 2c29e06fb8 | |||
| ca86fa58ac | |||
| de984fa101 | |||
| a12d4e13d0 | |||
| fa6c7acaf5 |
@@ -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.5"
|
bun-version: "1.2.14"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
+3
-1
@@ -29,4 +29,6 @@ packages/media/.env
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.git_old
|
.git_old
|
||||||
docker/tmp
|
docker/tmp
|
||||||
|
.debug
|
||||||
|
.history
|
||||||
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
test-results
|
|
||||||
playwright-report
|
playwright-report
|
||||||
bknd.config.*
|
test-results
|
||||||
|
bknd.config.*
|
||||||
|
__test__/helper.d.ts
|
||||||
@@ -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 "../../src/core/utils";
|
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
|
||||||
import { MediaApi } from "../../src/media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { assetsPath, assetsTmpPath } from "../helper";
|
import { assetsPath, assetsTmpPath } from "../helper";
|
||||||
|
|
||||||
const mockedBackend = new Hono()
|
const mockedBackend = new Hono()
|
||||||
@@ -39,10 +39,28 @@ 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}`;
|
||||||
@@ -103,8 +121,12 @@ describe("MediaApi", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should upload file in various ways", async () => {
|
it("should upload file in various ways", async () => {
|
||||||
// @ts-ignore tests
|
const api = new MediaApi(
|
||||||
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) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { TObject, TString } from "@sinclair/typebox";
|
import { type TObject, type TString, Type } from "@sinclair/typebox";
|
||||||
import { Registry } from "../../src/core/registry/Registry";
|
import { Registry } from "core";
|
||||||
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 "../../../src/core/utils";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
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 "../../../src/core/object/query/object-query";
|
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
|
||||||
|
|
||||||
describe("object-query", () => {
|
describe("object-query", () => {
|
||||||
const q: ObjectQuery = { name: "Michael" };
|
const q: ObjectQuery = { name: "Michael" };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Entity, NumberField, TextField } from "../../../src/data";
|
import { Entity, NumberField, TextField } from "data";
|
||||||
|
import * as p from "data/prototype";
|
||||||
|
|
||||||
describe("[data] Entity", async () => {
|
describe("[data] Entity", async () => {
|
||||||
const entity = new Entity("test", [
|
const entity = new Entity("test", [
|
||||||
@@ -47,14 +48,7 @@ describe("[data] Entity", async () => {
|
|||||||
expect(entity.getField("new_field")).toBe(field);
|
expect(entity.getField("new_field")).toBe(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
// @todo: move this to ClientApp
|
test.only("types", async () => {
|
||||||
/*test("serialize and deserialize", async () => {
|
console.log(entity.toTypes());
|
||||||
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,5 +266,12 @@ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-80
@@ -1,25 +1,18 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { Value, _jsonp } from "../../src/core/utils";
|
import { getDummyConnection } from "../helper";
|
||||||
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
import { type WhereQuery, WhereBuilder } from "data";
|
||||||
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
|
|
||||||
import { getDummyConnection } from "./helper";
|
|
||||||
|
|
||||||
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
function qb() {
|
||||||
const result = Value.Decode(querySchema, input);
|
const c = getDummyConnection();
|
||||||
expect(result).toEqual(expected);
|
const kysely = c.dummyConnection.kysely;
|
||||||
};
|
return kysely.selectFrom("t").selectAll();
|
||||||
|
}
|
||||||
describe("data-query-impl", () => {
|
function compile(q: WhereQuery) {
|
||||||
function qb() {
|
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||||
const c = getDummyConnection();
|
return { sql, parameters };
|
||||||
const kysely = c.dummyConnection.kysely;
|
}
|
||||||
return kysely.selectFrom("t").selectAll();
|
|
||||||
}
|
|
||||||
function compile(q: WhereQuery) {
|
|
||||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
|
||||||
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]],
|
||||||
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
|
|||||||
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" } });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Type } from "../../../../src/core/utils";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { Entity, EntityIndex, Field } from "../../../../src/data";
|
import { Entity, EntityIndex, Field } from "../../../../src/data";
|
||||||
|
|
||||||
class TestField extends Field {
|
class TestField extends Field {
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows";
|
import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export class StringifyTask<Output extends string> extends Task<
|
||||||
|
typeof StringifyTask.schema,
|
||||||
|
Output
|
||||||
|
> {
|
||||||
|
type = "stringify";
|
||||||
|
|
||||||
|
static override schema = Type.Optional(
|
||||||
|
Type.Object({
|
||||||
|
input: Type.Optional(Type.String()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
return JSON.stringify(this.params.input) as Output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("SubFlowTask", async () => {
|
describe("SubFlowTask", async () => {
|
||||||
test("Simple Subflow", async () => {
|
test("Simple Subflow", async () => {
|
||||||
@@ -22,8 +40,6 @@ 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");
|
||||||
});
|
});
|
||||||
@@ -40,8 +56,8 @@ describe("SubFlowTask", async () => {
|
|||||||
loop: true,
|
loop: true,
|
||||||
input: [1, 2, 3],
|
input: [1, 2, 3],
|
||||||
});
|
});
|
||||||
const task3 = new RenderTask("render2", {
|
const task3 = new StringifyTask("stringify", {
|
||||||
render: `Subflow output: {{ sub.output | join: ", " }}`,
|
input: "{{ sub.output }}",
|
||||||
});
|
});
|
||||||
|
|
||||||
const flow = new Flow("test", [task, task2, task3], []);
|
const flow = new Flow("test", [task, task2, task3], []);
|
||||||
@@ -51,41 +67,6 @@ describe("SubFlowTask", async () => {
|
|||||||
const execution = flow.createExecution();
|
const execution = flow.createExecution();
|
||||||
await execution.start();
|
await execution.start();
|
||||||
|
|
||||||
console.log("errors", execution.getErrors());
|
expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"');
|
||||||
|
|
||||||
/*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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Type } from "../../src/core/utils";
|
import { Type } from "@sinclair/typebox";
|
||||||
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,62 +51,4 @@ 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,7 +1,8 @@
|
|||||||
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 { type Static, type StaticDecode, Type, parse } from "../../src/core/utils";
|
import { parse } from "../../src/core/utils";
|
||||||
|
import { type Static, type StaticDecode, Type } from "@sinclair/typebox";
|
||||||
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
|
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
|
||||||
import { dynamic } from "../../src/flows/tasks/Task";
|
import { dynamic } from "../../src/flows/tasks/Task";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// 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 { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
|
import { _jsonp, withDisabledConsole } from "../../src/core/utils";
|
||||||
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
||||||
|
|
||||||
/*beforeAll(disableConsoleLog);
|
/*beforeAll(disableConsoleLog);
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ beforeAll(disableConsoleLog);
|
|||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("MediaController", () => {
|
describe("MediaController", () => {
|
||||||
test.only("accepts direct", async () => {
|
test("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");
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe("AppAuth", () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: "some@body.com",
|
email: "some@body.com",
|
||||||
password: "123456",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
enableConsoleLog();
|
enableConsoleLog();
|
||||||
@@ -81,6 +81,65 @@ describe("AppAuth", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creates user on register (bcrypt)", async () => {
|
||||||
|
const auth = new AppAuth(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
strategies: {
|
||||||
|
password: {
|
||||||
|
type: "password",
|
||||||
|
config: {
|
||||||
|
hashing: "bcrypt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
jwt: {
|
||||||
|
secret: "123456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await auth.build();
|
||||||
|
await ctx.em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
// expect no users, but the query to pass
|
||||||
|
const res = await ctx.em.repository("users").findMany();
|
||||||
|
expect(res.data.length).toBe(0);
|
||||||
|
|
||||||
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
|
{
|
||||||
|
disableConsoleLog();
|
||||||
|
const res = await app.request("/password/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "some@body.com",
|
||||||
|
password: "12345678",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
enableConsoleLog();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
|
expect(users.length).toBe(1);
|
||||||
|
expect(users[0]?.email).toBe("some@body.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// check user in database
|
||||||
|
const rawUser = await ctx.connection.kysely
|
||||||
|
.selectFrom("users")
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(rawUser.strategy_value).toStartWith("$");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("registers auth middleware for bknd routes only", async () => {
|
test("registers auth middleware for bknd routes only", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
initialConfig: {
|
||||||
|
|||||||
@@ -1,13 +1,51 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { beforeEach, 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 } from "../../src/modules";
|
import { AppData, type ModuleBuildContext } from "../../src/modules";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { makeCtx, 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { type TSchema, Type, stripMark } from "../../src/core/utils";
|
import { stripMark } from "../../src/core/utils";
|
||||||
|
import { type TSchema, Type } from "@sinclair/typebox";
|
||||||
import { EntityManager, em, entity, index, text } from "../../src/data";
|
import { 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";
|
||||||
@@ -9,10 +10,10 @@ function createModule<Schema extends TSchema>(schema: Schema) {
|
|||||||
getSchema() {
|
getSchema() {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
toJSON() {
|
override toJSON() {
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
useForceParse() {
|
override useForceParse() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
|
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
|
||||||
import { entity, text } from "../../src/data";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { Module } from "../../src/modules/Module";
|
import { Connection, entity, text } from "data";
|
||||||
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
import { Module } from "modules/Module";
|
||||||
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
|
||||||
|
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 () => {
|
||||||
@@ -380,4 +383,128 @@ 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("Example Test Suite", () => {
|
||||||
|
it("should pass basic arithmetic", () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle async operations", async () => {
|
||||||
|
const result = await Promise.resolve(42);
|
||||||
|
expect(result).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Automatically cleanup after each test
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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 + "/", "")),
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-8
@@ -1,5 +1,6 @@
|
|||||||
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");
|
||||||
@@ -8,8 +9,13 @@ 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.log("Cleaning dist (w/o static)");
|
console.info("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 {} +`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +27,11 @@ function buildTypes() {
|
|||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types built");
|
console.info("Types built");
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types aliased");
|
console.info("Types aliased");
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -47,10 +53,10 @@ if (types && !watch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function banner(title: string) {
|
function banner(title: string) {
|
||||||
console.log("");
|
console.info("");
|
||||||
console.log("=".repeat(40));
|
console.info("=".repeat(40));
|
||||||
console.log(title.toUpperCase());
|
console.info(title.toUpperCase());
|
||||||
console.log("-".repeat(40));
|
console.info("-".repeat(40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
@@ -65,6 +71,7 @@ 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",
|
||||||
@@ -101,6 +108,7 @@ async function buildUi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
@@ -160,6 +168,7 @@ 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: [
|
||||||
@@ -211,7 +220,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
|||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
...define,
|
||||||
...overrides.define,
|
...overrides.define,
|
||||||
},
|
},
|
||||||
external: [
|
external: [
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { $ } from "bun";
|
||||||
|
import path from "node:path";
|
||||||
|
import c from "picocolors";
|
||||||
|
|
||||||
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
cmd: string[] | string,
|
||||||
|
opts: Bun.SpawnOptions.OptionsObject & {},
|
||||||
|
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
||||||
|
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = Bun.spawn(Array.isArray(cmd) ? cmd : cmd.split(" "), {
|
||||||
|
...opts,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read from stdout
|
||||||
|
const reader = proc.stdout.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
// Function to read chunks
|
||||||
|
let resolveCalled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const text = decoder.decode(value);
|
||||||
|
if (!resolveCalled) {
|
||||||
|
console.log(c.dim(text.replace(/\n$/, "")));
|
||||||
|
}
|
||||||
|
onChunk(
|
||||||
|
text,
|
||||||
|
(data) => {
|
||||||
|
resolve({ proc, data });
|
||||||
|
resolveCalled = true;
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
proc.exited.then((code) => {
|
||||||
|
if (code !== 0 && code !== 130) {
|
||||||
|
throw new Error(`Process exited with code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapters = {
|
||||||
|
node: {
|
||||||
|
dir: path.join(basePath, "examples/node"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run start",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /running on (http:\/\/.*)\n/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bun: {
|
||||||
|
dir: path.join(basePath, "examples/bun"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run start",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /running on (http:\/\/.*)\n/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cloudflare: {
|
||||||
|
dir: path.join(basePath, "examples/cloudflare-worker"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .wrangler node_modules/.cache node_modules/.mf`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Ready on (http:\/\/.*)/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"react-router": {
|
||||||
|
dir: path.join(basePath, "examples/react-router"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .react-router data.db`;
|
||||||
|
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Local.*?(http:\/\/.*)\//;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextjs: {
|
||||||
|
dir: path.join(basePath, "examples/nextjs"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .nextjs data.db`;
|
||||||
|
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Local.*?(http:\/\/.*)\n/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
astro: {
|
||||||
|
dir: path.join(basePath, "examples/astro"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .astro data.db`;
|
||||||
|
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Local.*?(http:\/\/.*)\//;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function testAdapter(name: keyof typeof adapters) {
|
||||||
|
const config = adapters[name];
|
||||||
|
console.log("adapter", c.cyan(name));
|
||||||
|
await config.clean();
|
||||||
|
|
||||||
|
const { proc, data } = await config.start();
|
||||||
|
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
||||||
|
//proc.kill();process.exit(0);
|
||||||
|
|
||||||
|
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
|
||||||
|
console.log("DONE!");
|
||||||
|
|
||||||
|
while (!proc.killed) {
|
||||||
|
proc.kill("SIGINT");
|
||||||
|
await Bun.sleep(250);
|
||||||
|
console.log("Waiting for process to exit...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { testIds } from "../src/ui/lib/config";
|
||||||
|
|
||||||
|
import { getAdapterConfig } from "./inc/adapters";
|
||||||
|
const config = getAdapterConfig();
|
||||||
|
|
||||||
|
test("start page has expected title", async ({ page }) => {
|
||||||
|
await page.goto(config.base_path);
|
||||||
|
await expect(page).toHaveTitle(/BKND/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start page has expected heading", async ({ page }) => {
|
||||||
|
await page.goto(config.base_path);
|
||||||
|
|
||||||
|
// Example of checking if a heading with "No entity selected" exists and is visible
|
||||||
|
const heading = page.getByRole("heading", { name: /No entity selected/i });
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modal opens on button click", async ({ page }) => {
|
||||||
|
await page.goto(config.base_path);
|
||||||
|
await page.getByTestId(testIds.data.btnCreateEntity).click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const adapter = process.env.TEST_ADAPTER;
|
||||||
|
|
||||||
|
const default_config = {
|
||||||
|
media_adapter: "local",
|
||||||
|
base_path: "",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const configs = {
|
||||||
|
cloudflare: {
|
||||||
|
media_adapter: "r2",
|
||||||
|
},
|
||||||
|
"react-router": {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
nextjs: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
astro: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
base_path: "",
|
||||||
|
},
|
||||||
|
bun: {
|
||||||
|
base_path: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAdapterConfig(): typeof default_config {
|
||||||
|
if (adapter) {
|
||||||
|
if (!configs[adapter]) {
|
||||||
|
console.warn(
|
||||||
|
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...default_config,
|
||||||
|
...configs[adapter],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return default_config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { testIds } from "../src/ui/lib/config";
|
||||||
|
import type { SchemaResponse } from "../src/modules/server/SystemController";
|
||||||
|
import { getAdapterConfig } from "./inc/adapters";
|
||||||
|
|
||||||
|
// Annotate entire file as serial.
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
const config = getAdapterConfig();
|
||||||
|
|
||||||
|
test("can enable media", async ({ page }) => {
|
||||||
|
await page.goto(`${config.base_path}/media/settings`);
|
||||||
|
|
||||||
|
// enable
|
||||||
|
const enableToggle = page.getByTestId(testIds.media.switchEnabled);
|
||||||
|
if ((await enableToggle.getAttribute("aria-checked")) !== "true") {
|
||||||
|
await expect(enableToggle).toBeVisible();
|
||||||
|
await enableToggle.click();
|
||||||
|
await expect(enableToggle).toHaveAttribute("aria-checked", "true");
|
||||||
|
|
||||||
|
// select local
|
||||||
|
const adapterChoice = page.locator(`css=button#adapter-${config.media_adapter}`);
|
||||||
|
await expect(adapterChoice).toBeVisible();
|
||||||
|
await adapterChoice.click();
|
||||||
|
|
||||||
|
// save
|
||||||
|
const saveBtn = page.getByRole("button", { name: /Update/i });
|
||||||
|
await expect(saveBtn).toBeVisible();
|
||||||
|
|
||||||
|
// intercept network request, wait for it to finish and get the response
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest((request) => request.url().includes("api/system/schema")),
|
||||||
|
saveBtn.click(),
|
||||||
|
]);
|
||||||
|
const response = await request.response();
|
||||||
|
expect(response?.status(), "fresh config 200").toBe(200);
|
||||||
|
const body = (await response?.json()) as SchemaResponse;
|
||||||
|
expect(body.config.media.enabled, "media is enabled").toBe(true);
|
||||||
|
expect(body.config.media.adapter?.type, "correct adapter").toBe(config.media_adapter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can upload a file", async ({ page }) => {
|
||||||
|
await page.goto(`${config.base_path}/media`);
|
||||||
|
// check any text to contain "Upload files"
|
||||||
|
await expect(page.getByText(/Upload files/i)).toBeVisible();
|
||||||
|
|
||||||
|
// upload a file from disk
|
||||||
|
// Start waiting for file chooser before clicking. Note no await.
|
||||||
|
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||||
|
await page.getByText("Upload file").click();
|
||||||
|
const fileChooser = await fileChooserPromise;
|
||||||
|
await fileChooser.setFiles("./e2e/assets/image.jpg");
|
||||||
|
});
|
||||||
+32
-18
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.11.0-rc.2",
|
"version": "0.13.0",
|
||||||
"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,17 +14,11 @@
|
|||||||
"url": "https://github.com/bknd-io/bknd/issues"
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
|
||||||
"test:all": "bun run test && bun run test:node",
|
|
||||||
"test:bun": "ALL_TESTS=1 bun test --bail",
|
|
||||||
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
|
|
||||||
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
|
||||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
|
||||||
"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 build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
|
"build:cli": "bun run build.cli.ts",
|
||||||
"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",
|
||||||
@@ -32,39 +26,51 @@
|
|||||||
"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 build:all && cp ../README.md ./",
|
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && 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": "bun run test && bun run test:node",
|
||||||
|
"test:bun": "ALL_TESTS=1 bun test --bail",
|
||||||
|
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
|
||||||
|
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
||||||
|
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||||
|
"test:vitest": "vitest run",
|
||||||
|
"test:vitest:watch": "vitest",
|
||||||
|
"test:vitest:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:debug": "playwright test --debug",
|
||||||
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
@@ -76,21 +82,28 @@
|
|||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@mantine/modals": "^7.17.1",
|
"@mantine/modals": "^7.17.1",
|
||||||
"@mantine/notifications": "^7.17.1",
|
"@mantine/notifications": "^7.17.1",
|
||||||
|
"@playwright/test": "^1.51.1",
|
||||||
"@rjsf/core": "5.22.2",
|
"@rjsf/core": "5.22.2",
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@tabler/icons-react": "3.18.0",
|
||||||
"@tailwindcss/postcss": "^4.0.12",
|
"@tailwindcss/postcss": "^4.0.12",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"@tailwindcss/vite": "^4.0.12",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^3.0.9",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
|
"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",
|
||||||
@@ -109,6 +122,7 @@
|
|||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"vite": "^6.2.1",
|
"vite": "^6.2.1",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^3.0.9",
|
||||||
"wouter": "^3.6.0"
|
"wouter": "^3.6.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
|
||||||
|
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
|
||||||
|
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testMatch: "**/*.e2e-spec.ts",
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: "html",
|
||||||
|
timeout: 20000,
|
||||||
|
use: {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
video: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
/* {
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
}, */
|
||||||
|
],
|
||||||
|
webServer: autoStart
|
||||||
|
? {
|
||||||
|
command: startCommand,
|
||||||
|
url: baseUrl,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
+78
-25
@@ -1,13 +1,19 @@
|
|||||||
import type { SafeUser } from "auth";
|
import type { SafeUser } from "auth";
|
||||||
import { AuthApi } from "auth/api/AuthApi";
|
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
|
||||||
import { DataApi } from "data/api/DataApi";
|
import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
||||||
import { decode } from "hono/jwt";
|
import { decode } from "hono/jwt";
|
||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi, type MediaApiOptions } 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__: {
|
||||||
@@ -16,14 +22,24 @@ 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;
|
||||||
localStorage?: boolean;
|
storage?: {
|
||||||
fetcher?: typeof fetch;
|
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
|
||||||
|
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;
|
||||||
@@ -56,18 +72,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) {
|
if ("request" in options && options.request) {
|
||||||
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) {
|
} else if ("token" in options && options.token) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token);
|
this.updateToken(options.token, { trigger: false });
|
||||||
|
|
||||||
// then check for an user object
|
// then check for an user object
|
||||||
} else if ("user" in options) {
|
} else if ("user" in options && options.user) {
|
||||||
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;
|
||||||
@@ -110,18 +126,30 @@ export class Api {
|
|||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (this.options.localStorage) {
|
} else if (this.storage) {
|
||||||
const token = localStorage.getItem(this.tokenKey);
|
this.storage.getItem(this.tokenKey).then((token) => {
|
||||||
if (token) {
|
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(token);
|
this.updateToken(token ? String(token) : undefined);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.warn("Couldn't extract token");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
private get storage() {
|
||||||
|
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 }) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.verified = false;
|
this.verified = false;
|
||||||
|
|
||||||
@@ -131,17 +159,25 @@ export class Api {
|
|||||||
this.user = undefined;
|
this.user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.localStorage) {
|
if (this.storage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem(key, token);
|
this.storage.setItem(key, token).then(() => {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(key);
|
this.storage.removeItem(key).then(() => {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (opts?.trigger !== false) {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rebuild) this.buildApis();
|
if (opts?.rebuild) this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
private markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
@@ -211,15 +247,32 @@ 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(baseParams, fetcher);
|
this.data = new DataApi(
|
||||||
this.auth = new AuthApi(
|
|
||||||
{
|
{
|
||||||
...baseParams,
|
...baseParams,
|
||||||
onTokenUpdate: (token) => this.updateToken(token, true),
|
...this.options.data,
|
||||||
|
},
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
this.auth = new AuthApi(
|
||||||
|
{
|
||||||
|
...baseParams,
|
||||||
|
credentials: this.options.storage ? "omit" : "include",
|
||||||
|
...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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -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 there
|
// biome-ignore format: must be here
|
||||||
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;
|
return this.server.fetch as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
get module() {
|
get module() {
|
||||||
@@ -180,7 +180,10 @@ 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(config?.basepath ?? "/", this.adminController.getController());
|
this.modules.server.route(
|
||||||
|
this.adminController.basepath,
|
||||||
|
this.adminController.getController(),
|
||||||
|
);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 : {}),
|
||||||
assets_path: assets.url,
|
assetsPath: assets.url,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import type { FrameworkBkndConfig } from "bknd/adapter";
|
import type { RuntimeBkndConfig } 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 { getFresh, getWarm } from "./modes/fresh";
|
import type { App } from "bknd";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
export type CloudflareEnv = object;
|
export type CloudflareEnv = object;
|
||||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env> & {
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
bindings?: (args: Env) => {
|
bindings?: (args: Env) => {
|
||||||
kv?: KVNamespace;
|
kv?: KVNamespace;
|
||||||
@@ -20,8 +22,6 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<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 !== "assets") {
|
if (config.manifest && config.static === "kv") {
|
||||||
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,18 +70,24 @@ 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":
|
||||||
return await getFresh(config, context);
|
app = await getFresh(config, context, { force: true });
|
||||||
|
break;
|
||||||
case "warm":
|
case "warm":
|
||||||
return await getWarm(config, context);
|
app = await getFresh(config, context);
|
||||||
|
break;
|
||||||
case "cache":
|
case "cache":
|
||||||
return await getCached(config, context);
|
app = 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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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",
|
||||||
@@ -27,12 +28,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,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, getWarm } from "./modes/fresh";
|
export { makeApp, getFresh } 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,7 +40,6 @@ 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,6 +3,7 @@ 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>,
|
||||||
@@ -13,7 +14,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.log("onBuilt and beforeBuild are not supported with DurableObject mode");
|
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -25,9 +26,7 @@ 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);
|
||||||
@@ -110,6 +109,7 @@ 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) {
|
||||||
|
|||||||
@@ -7,33 +7,7 @@ export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
return await createRuntimeApp<Env>(
|
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
|
||||||
{
|
|
||||||
...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>(
|
||||||
@@ -41,8 +15,15 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
ctx: Context<Env>,
|
ctx: Context<Env>,
|
||||||
opts: RuntimeOptions = {},
|
opts: RuntimeOptions = {},
|
||||||
) {
|
) {
|
||||||
return await getWarm(config, ctx, {
|
return await makeApp(
|
||||||
...opts,
|
{
|
||||||
force: true,
|
...config,
|
||||||
});
|
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";
|
import { nodeTestRunner } from "adapter/node/test";
|
||||||
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,8 +1,10 @@
|
|||||||
import { registries } from "bknd";
|
import { registries } from "bknd";
|
||||||
import { isDebug } from "bknd/core";
|
import { isDebug } from "bknd/core";
|
||||||
import { StringEnum, Type } from "bknd/utils";
|
import { StringEnum } from "bknd/utils";
|
||||||
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
|
import { 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(
|
||||||
@@ -122,12 +124,10 @@ 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,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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";
|
import { nodeTestRunner } from "adapter/node/test";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
|
||||||
before(() => disableConsoleLog());
|
before(() => disableConsoleLog());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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> & {
|
||||||
@@ -62,7 +63,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,5 +1,6 @@
|
|||||||
import { describe } from "node:test";
|
import { describe } from "node:test";
|
||||||
import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
|
import { nodeTestRunner } from "adapter/node/test";
|
||||||
|
import { StorageLocalAdapter } from "adapter/node";
|
||||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
import { 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,7 +1,9 @@
|
|||||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
import { type Static, Type, isFile, parse } from "bknd/utils";
|
import { type Static, isFile, parse } from "bknd/utils";
|
||||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
|
import 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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
import {
|
||||||
|
type DevServerOptions,
|
||||||
|
default as honoViteDevServer,
|
||||||
|
} from "@hono/vite-dev-server";
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
import {
|
||||||
|
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 ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
export type ViteEnv = NodeJS.ProcessEnv;
|
||||||
mode?: "cached" | "fresh";
|
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
|
||||||
setAdminHtml?: boolean;
|
|
||||||
forceDev?: boolean | { mainPath: string };
|
|
||||||
html?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
export function addViteScript(
|
||||||
|
html: string,
|
||||||
|
addBkndContext: boolean = true,
|
||||||
|
) {
|
||||||
return html.replace(
|
return html.replace(
|
||||||
"</head>",
|
"</head>",
|
||||||
`<script type="module">
|
`<script type="module">
|
||||||
@@ -28,52 +34,40 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
async function createApp<ViteEnv>(
|
||||||
|
config: ViteBkndConfig<ViteEnv> = {},
|
||||||
|
env: ViteEnv = {} as ViteEnv,
|
||||||
|
opts: FrameworkOptions = {},
|
||||||
|
): Promise<App> {
|
||||||
registerLocalMediaAdapter();
|
registerLocalMediaAdapter();
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...config,
|
...config,
|
||||||
adminOptions:
|
adminOptions: config.adminOptions ?? {
|
||||||
config.setAdminHtml === false
|
forceDev: {
|
||||||
? undefined
|
mainPath: "/src/main.tsx",
|
||||||
: {
|
},
|
||||||
html: config.html,
|
},
|
||||||
forceDev: config.forceDev ?? {
|
|
||||||
mainPath: "/src/main.tsx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
||||||
},
|
},
|
||||||
env,
|
env,
|
||||||
|
opts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
export function serve<ViteEnv>(
|
||||||
|
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);
|
const app = await createApp(config, env, opts);
|
||||||
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,
|
||||||
|
|||||||
+15
-136
@@ -1,29 +1,23 @@
|
|||||||
import {
|
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||||
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, Exception, type PrimaryFieldType } from "core";
|
import { $console, type DB } from "core";
|
||||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
import { secureRandomString, transformObject } from "core/utils";
|
||||||
import type { Entity, EntityManager } from "data";
|
import type { Entity, EntityManager } from "data";
|
||||||
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
|
import { em, entity, enumm, type FieldSchema, 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, STRATEGIES, authConfigSchema } from "./auth-schema";
|
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
||||||
|
import { AppUserPool } from "auth/AppUserPool";
|
||||||
|
import type { AppEntity } from "core/config";
|
||||||
|
|
||||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||||
declare module "core" {
|
declare module "core" {
|
||||||
|
interface Users extends AppEntity, UserFieldSchema {}
|
||||||
interface DB {
|
interface DB {
|
||||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
users: Users;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@@ -31,12 +25,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
cache: Record<string, any> = {};
|
cache: Record<string, any> = {};
|
||||||
_controller!: AuthController;
|
_controller!: AuthController;
|
||||||
|
|
||||||
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
|
override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +74,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||||
jwt: this.config.jwt,
|
jwt: this.config.jwt,
|
||||||
cookie: this.config.cookie,
|
cookie: this.config.cookie,
|
||||||
});
|
});
|
||||||
@@ -90,7 +84,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(Object.values(AuthPermissions));
|
this.ctx.guard.registerPermissions(AuthPermissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
isStrategyEnabled(strategy: Strategy | string) {
|
isStrategyEnabled(strategy: Strategy | string) {
|
||||||
@@ -122,120 +116,6 @@ 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)) {
|
||||||
@@ -288,7 +168,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";
|
const strategy = "password" as const;
|
||||||
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");
|
||||||
@@ -315,8 +195,7 @@ 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),
|
||||||
type: strategy.getType(),
|
...strategy.toJSON(secrets),
|
||||||
config: strategy.toJSON(secrets),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { AppAuth } from "auth/AppAuth";
|
||||||
|
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
|
||||||
|
import { $console } from "core";
|
||||||
|
import { pick } from "lodash-es";
|
||||||
|
import {
|
||||||
|
InvalidConditionsException,
|
||||||
|
UnableToCreateUserException,
|
||||||
|
UserNotFoundException,
|
||||||
|
} from "auth/errors";
|
||||||
|
|
||||||
|
export class AppUserPool implements UserPool {
|
||||||
|
constructor(private appAuth: AppAuth) {}
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.appAuth.em;
|
||||||
|
}
|
||||||
|
|
||||||
|
get users() {
|
||||||
|
return this.appAuth.getUsersEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBy(strategy: string, prop: keyof SafeUser, value: any) {
|
||||||
|
$console.debug("[AppUserPool:findBy]", { strategy, prop, value });
|
||||||
|
this.toggleStrategyValueVisibility(true);
|
||||||
|
const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy });
|
||||||
|
this.toggleStrategyValueVisibility(false);
|
||||||
|
|
||||||
|
if (!result.data) {
|
||||||
|
$console.debug("[AppUserPool]: User not found");
|
||||||
|
throw new UserNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(strategy: string, payload: CreateUser & Partial<Omit<User, "id">>) {
|
||||||
|
$console.debug("[AppUserPool:create]", { strategy, payload });
|
||||||
|
if (!("strategy_value" in payload)) {
|
||||||
|
throw new InvalidConditionsException("Profile must have a strategy_value value");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = this.users.getSelect(undefined, "create");
|
||||||
|
const safeProfile = pick(payload, fields) as any;
|
||||||
|
const createPayload: Omit<User, "id"> = {
|
||||||
|
...safeProfile,
|
||||||
|
strategy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutator = this.em.mutator(this.users);
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
|
this.toggleStrategyValueVisibility(true);
|
||||||
|
const createResult = await mutator.insertOne(createPayload);
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
|
this.toggleStrategyValueVisibility(false);
|
||||||
|
if (!createResult.data) {
|
||||||
|
throw new UnableToCreateUserException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$console.debug("[AppUserPool]: User created", createResult.data);
|
||||||
|
return createResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleStrategyValueVisibility(visible: boolean) {
|
||||||
|
const toggle = (name: string, visible: boolean) => {
|
||||||
|
const field = this.users.field(name)!;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
field.config.hidden = false;
|
||||||
|
field.config.fillable = true;
|
||||||
|
} else {
|
||||||
|
// reset to normal
|
||||||
|
const template = AppAuth.usersFields.strategy_value.config;
|
||||||
|
field.config.hidden = template.hidden;
|
||||||
|
field.config.fillable = template.fillable;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle("strategy_value", visible);
|
||||||
|
toggle("strategy", visible);
|
||||||
|
|
||||||
|
// @todo: think about a PasswordField that automatically hashes on save?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,21 @@ 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: "include",
|
credentials: this.options.credentials,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
@@ -27,7 +29,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: "include",
|
credentials: this.options.credentials,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
@@ -68,5 +70,7 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { tbValidator as tb } from "core";
|
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||||
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,10 +12,6 @@ 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();
|
||||||
@@ -54,6 +50,10 @@ 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,9 +91,16 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
hono.get("create/schema.json", async (c) => {
|
hono.get(
|
||||||
return c.json(create.schema);
|
"create/schema.json",
|
||||||
});
|
describeRoute({
|
||||||
|
summary: "Get the schema for creating a user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(create.schema);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainHono.route(`/${name}/actions`, hono);
|
mainHono.route(`/${name}/actions`, hono);
|
||||||
@@ -102,42 +109,54 @@ 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();
|
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
hono.get(
|
||||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
"/me",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get the current user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
|
const claims = c.get("auth")?.user;
|
||||||
|
if (claims) {
|
||||||
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
|
return c.json({ user });
|
||||||
|
}
|
||||||
|
|
||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
return c.json({ user: null }, 403);
|
||||||
this.registerStrategyActions(strategy, hono);
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
hono.get("/me", auth(), async (c) => {
|
hono.get(
|
||||||
const claims = c.get("auth")?.user;
|
"/logout",
|
||||||
if (claims) {
|
describeRoute({
|
||||||
const { data: user } = await this.userRepo.findId(claims.id);
|
summary: "Logout the current user",
|
||||||
return c.json({ user });
|
tags: ["auth"],
|
||||||
}
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
|
await this.auth.authenticator.logout(c);
|
||||||
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
|
return c.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
const referer = c.req.header("referer");
|
||||||
});
|
if (referer) {
|
||||||
|
return c.redirect(referer);
|
||||||
|
}
|
||||||
|
|
||||||
hono.get("/logout", auth(), async (c) => {
|
return c.redirect("/");
|
||||||
await this.auth.authenticator.logout(c);
|
},
|
||||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
);
|
||||||
return c.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const referer = c.req.header("referer");
|
|
||||||
if (referer) {
|
|
||||||
return c.redirect(referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/strategies",
|
"/strategies",
|
||||||
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
|
describeRoute({
|
||||||
|
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);
|
||||||
@@ -155,6 +174,15 @@ 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,6 +1,8 @@
|
|||||||
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, Type, objectTransform } from "core/utils";
|
import { type Static, StringRecord, objectTransform } from "core/utils";
|
||||||
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const Strategies = {
|
export const Strategies = {
|
||||||
password: {
|
password: {
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { type DB, Exception } from "core";
|
import { $console, 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,
|
||||||
transformObject,
|
truncate,
|
||||||
} 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];
|
||||||
@@ -22,11 +25,12 @@ 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: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
preprocess: (input: Static<S>) => 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;
|
||||||
@@ -36,29 +40,22 @@ export interface Strategy {
|
|||||||
getActions?: () => StrategyActions;
|
getActions?: () => StrategyActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = DB["users"];
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
role: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProfileExchange = {
|
export type ProfileExchange = {
|
||||||
email?: string;
|
email?: string;
|
||||||
username?: string;
|
strategy?: string;
|
||||||
sub?: string;
|
strategy_value?: string;
|
||||||
password?: string;
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SafeUser = Omit<User, "password">;
|
export type SafeUser = Omit<User, "strategy_value">;
|
||||||
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
||||||
export type AuthResponse = { user: SafeUser; token: string };
|
export type AuthResponse = { user: SafeUser; token: string };
|
||||||
|
|
||||||
export interface UserPool<Fields = "id" | "email" | "username"> {
|
export interface UserPool {
|
||||||
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
|
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>;
|
||||||
create: (user: CreateUser) => Promise<User | undefined>;
|
create: (strategy: string, user: CreateUser) => Promise<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||||
@@ -100,12 +97,17 @@ 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,
|
||||||
) => Promise<SafeUser | undefined>;
|
opts?: AuthResolveOptions,
|
||||||
|
) => Promise<ProfileExchange | undefined>;
|
||||||
type AuthClaims = SafeUser & {
|
type AuthClaims = SafeUser & {
|
||||||
iat: number;
|
iat: number;
|
||||||
iss?: string;
|
iss?: string;
|
||||||
@@ -113,33 +115,117 @@ 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(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
constructor(
|
||||||
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
|
private readonly strategies: Strategies,
|
||||||
this.strategies = strategies as Strategies;
|
private readonly userPool: UserPool,
|
||||||
|
config?: AuthConfig,
|
||||||
|
) {
|
||||||
this.config = parse(authenticatorConfig, config ?? {});
|
this.config = parse(authenticatorConfig, config ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(
|
async resolveLogin(
|
||||||
action: AuthAction,
|
c: Context,
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
identifier: string,
|
profile: Partial<SafeUser>,
|
||||||
profile: ProfileExchange,
|
verify: (user: User) => Promise<void>,
|
||||||
): Promise<AuthResponse> {
|
opts?: AuthResolveOptions,
|
||||||
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
) {
|
||||||
const user = await this.userResolver(action, strategy, identifier, profile);
|
try {
|
||||||
|
// @todo: centralize identifier and checks
|
||||||
|
// @todo: check identifier value (if allowed)
|
||||||
|
const identifier = opts?.identifier || "email";
|
||||||
|
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||||
|
throw new InvalidConditionsException("Identifier must be a string");
|
||||||
|
}
|
||||||
|
if (!(identifier in profile)) {
|
||||||
|
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
|
||||||
|
}
|
||||||
|
|
||||||
if (user) {
|
const user = await this.userPool.findBy(
|
||||||
return {
|
strategy.getName(),
|
||||||
user,
|
identifier as any,
|
||||||
token: await this.jwt(user),
|
profile[identifier],
|
||||||
};
|
);
|
||||||
|
|
||||||
|
if (!user.strategy_value) {
|
||||||
|
throw new InvalidConditionsException("User must have a strategy value");
|
||||||
|
} else if (user.strategy !== strategy.getName()) {
|
||||||
|
throw new InvalidConditionsException("User signed up with a different strategy");
|
||||||
|
}
|
||||||
|
|
||||||
|
await verify(user);
|
||||||
|
const data = await this.safeAuthResponse(user);
|
||||||
|
return this.respondWithUser(c, data, opts);
|
||||||
|
} catch (e) {
|
||||||
|
return this.respondWithError(c, e as Error, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveRegister(
|
||||||
|
c: Context,
|
||||||
|
strategy: Strategy,
|
||||||
|
profile: CreateUser,
|
||||||
|
verify: (user: User) => Promise<void>,
|
||||||
|
opts?: AuthResolveOptions,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const identifier = opts?.identifier || "email";
|
||||||
|
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||||
|
throw new InvalidConditionsException("Identifier must be a string");
|
||||||
|
}
|
||||||
|
if (!(identifier in profile)) {
|
||||||
|
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
|
||||||
|
}
|
||||||
|
if (!("strategy_value" in profile)) {
|
||||||
|
throw new InvalidConditionsException("Profile must have a strategy value");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userPool.create(strategy.getName(), {
|
||||||
|
...profile,
|
||||||
|
strategy_value: profile.strategy_value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verify(user);
|
||||||
|
const data = await this.safeAuthResponse(user);
|
||||||
|
return this.respondWithUser(c, data, opts);
|
||||||
|
} catch (e) {
|
||||||
|
return this.respondWithError(c, e as Error, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) {
|
||||||
|
const successUrl = this.getSafeUrl(
|
||||||
|
c,
|
||||||
|
opts?.redirect ?? this.config.cookie.pathSuccess ?? "/",
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("token" in data) {
|
||||||
|
await this.setAuthCookie(c, data.token);
|
||||||
|
|
||||||
|
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
|
||||||
|
return c.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// can't navigate to "/" – doesn't work on nextjs
|
||||||
|
return c.redirect(successUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("User could not be resolved");
|
throw new Exception("Invalid response");
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) {
|
||||||
|
$console.error("respondWithError", error);
|
||||||
|
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
|
||||||
|
// let the server handle it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addFlashMessage(c, String(error), "error");
|
||||||
|
|
||||||
|
const referer = this.getSafeUrl(c, opts?.redirect ?? c.req.header("Referer") ?? "/");
|
||||||
|
return c.redirect(referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
getStrategies(): Strategies {
|
getStrategies(): Strategies {
|
||||||
@@ -158,13 +244,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @todo: add jwt tests
|
// @todo: add jwt tests
|
||||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
|
||||||
const prohibited = ["password"];
|
const user = pick(_user, this.config.jwt.fields);
|
||||||
for (const prop of prohibited) {
|
|
||||||
if (prop in user) {
|
|
||||||
throw new Error(`Property "${prop}" is prohibited`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: JWTPayload = {
|
const payload: JWTPayload = {
|
||||||
...user,
|
...user,
|
||||||
@@ -189,6 +270,14 @@ 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(
|
||||||
@@ -230,7 +319,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("[Error:getAuthCookie]", e.message);
|
$console.error("[getAuthCookie]", e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -247,11 +336,13 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +358,6 @@ 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,37 +381,6 @@ 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;
|
||||||
@@ -346,13 +405,3 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStrategyAction<S extends TObject>(
|
|
||||||
schema: S,
|
|
||||||
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
schema,
|
|
||||||
preprocess,
|
|
||||||
} as StrategyAction<S>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,152 +1,135 @@
|
|||||||
import type { Authenticator, Strategy } from "auth";
|
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
|
||||||
import { isDebug, tbValidator as tb } from "core";
|
import { $console, tbValidator as tb } from "core";
|
||||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
|
||||||
import { hash } from "core/utils";
|
import { Hono } from "hono";
|
||||||
import { type Context, Hono } from "hono";
|
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||||
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
import { Strategy } from "./Strategy";
|
||||||
|
|
||||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
const { Type } = tbbox;
|
||||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
|
||||||
|
|
||||||
const schema = Type.Object({
|
const schema = StrictObject({
|
||||||
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }),
|
hashing: StringEnum(["plain", "sha256", "bcrypt"], { 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 implements Strategy {
|
export class PasswordStrategy extends Strategy<typeof schema> {
|
||||||
private options: PasswordStrategyOptions;
|
constructor(config: Partial<PasswordStrategyOptions> = {}) {
|
||||||
|
super(config as any, "password", "password", "form");
|
||||||
|
|
||||||
constructor(options: Partial<PasswordStrategyOptions> = {}) {
|
this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => {
|
||||||
this.options = parse(schema, options);
|
return {
|
||||||
}
|
...input,
|
||||||
|
strategy_value: await this.hash(password),
|
||||||
async hash(password: string) {
|
};
|
||||||
switch (this.options.hashing) {
|
});
|
||||||
case "sha256":
|
|
||||||
return hash.sha256(password);
|
|
||||||
default:
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(input: LoginSchema) {
|
|
||||||
if (!("email" in input) || !("password" in input)) {
|
|
||||||
throw new Error("Invalid input: Email and password must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await this.hash(input.password);
|
|
||||||
return { ...input, password: hashedPassword };
|
|
||||||
}
|
|
||||||
|
|
||||||
async register(input: RegisterSchema) {
|
|
||||||
if (!input.email || !input.password) {
|
|
||||||
throw new Error("Invalid input: Email and password must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
password: await this.hash(input.password),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getController(authenticator: Authenticator): Hono<any> {
|
|
||||||
const hono = new Hono();
|
|
||||||
|
|
||||||
return hono
|
|
||||||
.post(
|
|
||||||
"/login",
|
|
||||||
tb(
|
|
||||||
"query",
|
|
||||||
Type.Object({
|
|
||||||
redirect: Type.Optional(Type.String()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = await authenticator.getBody(c);
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await this.login(body);
|
|
||||||
const data = await authenticator.resolve(
|
|
||||||
"login",
|
|
||||||
this,
|
|
||||||
payload.password,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
return await authenticator.respond(c, data, redirect);
|
|
||||||
} catch (e) {
|
|
||||||
return await authenticator.respond(c, e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/register",
|
|
||||||
tb(
|
|
||||||
"query",
|
|
||||||
Type.Object({
|
|
||||||
redirect: Type.Optional(Type.String()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = await authenticator.getBody(c);
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
|
|
||||||
const payload = await this.register(body);
|
|
||||||
const data = await authenticator.resolve(
|
|
||||||
"register",
|
|
||||||
this,
|
|
||||||
payload.password,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
return await authenticator.respond(c, data, redirect);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getActions(): StrategyActions {
|
|
||||||
return {
|
|
||||||
create: createStrategyAction(
|
|
||||||
Type.Object({
|
|
||||||
email: Type.String({
|
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
|
||||||
}),
|
|
||||||
password: Type.String({
|
|
||||||
minLength: 8, // @todo: this should be configurable
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async ({ password, ...input }) => {
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
strategy_value: await this.hash(password),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
getType() {
|
private getPayloadSchema() {
|
||||||
return "password";
|
return Type.Object({
|
||||||
|
email: Type.String({
|
||||||
|
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
||||||
|
}),
|
||||||
|
password: Type.String({
|
||||||
|
minLength: 8, // @todo: this should be configurable
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getMode() {
|
async hash(password: string) {
|
||||||
return "form" as const;
|
switch (this.config.hashing) {
|
||||||
|
case "sha256":
|
||||||
|
return hash.sha256(password);
|
||||||
|
case "bcrypt": {
|
||||||
|
const salt = await bcryptGenSalt(this.config.rounds ?? 4);
|
||||||
|
return bcryptHash(password, salt);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return password;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getName() {
|
async compare(actual: string, compare: string): Promise<boolean> {
|
||||||
return "password" as const;
|
switch (this.config.hashing) {
|
||||||
|
case "sha256": {
|
||||||
|
const compareHashed = await this.hash(compare);
|
||||||
|
return actual === compareHashed;
|
||||||
|
}
|
||||||
|
case "bcrypt":
|
||||||
|
return await bcryptCompare(compare, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
verify(password: string) {
|
||||||
return secrets ? this.options : undefined;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
Authenticator,
|
||||||
|
StrategyAction,
|
||||||
|
StrategyActionName,
|
||||||
|
StrategyActions,
|
||||||
|
} from "../Authenticator";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import type { Static, TSchema } from "@sinclair/typebox";
|
||||||
|
import { parse, type TObject } from "core/utils";
|
||||||
|
|
||||||
|
export type StrategyMode = "form" | "external";
|
||||||
|
|
||||||
|
export abstract class Strategy<Schema extends TSchema = TSchema> {
|
||||||
|
protected actions: StrategyActions = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected config: Static<Schema>,
|
||||||
|
public type: string,
|
||||||
|
public name: string,
|
||||||
|
public mode: StrategyMode,
|
||||||
|
) {
|
||||||
|
// don't worry about typing, it'll throw if invalid
|
||||||
|
this.config = parse(this.getSchema(), (config ?? {}) as any) as Static<Schema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected registerAction<S extends TObject = TObject>(
|
||||||
|
name: StrategyActionName,
|
||||||
|
schema: S,
|
||||||
|
preprocess: StrategyAction<S>["preprocess"],
|
||||||
|
): void {
|
||||||
|
this.actions[name] = {
|
||||||
|
schema,
|
||||||
|
preprocess,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getSchema(): Schema;
|
||||||
|
|
||||||
|
abstract getController(auth: Authenticator): Hono;
|
||||||
|
|
||||||
|
getType(): string {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMode() {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
|
||||||
|
return {
|
||||||
|
type: this.getType(),
|
||||||
|
config: secrets ? this.config : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions(): StrategyActions {
|
||||||
|
return this.actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
|
|||||||
export * as issuers from "./oauth/issuers";
|
export * as issuers from "./oauth/issuers";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PasswordStrategy,
|
|
||||||
type PasswordStrategyOptions,
|
type PasswordStrategyOptions,
|
||||||
|
PasswordStrategy,
|
||||||
OAuthStrategy,
|
OAuthStrategy,
|
||||||
OAuthCallbackException,
|
OAuthCallbackException,
|
||||||
CustomOAuthStrategy,
|
CustomOAuthStrategy,
|
||||||
|
|||||||
@@ -1,43 +1,35 @@
|
|||||||
import { type Static, StringEnum, Type } from "core/utils";
|
import { type Static, StrictObject, StringEnum } from "core/utils";
|
||||||
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type * as oauth from "oauth4webapi";
|
import 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 = Type.Object(
|
const oauthSchemaCustom = StrictObject(
|
||||||
{
|
{
|
||||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||||
name: Type.String(),
|
name: Type.String(),
|
||||||
client: Type.Object(
|
client: StrictObject({
|
||||||
{
|
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({
|
||||||
{
|
issuer: Type.String(),
|
||||||
additionalProperties: false,
|
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
|
||||||
},
|
scopes_supported: Type.Optional(Type.Array(Type.String())),
|
||||||
),
|
scope_separator: Type.Optional(Type.String({ default: " " })),
|
||||||
as: Type.Object(
|
authorization_endpoint: Type.Optional(UrlString),
|
||||||
{
|
token_endpoint: Type.Optional(UrlString),
|
||||||
issuer: Type.String(),
|
userinfo_endpoint: Type.Optional(UrlString),
|
||||||
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
|
}),
|
||||||
scopes_supported: Type.Optional(Type.Array(Type.String())),
|
|
||||||
scope_separator: Type.Optional(Type.String({ default: " " })),
|
|
||||||
authorization_endpoint: Type.Optional(UrlString),
|
|
||||||
token_endpoint: Type.Optional(UrlString),
|
|
||||||
userinfo_endpoint: Type.Optional(UrlString),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// @todo: profile mapping
|
// @todo: profile mapping
|
||||||
},
|
},
|
||||||
{ title: "Custom OAuth", additionalProperties: false },
|
{ title: "Custom OAuth" },
|
||||||
);
|
);
|
||||||
|
|
||||||
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
||||||
@@ -62,6 +54,11 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -70,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy {
|
|||||||
override getSchema() {
|
override getSchema() {
|
||||||
return oauthSchemaCustom;
|
return oauthSchemaCustom;
|
||||||
}
|
}
|
||||||
|
|
||||||
override getType() {
|
|
||||||
return "custom_oauth";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { AuthAction, Authenticator, Strategy } from "auth";
|
import type { AuthAction, Authenticator } from "auth";
|
||||||
import { Exception, isDebug } from "core";
|
import { Exception, isDebug } from "core";
|
||||||
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
|
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { 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";
|
||||||
@@ -13,17 +16,12 @@ 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[]),
|
||||||
client: Type.Object(
|
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }),
|
||||||
{
|
client: StrictObject({
|
||||||
client_id: Type.String(),
|
client_id: Type.String(),
|
||||||
client_secret: Type.String(),
|
client_secret: Type.String(),
|
||||||
},
|
}),
|
||||||
{
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ title: "OAuth" },
|
{ title: "OAuth" },
|
||||||
);
|
);
|
||||||
@@ -71,11 +69,13 @@ export class OAuthCallbackException extends Exception {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OAuthStrategy implements Strategy {
|
export class OAuthStrategy extends Strategy<typeof schemaProvided> {
|
||||||
constructor(private _config: OAuthConfig) {}
|
constructor(config: ProvidedOAuthConfig) {
|
||||||
|
super(config, "oauth", config.name, "external");
|
||||||
|
}
|
||||||
|
|
||||||
get config() {
|
getSchema() {
|
||||||
return this._config;
|
return schemaProvided;
|
||||||
}
|
}
|
||||||
|
|
||||||
getIssuerConfig(): IssuerConfig {
|
getIssuerConfig(): IssuerConfig {
|
||||||
@@ -103,7 +103,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
type: info.type,
|
type: info.type,
|
||||||
client: {
|
client: {
|
||||||
...info.client,
|
...info.client,
|
||||||
...this._config.client,
|
...this.config.client,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,8 +172,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
) {
|
) {
|
||||||
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
|
||||||
@@ -181,13 +180,9 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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,
|
||||||
@@ -195,13 +190,9 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
@@ -216,20 +207,13 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -240,8 +224,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
) {
|
) {
|
||||||
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
|
||||||
@@ -249,13 +232,9 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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,
|
||||||
@@ -266,9 +245,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
|
|
||||||
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");
|
||||||
}
|
}
|
||||||
@@ -279,19 +255,15 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +273,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
): 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);
|
||||||
@@ -325,7 +296,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
@@ -356,7 +326,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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 =
|
||||||
@@ -369,21 +338,28 @@ export class OAuthStrategy implements Strategy {
|
|||||||
state: state.state,
|
state: state.state,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const safeProfile = {
|
||||||
const data = await auth.resolve(state.action, this, profile.sub, profile);
|
email: profile.email,
|
||||||
console.log("******** RESOLVED ********", data);
|
strategy_value: profile.sub,
|
||||||
|
} as const;
|
||||||
|
|
||||||
if (state.mode === "cookie") {
|
const verify = async (user) => {
|
||||||
return await auth.respond(c, data, state.redirect);
|
if (user.strategy_value !== profile.sub) {
|
||||||
|
throw new Exception("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const opts = {
|
||||||
|
redirect: state.redirect,
|
||||||
|
forceJsonResponse: state.mode !== "cookie",
|
||||||
|
} as const;
|
||||||
|
|
||||||
return c.json(data);
|
switch (state.action) {
|
||||||
} catch (e) {
|
case "login":
|
||||||
if (state.mode === "cookie") {
|
return auth.resolveLogin(c, this, safeProfile, verify, opts);
|
||||||
return await auth.respond(c, e, state.redirect);
|
case "register":
|
||||||
}
|
return auth.resolveRegister(c, this, safeProfile, verify, opts);
|
||||||
|
default:
|
||||||
throw e;
|
throw new Error("Invalid action");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,10 +388,8 @@ export class OAuthStrategy implements Strategy {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
@@ -456,28 +430,15 @@ export class OAuthStrategy implements Strategy {
|
|||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|
||||||
getType() {
|
override toJSON(secrets?: boolean) {
|
||||||
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 {
|
||||||
type: this.getIssuerConfig().type,
|
...super.toJSON(secrets),
|
||||||
...config,
|
config: {
|
||||||
|
...config,
|
||||||
|
type: this.getIssuerConfig().type,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ 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: {
|
||||||
@@ -45,7 +43,6 @@ 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");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Exception, Permission } from "core";
|
import { $console, 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,8 +14,6 @@ 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[];
|
||||||
@@ -83,8 +81,12 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPermissions(permissions: Permission[]) {
|
registerPermissions(permissions: Record<string, Permission>);
|
||||||
for (const permission of permissions) {
|
registerPermissions(permissions: Permission[]);
|
||||||
|
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
|
||||||
|
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
|
||||||
|
|
||||||
|
for (const permission of p) {
|
||||||
this.registerPermission(permission);
|
this.registerPermission(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +97,14 @@ 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) {
|
||||||
debug && console.log("guard: role found", [user.role]);
|
$console.debug(`guard: role "${user.role}" found`);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug &&
|
$console.debug("guard: role not found", {
|
||||||
console.log("guard: role not found", {
|
user,
|
||||||
user: user,
|
});
|
||||||
role: user?.role,
|
|
||||||
});
|
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,11 +120,14 @@ 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`);
|
||||||
@@ -133,10 +136,10 @@ export class Guard {
|
|||||||
const role = this.getUserRole(user);
|
const role = this.getUserRole(user);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
debug && console.log("guard: role not found, denying");
|
$console.debug("guard: user has no role, denying");
|
||||||
return false;
|
return false;
|
||||||
} else if (role.implicit_allow === true) {
|
} else if (role.implicit_allow === true) {
|
||||||
debug && console.log("guard: role implicit allow, allowing");
|
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +147,11 @@ export class Guard {
|
|||||||
(rolePermission) => rolePermission.permission.name === name,
|
(rolePermission) => rolePermission.permission.name === name,
|
||||||
);
|
);
|
||||||
|
|
||||||
debug &&
|
$console.debug("guard: rolePermission, allowing?", {
|
||||||
console.log("guard: rolePermission, allowing?", {
|
permission: name,
|
||||||
permission: name,
|
role: role.name,
|
||||||
role: role.name,
|
allowing: !!rolePermission,
|
||||||
allowing: !!rolePermission,
|
});
|
||||||
});
|
|
||||||
return !!rolePermission;
|
return !!rolePermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-7
@@ -1,28 +1,66 @@
|
|||||||
import { Exception } from "core";
|
import { Exception, isDebug } from "core";
|
||||||
|
import { HttpStatus } from "core/utils";
|
||||||
|
|
||||||
export class UserExistsException extends Exception {
|
export class AuthException extends Exception {
|
||||||
|
getSafeErrorAndCode() {
|
||||||
|
return {
|
||||||
|
error: "Invalid credentials",
|
||||||
|
code: HttpStatus.UNAUTHORIZED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override toJSON(): any {
|
||||||
|
if (isDebug()) {
|
||||||
|
return super.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: this.getSafeErrorAndCode().error,
|
||||||
|
type: "AuthException",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserExistsException extends AuthException {
|
||||||
override name = "UserExistsException";
|
override name = "UserExistsException";
|
||||||
override code = 422;
|
override code = HttpStatus.UNPROCESSABLE_ENTITY;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("User already exists");
|
super("User already exists");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserNotFoundException extends Exception {
|
export class UserNotFoundException extends AuthException {
|
||||||
override name = "UserNotFoundException";
|
override name = "UserNotFoundException";
|
||||||
override code = 404;
|
override code = HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("User not found");
|
super("User not found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidCredentialsException extends Exception {
|
export class InvalidCredentialsException extends AuthException {
|
||||||
override name = "InvalidCredentialsException";
|
override name = "InvalidCredentialsException";
|
||||||
override code = 401;
|
override code = HttpStatus.UNAUTHORIZED;
|
||||||
|
|
||||||
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,5 +1,4 @@
|
|||||||
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,
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
// @deprecated: moved to @bknd/core
|
|
||||||
export async function sha256(password: string, salt?: string) {
|
|
||||||
// 1. Convert password to Uint8Array
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode((salt ?? "") + password);
|
|
||||||
|
|
||||||
// 2. Hash the data using SHA-256
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
|
|
||||||
// 3. Convert hash to hex string for easier display
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@ export const config: CliCommand = (program) => {
|
|||||||
.option("--pretty", "pretty print")
|
.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));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ 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)}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,14 @@ async function onExit() {
|
|||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function action(options: { template?: string; dir?: string; integration?: string, yes?: boolean, clean?: boolean }) {
|
async function action(options: {
|
||||||
|
template?: string;
|
||||||
|
dir?: string;
|
||||||
|
integration?: string;
|
||||||
|
yes?: boolean;
|
||||||
|
clean?: boolean;
|
||||||
|
}) {
|
||||||
|
// biome-ignore lint/suspicious/noConsoleLog:
|
||||||
console.log("");
|
console.log("");
|
||||||
const $t = createScoped("create");
|
const $t = createScoped("create");
|
||||||
$t.capture("start", {
|
$t.capture("start", {
|
||||||
@@ -96,10 +105,12 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
|
|
||||||
$t.properties.at = "dir";
|
$t.properties.at = "dir";
|
||||||
if (fs.existsSync(downloadOpts.dir)) {
|
if (fs.existsSync(downloadOpts.dir)) {
|
||||||
const clean = options.clean ?? await $p.confirm({
|
const clean =
|
||||||
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
|
options.clean ??
|
||||||
initialValue: false,
|
(await $p.confirm({
|
||||||
});
|
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
|
||||||
|
initialValue: false,
|
||||||
|
}));
|
||||||
if ($p.isCancel(clean)) {
|
if ($p.isCancel(clean)) {
|
||||||
await onExit();
|
await onExit();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -174,8 +185,6 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
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();
|
||||||
@@ -261,9 +270,11 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
|
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
|
||||||
|
|
||||||
{
|
{
|
||||||
const install = options.yes ?? await $p.confirm({
|
const install =
|
||||||
message: "Install dependencies?",
|
options.yes ??
|
||||||
});
|
(await $p.confirm({
|
||||||
|
message: "Install dependencies?",
|
||||||
|
}));
|
||||||
|
|
||||||
if ($p.isCancel(install)) {
|
if ($p.isCancel(install)) {
|
||||||
await onExit();
|
await onExit();
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ export const cloudflare = {
|
|||||||
{ dir: ctx.dir },
|
{ dir: ctx.dir },
|
||||||
);
|
);
|
||||||
|
|
||||||
const db = ctx.skip ? "d1" : await $p.select({
|
const db = ctx.skip
|
||||||
message: "What database do you want to use?",
|
? "d1"
|
||||||
options: [
|
: await $p.select({
|
||||||
{ label: "Cloudflare D1", value: "d1" },
|
message: "What database do you want to use?",
|
||||||
{ label: "LibSQL", value: "libsql" },
|
options: [
|
||||||
],
|
{ label: "Cloudflare D1", value: "d1" },
|
||||||
});
|
{ label: "LibSQL", value: "libsql" },
|
||||||
|
],
|
||||||
|
});
|
||||||
if ($p.isCancel(db)) {
|
if ($p.isCancel(db)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -64,17 +66,19 @@ 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 ? default_db : await $p.text({
|
const name = ctx.skip
|
||||||
message: "Enter database name",
|
? default_db
|
||||||
initialValue: default_db,
|
: await $p.text({
|
||||||
placeholder: default_db,
|
message: "Enter database name",
|
||||||
validate: (v) => {
|
initialValue: default_db,
|
||||||
if (!v) {
|
placeholder: default_db,
|
||||||
return "Invalid name";
|
validate: (v) => {
|
||||||
}
|
if (!v) {
|
||||||
return;
|
return "Invalid name";
|
||||||
},
|
}
|
||||||
});
|
return;
|
||||||
|
},
|
||||||
|
});
|
||||||
if ($p.isCancel(name)) {
|
if ($p.isCancel(name)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -153,13 +157,16 @@ async function createLibsql(ctx: TemplateSetupCtx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createR2(ctx: TemplateSetupCtx) {
|
async function createR2(ctx: TemplateSetupCtx) {
|
||||||
const create = ctx.skip ?? await $p.confirm({
|
const create = ctx.skip
|
||||||
message: "Do you want to use a R2 bucket?",
|
? false
|
||||||
initialValue: true,
|
: await $p.confirm({
|
||||||
});
|
message: "Do you want to use a R2 bucket?",
|
||||||
|
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,
|
||||||
@@ -173,17 +180,19 @@ async function createR2(ctx: TemplateSetupCtx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const default_bucket = "bucket";
|
const default_bucket = "bucket";
|
||||||
const name = ctx.skip ? default_bucket : await $p.text({
|
const name = ctx.skip
|
||||||
message: "Enter bucket name",
|
? default_bucket
|
||||||
initialValue: default_bucket,
|
: await $p.text({
|
||||||
placeholder: default_bucket,
|
message: "Enter bucket name",
|
||||||
validate: (v) => {
|
initialValue: default_bucket,
|
||||||
if (!v) {
|
placeholder: default_bucket,
|
||||||
return "Invalid name";
|
validate: (v) => {
|
||||||
}
|
if (!v) {
|
||||||
return;
|
return "Invalid name";
|
||||||
},
|
}
|
||||||
});
|
return;
|
||||||
|
},
|
||||||
|
});
|
||||||
if ($p.isCancel(name)) {
|
if ($p.isCancel(name)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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(),
|
||||||
@@ -27,6 +28,7 @@ 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 });
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ 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";
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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 { config } from "core";
|
import { $console, 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];
|
||||||
@@ -32,11 +33,11 @@ export async function attachServeStatic(app: any, platform: Platform) {
|
|||||||
|
|
||||||
export async function startServer(
|
export async function startServer(
|
||||||
server: Platform,
|
server: Platform,
|
||||||
app: any,
|
app: App,
|
||||||
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": {
|
||||||
@@ -58,7 +59,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ 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 = typeof Bun !== "undefined";
|
const isBun = $isBun();
|
||||||
|
|
||||||
export const run: CliCommand = (program) => {
|
export const run: CliCommand = (program) => {
|
||||||
program
|
program
|
||||||
@@ -76,12 +77,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",
|
||||||
);
|
);
|
||||||
@@ -91,14 +92,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, { env: process.env });
|
const config = makeConfig(_config, process.env);
|
||||||
return makeApp({
|
return makeApp({
|
||||||
...config,
|
...config,
|
||||||
server: { platform },
|
server: { platform },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function action(options: {
|
type RunOptions = {
|
||||||
port: number;
|
port: number;
|
||||||
memory?: boolean;
|
memory?: boolean;
|
||||||
config?: string;
|
config?: string;
|
||||||
@@ -106,8 +107,9 @@ async function action(options: {
|
|||||||
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;
|
||||||
@@ -147,12 +149,19 @@ async function action(options: {
|
|||||||
// 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 connection", c.cyan(connection.url));
|
console.info("Using fallback 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { CliCommand } from "cli/types";
|
||||||
|
import { Option } from "commander";
|
||||||
|
import { makeAppFromEnv } from "cli/commands/run";
|
||||||
|
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||||
|
import { writeFile } from "cli/utils/sys";
|
||||||
|
import c from "picocolors";
|
||||||
|
|
||||||
|
export const types: CliCommand = (program) => {
|
||||||
|
program
|
||||||
|
.command("types")
|
||||||
|
.description("generate types")
|
||||||
|
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
|
||||||
|
.addOption(new Option("--no-write", "do not write to file").default(true))
|
||||||
|
.action(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function action({
|
||||||
|
outfile,
|
||||||
|
write,
|
||||||
|
}: {
|
||||||
|
outfile: string;
|
||||||
|
write: boolean;
|
||||||
|
}) {
|
||||||
|
const app = await makeAppFromEnv({
|
||||||
|
server: "node",
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const et = new EntityTypescript(app.em);
|
||||||
|
|
||||||
|
if (write) {
|
||||||
|
await writeFile(outfile, et.toString());
|
||||||
|
console.info(`\nTypes written to ${c.cyan(outfile)}`);
|
||||||
|
} else {
|
||||||
|
console.info(et.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,32 @@
|
|||||||
import { password as $password, text as $text } from "@clack/prompts";
|
import {
|
||||||
|
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 { makeConfigApp } from "cli/commands/run";
|
import { makeAppFromEnv } from "cli/commands/run";
|
||||||
import { getConfigPath } from "cli/commands/run/platform";
|
import type { CliCommand } from "cli/types";
|
||||||
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 and update user (auth)")
|
.description("create/update users, or generate a token (auth)")
|
||||||
.addArgument(new Argument("<action>", "action to perform").choices(["create", "update"]))
|
.addArgument(
|
||||||
|
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
|
||||||
|
)
|
||||||
.action(action);
|
.action(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function action(action: "create" | "update", options: any) {
|
async function action(action: "create" | "update" | "token", options: any) {
|
||||||
const configFilePath = await getConfigPath();
|
const app = await makeAppFromEnv({
|
||||||
if (!configFilePath) {
|
server: "node",
|
||||||
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":
|
||||||
@@ -31,6 +35,9 @@ async function action(action: "create" | "update", options: any) {
|
|||||||
case "update":
|
case "update":
|
||||||
await update(app, options);
|
await update(app, options);
|
||||||
break;
|
break;
|
||||||
|
case "token":
|
||||||
|
await token(app, options);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +45,8 @@ 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) {
|
||||||
throw new Error("Password strategy not configured");
|
$log.error("Password strategy not configured");
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = await $text({
|
const email = await $text({
|
||||||
@@ -50,6 +58,7 @@ 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",
|
||||||
@@ -60,20 +69,17 @@ 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),
|
||||||
});
|
});
|
||||||
console.log("Created:", created);
|
$log.success(`Created user: ${c.cyan(created.email)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error", e);
|
$log.error("Error creating user");
|
||||||
|
$console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,17 +98,14 @@ async function update(app: App, options: any) {
|
|||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
})) as string;
|
})) as string;
|
||||||
if (typeof email !== "string") {
|
if ($isCancel(email)) process.exit(1);
|
||||||
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) {
|
||||||
console.log("User not found");
|
$log.error("User not found");
|
||||||
process.exit(0);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("User found:", user);
|
$log.info(`User found: ${c.cyan(user.email)}`);
|
||||||
|
|
||||||
const password = await $password({
|
const password = await $password({
|
||||||
message: "New Password?",
|
message: "New Password?",
|
||||||
@@ -113,10 +116,7 @@ async function update(app: App, options: any) {
|
|||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (typeof password !== "string") {
|
if ($isCancel(password)) process.exit(1);
|
||||||
console.log("Cancelled");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
function togglePw(visible: boolean) {
|
function togglePw(visible: boolean) {
|
||||||
@@ -134,8 +134,43 @@ async function update(app: App, options: any) {
|
|||||||
});
|
});
|
||||||
togglePw(false);
|
togglePw(false);
|
||||||
|
|
||||||
console.log("Updated:", user);
|
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error", e);
|
$log.error("Error updating user");
|
||||||
|
$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,8 +1,17 @@
|
|||||||
|
import { $console } from "core";
|
||||||
import { execSync, exec as nodeExec } from "node:child_process";
|
import { execSync, exec as nodeExec } from "node:child_process";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import 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
|
||||||
@@ -40,6 +49,16 @@ export async function fileExists(filePath: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function writeFile(filePath: string, content: string) {
|
||||||
|
try {
|
||||||
|
await nodeWriteFile(path.resolve(process.cwd(), filePath), content);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
$console.error("Failed to write file", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
|
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, {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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]) => {
|
||||||
@@ -76,8 +75,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
import type { Generated } from "kysely";
|
import type { Generated } from "kysely";
|
||||||
|
|
||||||
export type PrimaryFieldType = number | Generated<number>;
|
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
|
||||||
|
|
||||||
|
export interface AppEntity<IdType extends number = number> {
|
||||||
|
id: PrimaryFieldType<IdType>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
// make sure to make unknown as "any"
|
// make sure to make unknown as "any"
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ 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: {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { HttpStatus } from "./utils/reqres";
|
||||||
|
|
||||||
export class Exception extends Error {
|
export class Exception extends Error {
|
||||||
code = 400;
|
code: ContentfulStatusCode = HttpStatus.BAD_REQUEST;
|
||||||
override name = "Exception";
|
override name = "Exception";
|
||||||
protected _context = undefined;
|
protected _context = undefined;
|
||||||
|
|
||||||
constructor(message: string, code?: number) {
|
constructor(message: string, code?: ContentfulStatusCode) {
|
||||||
super(message);
|
super(message);
|
||||||
if (code) {
|
if (code) {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -83,10 +84,6 @@ 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);
|
||||||
@@ -128,8 +125,7 @@ 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) {
|
||||||
// @todo: add a verbose option?
|
$console.debug(`Listener with id "${listener.id}" already exists.`);
|
||||||
//console.warn(`Listener with id "${listener.id}" already exists.`);
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +187,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.log("EventManager disabled, not emitting", slug);
|
$console.debug("EventManager disabled, not emitting", slug);
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +236,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 {
|
||||||
|
|||||||
@@ -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 } from "./config";
|
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config";
|
||||||
export { AwsClient } from "./clients/aws/AwsClient";
|
export { AwsClient } from "./clients/aws/AwsClient";
|
||||||
export {
|
export {
|
||||||
SimpleRenderer,
|
SimpleRenderer,
|
||||||
@@ -25,8 +25,11 @@ 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>;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ 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);
|
||||||
|
|
||||||
@@ -122,19 +123,15 @@ 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
|
||||||
@@ -149,7 +146,6 @@ 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)
|
||||||
@@ -157,12 +153,10 @@ 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));
|
||||||
@@ -170,8 +164,6 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("patch", _jsonp({ path, value, partial, config, current }));
|
|
||||||
|
|
||||||
const newConfig = await this.set(config);
|
const newConfig = await this.set(config);
|
||||||
return [partial, newConfig];
|
return [partial, newConfig];
|
||||||
}
|
}
|
||||||
@@ -181,14 +173,11 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
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];
|
||||||
}
|
}
|
||||||
@@ -198,7 +187,6 @@ 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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-13
@@ -1,4 +1,5 @@
|
|||||||
enum Change {
|
// biome-ignore lint/suspicious/noConstEnum: <explanation>
|
||||||
|
export const enum DiffChange {
|
||||||
Add = "a",
|
Add = "a",
|
||||||
Remove = "r",
|
Remove = "r",
|
||||||
Edit = "e",
|
Edit = "e",
|
||||||
@@ -7,8 +8,8 @@ enum Change {
|
|||||||
type Object = object;
|
type Object = object;
|
||||||
type Primitive = string | number | boolean | null | object | any[] | undefined;
|
type Primitive = string | number | boolean | null | object | any[] | undefined;
|
||||||
|
|
||||||
interface DiffEntry {
|
export interface DiffEntry {
|
||||||
t: Change | string;
|
t: DiffChange | string;
|
||||||
p: (string | number)[];
|
p: (string | number)[];
|
||||||
o: Primitive;
|
o: Primitive;
|
||||||
n: Primitive;
|
n: Primitive;
|
||||||
@@ -47,7 +48,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
|
|||||||
|
|
||||||
if (typeof oldValue !== typeof newValue) {
|
if (typeof oldValue !== typeof newValue) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
t: Change.Edit,
|
t: DiffChange.Edit,
|
||||||
p: path,
|
p: path,
|
||||||
o: oldValue,
|
o: oldValue,
|
||||||
n: newValue,
|
n: newValue,
|
||||||
@@ -57,14 +58,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: Change.Add,
|
t: DiffChange.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: Change.Remove,
|
t: DiffChange.Remove,
|
||||||
p: [...path, i],
|
p: [...path, i],
|
||||||
o: oldValue[i],
|
o: oldValue[i],
|
||||||
n: undefined,
|
n: undefined,
|
||||||
@@ -80,14 +81,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: Change.Add,
|
t: DiffChange.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: Change.Remove,
|
t: DiffChange.Remove,
|
||||||
p: [...path, key],
|
p: [...path, key],
|
||||||
o: oldValue[key],
|
o: oldValue[key],
|
||||||
n: undefined,
|
n: undefined,
|
||||||
@@ -98,7 +99,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
t: Change.Edit,
|
t: DiffChange.Edit,
|
||||||
p: path,
|
p: path,
|
||||||
o: oldValue,
|
o: oldValue,
|
||||||
n: newValue,
|
n: newValue,
|
||||||
@@ -136,9 +137,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 === Change.Add || type === Change.Edit) {
|
if (type === DiffChange.Add || type === DiffChange.Edit) {
|
||||||
parent[key] = newValue;
|
parent[key] = newValue;
|
||||||
} else if (type === Change.Remove) {
|
} else if (type === DiffChange.Remove) {
|
||||||
if (Array.isArray(parent)) {
|
if (Array.isArray(parent)) {
|
||||||
parent.splice(key as number, 1);
|
parent.splice(key as number, 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -152,13 +153,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 === Change.Add) {
|
if (type === DiffChange.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 === Change.Remove || type === Change.Edit) {
|
} else if (type === DiffChange.Remove || type === DiffChange.Edit) {
|
||||||
parent[key] = oldValue;
|
parent[key] = oldValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ 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>];
|
||||||
@@ -195,5 +197,7 @@ 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>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { tbValidator } from "./tbValidator";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { SimpleRenderer } from "core";
|
||||||
|
|
||||||
|
describe(SimpleRenderer, () => {
|
||||||
|
const renderer = new SimpleRenderer(
|
||||||
|
{
|
||||||
|
name: "World",
|
||||||
|
views: 123,
|
||||||
|
nested: {
|
||||||
|
foo: "bar",
|
||||||
|
baz: ["quz", "foo"],
|
||||||
|
},
|
||||||
|
someArray: [1, 2, 3],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
renderKeys: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("strings", async () => {
|
||||||
|
const tests = [
|
||||||
|
["Hello {{ name }}, count: {{views}}", "Hello World, count: 123"],
|
||||||
|
["Nested: {{nested.foo}}", "Nested: bar"],
|
||||||
|
["Nested: {{nested.baz[0]}}", "Nested: quz"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [template, expected] of tests) {
|
||||||
|
expect(await renderer.renderString(template)).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("arrays", async () => {
|
||||||
|
const tests = [
|
||||||
|
[
|
||||||
|
["{{someArray[0]}}", "{{someArray[1]}}", "{{someArray[2]}}"],
|
||||||
|
["1", "2", "3"],
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [template, expected] of tests) {
|
||||||
|
const result = await renderer.render(template);
|
||||||
|
expect(result).toEqual(expected as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("objects", async () => {
|
||||||
|
const tests = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
foo: "{{name}}",
|
||||||
|
bar: "{{views}}",
|
||||||
|
baz: "{{nested.foo}}",
|
||||||
|
quz: "{{nested.baz[0]}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foo: "World",
|
||||||
|
bar: "123",
|
||||||
|
baz: "bar",
|
||||||
|
quz: "quz",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [template, expected] of tests) {
|
||||||
|
const result = await renderer.render(template);
|
||||||
|
expect(result).toEqual(expected as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Liquid, LiquidError } from "liquidjs";
|
import { get } from "lodash-es";
|
||||||
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;
|
export type TemplateTypes = string | TemplateObject | any;
|
||||||
|
|
||||||
export type SimpleRendererOptions = RenderOptions & {
|
export type SimpleRendererOptions = {
|
||||||
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 = {},
|
||||||
@@ -22,7 +18,6 @@ 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") {
|
||||||
@@ -34,49 +29,29 @@ export class SimpleRenderer {
|
|||||||
flat = String(template);
|
flat = String(template);
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("** flat", flat);
|
const checks = ["{{"];
|
||||||
|
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>(template: Given): Promise<Given> {
|
async render<Given extends TemplateTypes = TemplateTypes>(template: Given): Promise<Given> {
|
||||||
try {
|
if (typeof template === "undefined" || template === null) return template;
|
||||||
if (typeof template === "string") {
|
|
||||||
return (await this.renderString(template)) as unknown as Given;
|
|
||||||
} else if (Array.isArray(template)) {
|
|
||||||
return (await Promise.all(
|
|
||||||
template.map((item) => this.render(item)),
|
|
||||||
)) as unknown as Given;
|
|
||||||
} else if (typeof template === "object") {
|
|
||||||
return (await this.renderObject(template)) as unknown as Given;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof LiquidError) {
|
|
||||||
const details = {
|
|
||||||
name: e.name,
|
|
||||||
token: {
|
|
||||||
kind: e.token.kind,
|
|
||||||
input: e.token.input,
|
|
||||||
begin: e.token.begin,
|
|
||||||
end: e.token.end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
throw new BkndError(e.message, details, "liquid");
|
if (typeof template === "string") {
|
||||||
}
|
return (await this.renderString(template)) as unknown as Given;
|
||||||
|
} else if (Array.isArray(template)) {
|
||||||
throw e;
|
return (await Promise.all(template.map((item) => this.render(item)))) as unknown as Given;
|
||||||
|
} else if (typeof template === "object") {
|
||||||
|
return (await this.renderObject(template as any)) as unknown as Given;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Invalid template type");
|
throw new Error("Invalid template type");
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderString(template: string): Promise<string> {
|
async renderString(template: string): Promise<string> {
|
||||||
//console.log("*** renderString", template, this.variables);
|
return template.replace(/{{\s*([^{}]+?)\s*}}/g, (_, expr: string) => {
|
||||||
return this.engine.parseAndRender(template, this.variables, this.options);
|
const value = get(this.variables, expr.trim());
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderObject(template: TemplateObject): Promise<TemplateObject> {
|
async renderObject(template: TemplateObject): Promise<TemplateObject> {
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -33,6 +31,8 @@ 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;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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,6 +2,7 @@ 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;
|
||||||
@@ -130,7 +131,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 {
|
||||||
@@ -141,7 +142,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -359,3 +359,63 @@ 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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import { randomString } from "core/utils/strings";
|
|
||||||
import type { Context } from "hono";
|
|
||||||
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
|
|
||||||
|
|
||||||
export function headersToObject(headers: Headers): Record<string, string> {
|
export function headersToObject(headers: Headers): Record<string, string> {
|
||||||
if (!headers) return {};
|
if (!headers) return {};
|
||||||
return { ...Object.fromEntries(headers.entries()) };
|
return { ...Object.fromEntries(headers.entries()) };
|
||||||
@@ -102,7 +98,7 @@ export function decodeSearch(str) {
|
|||||||
export const enum HttpStatus {
|
export const enum HttpStatus {
|
||||||
// Informational responses (100–199)
|
// Informational responses (100–199)
|
||||||
CONTINUE = 100,
|
CONTINUE = 100,
|
||||||
SWITCHING_PROTOCOLS = 101,
|
//SWITCHING_PROTOCOLS = 101,
|
||||||
PROCESSING = 102,
|
PROCESSING = 102,
|
||||||
EARLY_HINTS = 103,
|
EARLY_HINTS = 103,
|
||||||
|
|
||||||
@@ -111,8 +107,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,
|
||||||
@@ -123,7 +119,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,
|
||||||
@@ -172,3 +168,13 @@ 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 (100–199)
|
||||||
|
SWITCHING_PROTOCOLS = 101,
|
||||||
|
// Successful responses (200–299)
|
||||||
|
NO_CONTENT = 204,
|
||||||
|
RESET_CONTENT = 205,
|
||||||
|
// Redirection messages (300–399)
|
||||||
|
NOT_MODIFIED = 304,
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user