Compare commits

...

30 Commits

Author SHA1 Message Date
dswbx 3e77982996 docs: added docs about how to use bknd.config.ts 2025-06-05 17:11:50 +02:00
dswbx 7b128c9701 Merge pull request #176 from bknd-io/chores/repo-cleanup
repo chores: fixed root dir, removed unused class, added .env.example
2025-05-27 20:27:38 +02:00
dswbx 061181d59d repo chores: fixed root dir, removed unused class, added .env.example 2025-05-27 20:25:37 +02:00
dswbx af6cb0c8f0 bump 0.13.0 + separated cli build into separate file 2025-05-27 16:53:49 +02:00
dswbx 5a693c0370 Merge pull request #169 from bknd-io/release/0.13
Release 0.13
2025-05-27 16:29:43 +02:00
dswbx 262588decc update github action to use bun 1.2.14 + added .nvmrc (24 breaks for node tests) 2025-05-27 13:30:02 +02:00
dswbx 17ab35e245 api: added custom storage option (#174) 2025-05-27 13:09:24 +02:00
dswbx db795ec050 Controllers: New validation + auto OpenAPI (#173)
* updated controllers to use custom json schema and added auto openapi specs

* fix data routes parsing body

* added schema exports to core

* added swagger link to Admin, switched use-search
2025-05-27 09:06:36 +02:00
dswbx 773df544dd feat/custom-json-schema (#172)
* init

* update

* finished new repo query, removed old implementation

* remove debug folder
2025-05-22 08:52:25 +02:00
dswbx 0ac7d1fd6e remove batching workaround for Turso AWS endpoints (#171)
the underlying issue with batching on Turso AWS endpoints appears resolved, making the workaround unnecessary.
2025-05-21 07:47:24 +02:00
dswbx 6694c63990 admin: data/auth route-driven settings and collapsible components (#168)
introduced `useRoutePathState` for managing active states via routes, added `CollapsibleList` for reusable collapsible UI, and updated various components to leverage route awareness for improved navigation state handling. Also adjusted routing for entities, strategies, and schema to support optional sub-paths.
2025-05-03 11:05:38 +02:00
dswbx b3f95f9552 v0.12.0 2025-05-01 10:13:40 +02:00
dswbx 372f94d22a Release 0.12 (#143)
* changed tb imports

* cleanup: replace console.log/warn with $console, remove commented-out code

Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage.

* ts: enable incremental

* fix imports in test files

reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes.

* added media permissions (#142)

* added permissions support for media module

introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic.

* fix: handle token absence in getUploadHeaders and add tests for transport modes

ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options.

* remove console.log on DropzoneContainer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add bcrypt and refactored auth resolve (#147)

* reworked auth architecture with improved password handling and claims

Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly.

* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* refactored auth handling to support bcrypt, extracted user pool

* update email regex to allow '+' and '_' characters

* update test stub password for AppAuth spec

* update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool

* rework strategies to extend a base class instead of interface

* added simple bcrypt test

* add validation logs and improve data validation handling (#157)

Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation.

* modify MediaApi to support custom fetch implementation, defaults to native fetch (#158)

* modify MediaApi to support custom fetch implementation, defaults to native fetch

added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided.

* fix tests and improve api fetcher types

* update admin basepath handling and window context integration (#155)

Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context.

* trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160)

* refactor error handling in authenticator and password strategy (#161)

made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency.

* add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162)

Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage.

* update dependencies in package.json (#156)

moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries.

* update imports to adjust nodeTestRunner path and remove unused export (#163)

updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now.

* fix sync events not awaited (#164)

* refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)

Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.

* replace LiquidJs rendering with simplified renderer (#167)

* replace LiquidJs rendering with simplified renderer

Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags.

* remove liquid js from package json

* feat/cli-generate-types (#166)

* init types generation

* update type generation for entities and fields

Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files.

* update type generation code and CLI option description

removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description.

* fix json schema field type generation

* reworked system entities to prevent recursive types

* reworked system entities to prevent recursive types

* remove unused object function

* types: use number instead of Generated

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-01 10:12:18 +02:00
dswbx d6f94a2ce1 fix docs mdx snippets imports 2025-04-26 07:34:56 +02:00
dswbx 89a39a7dc6 fix docs: try locking version 2025-04-25 08:52:52 +02:00
dswbx 88cf65f792 fix docs by tmp renaming intro page 2025-04-24 21:20:12 +02:00
dswbx 07723ce6ae bump to 0.11.2 2025-04-22 15:55:20 +02:00
dswbx 9010401af6 remove unused useTheme import and add loading state for entity detail view (#154) 2025-04-22 15:43:27 +02:00
dswbx 4c11789ea8 Fix Release 0.11.1 (#150)
* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* fix admin access permissions and refactor routing structure

display a fixed error for unmet permissions when retrieving the schema. moved auth routes outside of BkndProvider and reorganized remaining routes to include BkndWrapper.

* fix: properly type BkndWrapper

* bump fix release version

* ModuleManager: update diff checking and AppData validation

Revised diff handling includes validation of diffs, reverting changes on failure, and enforcing module constraints with onBeforeUpdate hooks. Introduced `validateDiffs` and backup of stable configs. Applied changes in related modules, tests, and UI layer to align with updated diff logic.

* fix: cli: running from config file were using invalid args

* fix: cli: improve sequence of onBuilt trigger to allow custom routes from cli

* fix e2e tests
2025-04-20 09:29:58 +02:00
dswbx 2988e4c3bd fix: force json-schema-library version 2025-04-18 16:06:32 +02:00
dswbx ad7926db4c Merge pull request #121 from bknd-io/release/0.11
Release 0.11
2025-04-08 12:51:34 +02:00
dswbx 53a3dcdee7 release: v0.11 2025-04-08 12:50:37 +02:00
dswbx 53467d6750 fix: create: cloudflare starter wasn't creating a r2 bucket 2025-04-08 12:43:50 +02:00
dswbx a80a731498 fix: cli: user command now uses the same app env setup as run 2025-04-08 11:22:10 +02:00
dswbx 7e1757b7f4 fix: updated cloudflare adapter to use runtime config, aligned vite 2025-04-05 18:03:58 +02:00
dswbx 2c29e06fb8 fix: media infinite should be disabled for dropzone in entities 2025-04-05 18:01:04 +02:00
dswbx ca86fa58ac cli: user: add token generation (#140)
* cli: user: add token generation

* cli: user: add token generation

* cli: user: check for value being cancel before continuing
2025-04-05 17:57:03 +02:00
dswbx de984fa101 fixes issues in firefox where view transitions are not available (#139) 2025-04-04 09:00:50 +02:00
dswbx a12d4e13d0 e2e: added script to auto test adapters 2025-04-03 16:40:51 +02:00
dswbx fa6c7acaf5 implement/init e2e tests (#135)
* init e2e

* updated/moved vitest, finished merge

* fix bun picking up e2e tests

* e2e: overwrite webserver config with env

* e2e: added adapter configs

* e2e: replaced image
2025-04-03 11:08:16 +02:00
278 changed files with 17001 additions and 4728 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.2.5" bun-version: "1.2.14"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
+2
View File
@@ -30,3 +30,5 @@ packages/media/.env
.vscode .vscode
.git_old .git_old
docker/tmp docker/tmp
.debug
.history
+1
View File
@@ -0,0 +1 @@
22
+42
View File
@@ -0,0 +1,42 @@
# ===== DB Settings =====
VITE_DB_URL=:memory:
# you can set a location for a database here, it'll overwrite the previous setting
# ideally use the ".db" folder (create it first), it's git ignored
VITE_DB_URL=file:.db/dev.db
# alternatively, you can use url/token combination
#VITE_DB_URL=
#VITE_DB_TOKEN=
# ===== DEV Server =====
# restart the dev server on every change (enable with "1")
VITE_APP_FRESH=
# displays react-scan widget (enable with "1")
VITE_DEBUG_RERENDERS=
# console logs registered routes on start (enable with "1")
VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_ACCESS_KEY=
R2_URL=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_S3_URL=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=
# ===== Internals =====
BKND_CLI_CREATE_REF=main
BKND_CLI_LOG_LEVEL=debug
BKND_MODULES_DEBUG=1
+2 -1
View File
@@ -1,3 +1,4 @@
test-results
playwright-report playwright-report
test-results
bknd.config.* bknd.config.*
__test__/helper.d.ts
+26 -4
View File
@@ -1,8 +1,8 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "../../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) {
+2 -3
View File
@@ -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" };
+5 -11
View File
@@ -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();
}); });
}); });
@@ -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 {
+22 -41
View File
@@ -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 -59
View File
@@ -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);
});
}); });
+2 -1
View File
@@ -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";
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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");
+60 -1
View File
@@ -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: {
+41 -3
View File
@@ -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();
});
}); });
+4 -3
View File
@@ -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;
} }
} }
+132 -5
View File
@@ -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);
}
});
});
}); });
+12
View File
@@ -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);
});
});
+8
View File
@@ -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();
});
+23
View File
@@ -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
View File
@@ -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: [
+214
View File
@@ -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

+25
View File
@@ -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();
});
+44
View File
@@ -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;
}
+55
View File
@@ -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
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.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": {
+42
View File
@@ -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
View File
@@ -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
View File
@@ -15,7 +15,7 @@ import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be 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;
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
case "url": case "url":
additional.adminOptions = { additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}), ...(typeof adminOptions === "object" ? adminOptions : {}),
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);
}, },
}; };
} }
+3 -2
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter"; export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, 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 -3
View File
@@ -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) {
+12 -31
View File
@@ -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,
-1
View File
@@ -3,7 +3,6 @@ import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"]; type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & { export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] }; cleanRequest?: { searchParams?: string[] };
}; };
-1
View File
@@ -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());
+2 -1
View File
@@ -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(
{ {
+32 -38
View File
@@ -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
View File
@@ -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),
})), })),
}; };
} }
+83
View File
@@ -0,0 +1,83 @@
import { AppAuth } from "auth/AppAuth";
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
import { $console } from "core";
import { pick } from "lodash-es";
import {
InvalidConditionsException,
UnableToCreateUserException,
UserNotFoundException,
} from "auth/errors";
export class AppUserPool implements UserPool {
constructor(private appAuth: AppAuth) {}
get em() {
return this.appAuth.em;
}
get users() {
return this.appAuth.getUsersEntity();
}
async findBy(strategy: string, prop: keyof SafeUser, value: any) {
$console.debug("[AppUserPool:findBy]", { strategy, prop, value });
this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
$console.debug("[AppUserPool]: User not found");
throw new UserNotFoundException();
}
return result.data;
}
async create(strategy: string, payload: CreateUser & Partial<Omit<User, "id">>) {
$console.debug("[AppUserPool:create]", { strategy, payload });
if (!("strategy_value" in payload)) {
throw new InvalidConditionsException("Profile must have a strategy_value value");
}
const fields = this.users.getSelect(undefined, "create");
const safeProfile = pick(payload, fields) as any;
const createPayload: Omit<User, "id"> = {
...safeProfile,
strategy,
};
const mutator = this.em.mutator(this.users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(createPayload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new UnableToCreateUserException();
}
$console.debug("[AppUserPool]: User created", createResult.data);
return createResult.data;
}
private toggleStrategyValueVisibility(visible: boolean) {
const toggle = (name: string, visible: boolean) => {
const field = this.users.field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
// @todo: think about a PasswordField that automatically hashes on save?
}
}
+8 -4
View File
@@ -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);
}
} }
+65 -37
View File
@@ -1,9 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { 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());
} }
} }
+3 -1
View File
@@ -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: {
+137 -88
View File
@@ -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");
+22 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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,
-13
View File
@@ -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("");
}
+2
View File
@@ -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));
}); });
}; };
+1
View File
@@ -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)}`));
} }
+21 -10
View File
@@ -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);
} }
+2
View File
@@ -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 });
+1
View File
@@ -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";
+6 -4
View File
@@ -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);
} }
+18 -9
View File
@@ -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 });
} }
+1
View File
@@ -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));
}); });
}; };
+1
View File
@@ -0,0 +1 @@
export * from "./types";
+37
View File
@@ -0,0 +1,37 @@
import type { CliCommand } from "cli/types";
import { Option } from "commander";
import { makeAppFromEnv } from "cli/commands/run";
import { EntityTypescript } from "data/entities/EntityTypescript";
import { writeFile } from "cli/utils/sys";
import c from "picocolors";
export const types: CliCommand = (program) => {
program
.command("types")
.description("generate types")
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
.addOption(new Option("--no-write", "do not write to file").default(true))
.action(action);
};
async function action({
outfile,
write,
}: {
outfile: string;
write: boolean;
}) {
const app = await makeAppFromEnv({
server: "node",
});
await app.build();
const et = new EntityTypescript(app.em);
if (write) {
await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`);
} else {
console.info(et.toString());
}
}
+71 -36
View File
@@ -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`,
);
}
+20 -1
View File
@@ -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, {
-3
View File
@@ -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;
} }
+5 -1
View File
@@ -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"
+9
View File
@@ -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: {
+5 -2
View File
@@ -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;
+4
View File
@@ -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);
} }
+4 -8
View File
@@ -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 {
+4 -1
View File
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator"; export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors"; export { Exception, BkndError } from "./errors";
export { isDebug, env } from "./env"; export { isDebug, env } from "./env";
export { type PrimaryFieldType, config, type DB } 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>;
+1 -13
View File
@@ -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
View File
@@ -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;
} }
} }
+4
View File
@@ -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>,
}; };
} }
+52
View File
@@ -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;
}
+63
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export { tbValidator } from "./tbValidator";
@@ -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);
}
});
});
+17 -42
View File
@@ -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> {
+2 -2
View File
@@ -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;
-1
View File
@@ -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;
} }
} }
+3 -2
View File
@@ -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);
} }
} }
+60
View File
@@ -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>,
);
}

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