diff --git a/app/__test__/api/Api.spec.ts b/app/__test__/api/Api.spec.ts new file mode 100644 index 0000000..85f144d --- /dev/null +++ b/app/__test__/api/Api.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { sign } from "hono/jwt"; +import { Api } from "../../src/Api"; + +describe("Api", async () => { + it("should construct without options", () => { + const api = new Api(); + expect(api.baseUrl).toBe("http://localhost"); + expect(api.isAuthVerified()).toBe(false); + }); + + it("should ignore force verify if no claims given", () => { + const api = new Api({ verified: true }); + expect(api.baseUrl).toBe("http://localhost"); + expect(api.isAuthVerified()).toBe(false); + }); + + it("should construct from request (token)", async () => { + const token = await sign({ foo: "bar" }, "1234"); + const request = new Request("http://example.com/test", { + headers: { + Authorization: `Bearer ${token}` + } + }); + const api = new Api({ request }); + expect(api.isAuthVerified()).toBe(false); + + const params = api.getParams(); + expect(params.token).toBe(token); + expect(params.token_transport).toBe("header"); + expect(params.host).toBe("http://example.com"); + }); + + it("should construct from request (cookie)", async () => { + const token = await sign({ foo: "bar" }, "1234"); + const request = new Request("http://example.com/test", { + headers: { + Cookie: `auth=${token}` + } + }); + const api = new Api({ request }); + expect(api.isAuthVerified()).toBe(false); + + const params = api.getParams(); + console.log(params); + expect(params.token).toBe(token); + expect(params.token_transport).toBe("cookie"); + expect(params.host).toBe("http://example.com"); + }); + + it("should construct from token", async () => { + const token = await sign({ foo: "bar" }, "1234"); + const api = new Api({ token }); + expect(api.isAuthVerified()).toBe(false); + + const params = api.getParams(); + expect(params.token).toBe(token); + expect(params.token_transport).toBe("header"); + expect(params.host).toBe("http://localhost"); + }); + + it("should prefer host when request is given", async () => { + const request = new Request("http://example.com/test"); + const api = new Api({ request, host: "http://another.com" }); + + const params = api.getParams(); + expect(params.token).toBeUndefined(); + expect(params.token_transport).toBe("header"); + expect(params.host).toBe("http://another.com"); + }); +}); diff --git a/app/__test__/api/ModuleApi.spec.ts b/app/__test__/api/ModuleApi.spec.ts index 5fb6976..9577fd1 100644 --- a/app/__test__/api/ModuleApi.spec.ts +++ b/app/__test__/api/ModuleApi.spec.ts @@ -27,10 +27,7 @@ describe("ModuleApi", () => { it("fetches endpoint", async () => { const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); - const api = new Api({ host }); - - // @ts-expect-error it's protected - api.fetcher = app.request as typeof fetch; + const api = new Api({ host }, app.request as typeof fetch); const res = await api.get("/endpoint"); expect(res.res.ok).toEqual(true); @@ -41,10 +38,7 @@ describe("ModuleApi", () => { it("has accessible request", async () => { const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); - const api = new Api({ host }); - - // @ts-expect-error it's protected - api.fetcher = app.request as typeof fetch; + const api = new Api({ host }, app.request as typeof fetch); const promise = api.get("/endpoint"); expect(promise.request).toBeDefined(); diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index c7ccd45..b484be8 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Perf } from "../../src/core/utils"; -import * as reqres from "../../src/core/utils/reqres"; -import * as strings from "../../src/core/utils/strings"; +import * as utils from "../../src/core/utils"; async function wait(ms: number) { return new Promise((resolve) => { @@ -13,7 +12,7 @@ describe("Core Utils", async () => { describe("[core] strings", async () => { test("objectToKeyValueArray", async () => { const obj = { a: 1, b: 2, c: 3 }; - const result = strings.objectToKeyValueArray(obj); + const result = utils.objectToKeyValueArray(obj); expect(result).toEqual([ { key: "a", value: 1 }, { key: "b", value: 2 }, @@ -22,24 +21,24 @@ describe("Core Utils", async () => { }); test("snakeToPascalWithSpaces", async () => { - const result = strings.snakeToPascalWithSpaces("snake_to_pascal"); + const result = utils.snakeToPascalWithSpaces("snake_to_pascal"); expect(result).toBe("Snake To Pascal"); }); test("randomString", async () => { - const result = strings.randomString(10); + const result = utils.randomString(10); expect(result).toHaveLength(10); }); test("pascalToKebab", async () => { - const result = strings.pascalToKebab("PascalCase"); + const result = utils.pascalToKebab("PascalCase"); expect(result).toBe("pascal-case"); }); test("replaceSimplePlaceholders", async () => { const str = "Hello, {$name}!"; const vars = { name: "John" }; - const result = strings.replaceSimplePlaceholders(str, vars); + const result = utils.replaceSimplePlaceholders(str, vars); expect(result).toBe("Hello, John!"); }); }); @@ -49,7 +48,7 @@ describe("Core Utils", async () => { const headers = new Headers(); headers.append("Content-Type", "application/json"); headers.append("Authorization", "Bearer 123"); - const obj = reqres.headersToObject(headers); + const obj = utils.headersToObject(headers); expect(obj).toEqual({ "content-type": "application/json", authorization: "Bearer 123" @@ -59,21 +58,21 @@ describe("Core Utils", async () => { test("replaceUrlParam", () => { const url = "/api/:id/:name"; const params = { id: "123", name: "test" }; - const result = reqres.replaceUrlParam(url, params); + const result = utils.replaceUrlParam(url, params); expect(result).toBe("/api/123/test"); }); test("encode", () => { const obj = { id: "123", name: "test" }; - const result = reqres.encodeSearch(obj); + const result = utils.encodeSearch(obj); expect(result).toBe("id=123&name=test"); const obj2 = { id: "123", name: ["test1", "test2"] }; - const result2 = reqres.encodeSearch(obj2); + const result2 = utils.encodeSearch(obj2); expect(result2).toBe("id=123&name=test1&name=test2"); const obj3 = { id: "123", name: { test: "test" } }; - const result3 = reqres.encodeSearch(obj3, { encode: true }); + const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); }); @@ -108,4 +107,91 @@ describe("Core Utils", async () => { expect(count).toBe(2); }); }); + + describe("objects", () => { + test("omitKeys", () => { + const objects = [ + [{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }], + [{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }], + [{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }], + [{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }], + [{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}] + ] as [object, string[], object][]; + + for (const [obj, keys, expected] of objects) { + const result = utils.omitKeys(obj, keys as any); + expect(result).toEqual(expected); + } + }); + + test("isEqual", () => { + const objects = [ + [1, 1, true], + [1, "1", false], + [1, 2, false], + ["1", "1", true], + ["1", "2", false], + [true, true, true], + [true, false, false], + [false, false, true], + [1, NaN, false], + [NaN, NaN, true], + [null, null, true], + [null, undefined, false], + [undefined, undefined, true], + [new Map([["a", 1]]), new Map([["a", 1]]), true], + [new Map([["a", 1]]), new Map([["a", 2]]), false], + [new Map([["a", 1]]), new Map([["b", 1]]), false], + [ + new Map([["a", 1]]), + new Map([ + ["a", 1], + ["b", 2] + ]), + false + ], + [{ a: 1 }, { a: 1 }, true], + [{ a: 1 }, { a: 2 }, false], + [{ a: 1 }, { b: 1 }, false], + [{ a: "1" }, { a: "1" }, true], + [{ a: "1" }, { a: "2" }, false], + [{ a: "1" }, { b: "1" }, false], + [{ a: 1 }, { a: 1, b: 2 }, false], + [{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true], + [{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false], + [{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false], + [{ a: { b: 1 } }, { a: { b: 1 } }, true], + [{ a: { b: 1 } }, { a: { b: 2 } }, false], + [{ a: { b: 1 } }, { a: { c: 1 } }, false], + [{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false], + [[1, 2, 3], [1, 2, 3], true], + [[1, 2, 3], [1, 2, 4], false], + [[1, 2, 3], [1, 2, 3, 4], false], + [[{ a: 1 }], [{ a: 1 }], true], + [[{ a: 1 }], [{ a: 2 }], false], + [[{ a: 1 }], [{ b: 1 }], false] + ] as [any, any, boolean][]; + + for (const [a, b, expected] of objects) { + const result = utils.isEqual(a, b); + expect(result).toEqual(expected); + } + }); + + test("getPath", () => { + const tests = [ + [{ a: 1, b: 2, c: 3 }, "a", 1], + [{ a: 1, b: 2, c: 3 }, "b", 2], + [{ a: { b: 1 } }, "a.b", 1], + [{ a: { b: 1 } }, "a.b.c", null, null], + [{ a: { b: 1 } }, "a.b.c", 1, 1], + [[[1]], "0.0", 1] + ] as [object, string, any, any][]; + + for (const [obj, path, expected, defaultValue] of tests) { + const result = utils.getPath(obj, path, defaultValue); + expect(result).toEqual(expected); + } + }); + }); }); diff --git a/app/__test__/data/data-query-impl.spec.ts b/app/__test__/data/data-query-impl.spec.ts index c03b1fe..d219bcc 100644 --- a/app/__test__/data/data-query-impl.spec.ts +++ b/app/__test__/data/data-query-impl.spec.ts @@ -123,6 +123,23 @@ describe("data-query-impl", () => { } } ); + + // 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); + } }); }); diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts new file mode 100644 index 0000000..2fa535a --- /dev/null +++ b/app/__test__/ui/json-form.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { Draft2019 } from "json-schema-library"; +import type { JSONSchema } from "json-schema-to-ts"; +import * as utils from "../../src/ui/components/form/json-schema-form/utils"; +import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils"; + +describe("json form", () => { + test("coerse", () => { + const examples = [ + ["test", { type: "string" }, "test"], + ["1", { type: "integer" }, 1], + ["1", { type: "number" }, 1], + ["true", { type: "boolean" }, true], + ["false", { type: "boolean" }, false], + ["1", { type: "boolean" }, true], + ["0", { type: "boolean" }, false], + ["on", { type: "boolean" }, true], + ["off", { type: "boolean" }, false], + ["null", { type: "null" }, null] + ] satisfies [string, Exclude, any][]; + + for (const [input, schema, output] of examples) { + expect(utils.coerce(input, schema)).toBe(output); + } + }); + + test("isType", () => { + const examples = [ + ["string", "string", true], + ["integer", "number", false], + ["number", "number", true], + ["boolean", "boolean", true], + ["null", "null", true], + ["object", "object", true], + ["array", "array", true], + ["object", "array", false], + [["string", "number"], "number", true], + ["number", ["string", "number"], true] + ] satisfies [IsTypeType, IsTypeType, boolean][]; + + for (const [type, schemaType, output] of examples) { + expect(utils.isType(type, schemaType)).toBe(output); + } + }); + + test("getParentPointer", () => { + const examples = [ + ["#/nested/property/0/name", "#/nested/property/0"], + ["#/nested/property/0", "#/nested/property"], + ["#/nested/property", "#/nested"], + ["#/nested", "#"] + ]; + + for (const [input, output] of examples) { + expect(utils.getParentPointer(input)).toBe(output); + } + }); + + test("isRequired", () => { + const examples = [ + [ + "#/description", + { type: "object", properties: { description: { type: "string" } } }, + false + ], + [ + "#/description", + { + type: "object", + required: ["description"], + properties: { description: { type: "string" } } + }, + true + ], + [ + "#/nested/property", + { + type: "object", + properties: { + nested: { + type: "object", + properties: { property: { type: "string" } } + } + } + }, + false + ], + [ + "#/nested/property", + { + type: "object", + properties: { + nested: { + type: "object", + required: ["property"], + properties: { property: { type: "string" } } + } + } + }, + true + ] + ] satisfies [string, Exclude, boolean][]; + + for (const [pointer, schema, output] of examples) { + expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output); + } + }); + + test("prefixPath", () => { + const examples = [ + ["normal", "", "normal"], + ["", "prefix", "prefix"], + ["tags", "0", "0.tags"], + ["tags", 0, "0.tags"], + ["nested.property", "prefix", "prefix.nested.property"], + ["nested.property", "", "nested.property"] + ] satisfies [string, any, string][]; + + for (const [path, prefix, output] of examples) { + expect(utils.prefixPath(path, prefix)).toBe(output); + } + }); + + test("suffixPath", () => { + const examples = [ + ["normal", "", "normal"], + ["", "suffix", "suffix"], + ["tags", "0", "tags.0"], + ["tags", 0, "tags.0"], + ["nested.property", "suffix", "nested.property.suffix"], + ["nested.property", "", "nested.property"] + ] satisfies [string, any, string][]; + + for (const [path, suffix, output] of examples) { + expect(utils.suffixPath(path, suffix)).toBe(output); + } + }); +}); diff --git a/app/build.ts b/app/build.ts index e931021..ebbf33a 100644 --- a/app/build.ts +++ b/app/build.ts @@ -173,13 +173,20 @@ function baseConfig(adapter: string): tsup.Options { ], metafile: true, splitting: false, - treeshake: true, onSuccess: async () => { delayTypes(); } }; } +// base adapter handles +await tsup.build({ + ...baseConfig(""), + entry: ["src/adapter/index.ts"], + outDir: "dist/adapter" +}); + +// specific adatpers await tsup.build(baseConfig("remix")); await tsup.build(baseConfig("bun")); await tsup.build(baseConfig("astro")); diff --git a/app/package.json b/app/package.json index a04b686..b19a44e 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.6.2", + "version": "0.7.0-rc.11", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -33,26 +33,30 @@ "license": "FSL-1.1-MIT", "dependencies": { "@cfworker/json-schema": "^2.0.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-liquid": "^6.2.1", + "@hello-pangea/dnd": "^17.0.0", "@libsql/client": "^0.14.0", + "@mantine/core": "^7.13.4", "@sinclair/typebox": "^0.32.34", "@tanstack/react-form": "0.19.2", + "@uiw/react-codemirror": "^4.23.6", + "@xyflow/react": "^12.3.2", "aws4fetch": "^1.0.18", "dayjs": "^1.11.13", "fast-xml-parser": "^4.4.0", "hono": "^4.6.12", + "json-schema-form-react": "^0.0.2", + "json-schema-library": "^10.0.0-rc7", "kysely": "^0.27.4", "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", - "swr": "^2.2.5", - "json-schema-form-react": "^0.0.2", - "@uiw/react-codemirror": "^4.23.6", - "@codemirror/lang-html": "^6.4.9", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.1", - "@xyflow/react": "^12.3.2", - "@mantine/core": "^7.13.4", - "@hello-pangea/dnd": "^17.0.0" + "object-path-immutable": "^4.1.2", + "radix-ui": "^1.1.2", + "json-schema-to-ts": "^3.1.1", + "swr": "^2.2.5" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", @@ -62,7 +66,6 @@ "@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^3.9.1", "@libsql/kysely-libsql": "^0.4.1", - "@radix-ui/react-scroll-area": "^1.2.0", "@rjsf/core": "^5.22.2", "@tabler/icons-react": "3.18.0", "@types/node": "^22.10.0", @@ -148,6 +151,10 @@ "import": "./dist/adapter/cloudflare/index.js", "require": "./dist/adapter/cloudflare/index.cjs" }, + "./adapter": { + "types": "./dist/types/adapter/index.d.ts", + "import": "./dist/adapter/index.js" + }, "./adapter/vite": { "types": "./dist/types/adapter/vite/index.d.ts", "import": "./dist/adapter/vite/index.js", diff --git a/app/src/Api.ts b/app/src/Api.ts index 835ff14..f0946f0 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -17,13 +17,21 @@ declare global { } export type ApiOptions = { - host: string; - user?: TApiUser; - token?: string; + host?: string; headers?: Headers; key?: string; localStorage?: boolean; -}; + fetcher?: typeof fetch; + verified?: boolean; +} & ( + | { + token?: string; + user?: TApiUser; + } + | { + request: Request; + } +); export type AuthState = { token?: string; @@ -42,14 +50,26 @@ export class Api { public auth!: AuthApi; public media!: MediaApi; - constructor(private readonly options: ApiOptions) { - if (options.user) { - this.user = options.user; - this.token_transport = "none"; - this.verified = true; - } else if (options.token) { + constructor(private options: ApiOptions = {}) { + // only mark verified if forced + this.verified = options.verified === true; + + // prefer request if given + if ("request" in options) { + this.options.host = options.host ?? new URL(options.request.url).origin; + this.options.headers = options.headers ?? options.request.headers; + this.extractToken(); + + // then check for a token + } else if ("token" in options) { this.token_transport = "header"; this.updateToken(options.token); + + // then check for an user object + } else if ("user" in options) { + this.token_transport = "none"; + this.user = options.user; + this.verified = options.verified !== false; } else { this.extractToken(); } @@ -58,7 +78,7 @@ export class Api { } get baseUrl() { - return this.options.host; + return this.options.host ?? "http://localhost"; } get tokenKey() { @@ -66,13 +86,15 @@ export class Api { } private extractToken() { + // if token has to be extracted, it's never verified + this.verified = false; + if (this.options.headers) { // try cookies const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); if (cookieToken) { - this.updateToken(cookieToken); this.token_transport = "cookie"; - this.verified = true; + this.updateToken(cookieToken); return; } @@ -96,6 +118,8 @@ export class Api { updateToken(token?: string, rebuild?: boolean) { this.token = token; + this.verified = false; + if (token) { this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; } else { @@ -115,11 +139,15 @@ export class Api { if (rebuild) this.buildApis(); } - markAuthVerified(verfied: boolean) { + private markAuthVerified(verfied: boolean) { this.verified = verfied; return this; } + isAuthVerified(): boolean { + return this.verified; + } + getAuthState(): AuthState { return { token: this.token, @@ -128,6 +156,11 @@ export class Api { }; } + isAuthenticated(): boolean { + const { token, user } = this.getAuthState(); + return !!token && !!user; + } + async getVerifiedAuthState(): Promise { await this.verifyAuth(); return this.getAuthState(); @@ -140,11 +173,13 @@ export class Api { } try { - const res = await this.auth.me(); - if (!res.ok || !res.body.user) { + const { ok, data } = await this.auth.me(); + const user = data?.user; + if (!ok || !user) { throw new Error(); } + this.user = user; this.markAuthVerified(true); } catch (e) { this.markAuthVerified(false); @@ -156,21 +191,29 @@ export class Api { return this.user || null; } - private buildApis() { - const baseParams = { - host: this.options.host, + getParams() { + return Object.freeze({ + host: this.baseUrl, token: this.token, headers: this.options.headers, token_transport: this.token_transport - }; - - this.system = new SystemApi(baseParams); - this.data = new DataApi(baseParams); - this.auth = new AuthApi({ - ...baseParams, - onTokenUpdate: (token) => this.updateToken(token, true) }); - this.media = new MediaApi(baseParams); + } + + private buildApis() { + const baseParams = this.getParams(); + const fetcher = this.options.fetcher; + + this.system = new SystemApi(baseParams, fetcher); + this.data = new DataApi(baseParams, fetcher); + this.auth = new AuthApi( + { + ...baseParams, + onTokenUpdate: (token) => this.updateToken(token, true) + }, + fetcher + ); + this.media = new MediaApi(baseParams, fetcher); } } diff --git a/app/src/App.ts b/app/src/App.ts index b98fc67..d32d57c 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,6 +1,7 @@ import type { CreateUserPayload } from "auth/AppAuth"; import { Event } from "core/events"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; +import type { Hono } from "hono"; import { type InitialModuleConfigs, ModuleManager, @@ -132,7 +133,7 @@ export class App { return this.modules.ctx().em; } - get fetch(): any { + get fetch(): Hono["fetch"] { return this.server.fetch; } @@ -155,6 +156,10 @@ export class App { return this.modules.version(); } + isBuilt(): boolean { + return this.modules.isBuilt(); + } + registerAdminController(config?: AdminControllerOptions) { // register admin this.adminController = new AdminController(this, config); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 479b873..c15d570 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,7 +1,8 @@ -import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; -import { Api, type ApiOptions, type App } from "bknd"; +import type { App } from "bknd"; +import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import { Api, type ApiOptions } from "bknd/client"; -export type AstroBkndConfig = FrameworkBkndConfig; +export type AstroBkndConfig = FrameworkBkndConfig; type TAstro = { request: Request; @@ -13,18 +14,20 @@ export type Options = { host?: string; }; -export function getApi(Astro: TAstro, options: Options = { mode: "static" }) { - return new Api({ +export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) { + const api = new Api({ host: new URL(Astro.request.url).origin, headers: options.mode === "dynamic" ? Astro.request.headers : undefined }); + await api.verifyAuth(); + return api; } let app: App; -export function serve(config: AstroBkndConfig = {}) { - return async (args: TAstro) => { +export function serve(config: AstroBkndConfig = {}) { + return async (args: Context) => { if (!app) { - app = await createFrameworkApp(config); + app = await createFrameworkApp(config, args); } return app.fetch(args.request); }; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 390ac3a..05eec3c 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -2,10 +2,11 @@ import path from "node:path"; import type { App } from "bknd"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import { config } from "bknd/core"; import type { ServeOptions } from "bun"; -import { config } from "core"; import { serveStatic } from "hono/bun"; -import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; let app: App; @@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); if (!app) { + registerLocalMediaAdapter(); app = await createRuntimeApp({ ...config, - registerLocalMedia: true, serveStatic: serveStatic({ root }) }); } diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index da3c762..3a4044b 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,18 +1,17 @@ -import type { CreateAppConfig } from "bknd"; +import type { FrameworkBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { FrameworkBkndConfig } from "../index"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import { getFresh, getWarm } from "./modes/fresh"; -export type CloudflareBkndConfig = Omit & { - app: CreateAppConfig | ((env: Env) => CreateAppConfig); +export type CloudflareBkndConfig = FrameworkBkndConfig> & { mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (env: Env) => { + bindings?: (args: Context) => { kv?: KVNamespace; dobj?: DurableObjectNamespace; }; + static?: "kv" | "assets"; key?: string; keepAliveSeconds?: number; forceHttps?: boolean; @@ -21,28 +20,33 @@ export type CloudflareBkndConfig = Omit & html?: string; }; -export type Context = { +export type Context = { request: Request; - env: any; + env: Env; ctx: ExecutionContext; }; -export function serve(config: CloudflareBkndConfig) { +export function serve(config: CloudflareBkndConfig) { return { - async fetch(request: Request, env: any, ctx: ExecutionContext) { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); - const manifest = config.manifest; - if (manifest) { + if (config.manifest && config.static === "assets") { + console.warn("manifest is not useful with static 'assets'"); + } else if (!config.manifest && config.static === "kv") { + throw new Error("manifest is required with static 'kv'"); + } + + if (config.manifest && config.static !== "assets") { const pathname = url.pathname.slice(1); - const assetManifest = JSON.parse(manifest); + const assetManifest = JSON.parse(config.manifest); if (pathname && pathname in assetManifest) { const hono = new Hono(); hono.all("*", async (c, next) => { const res = await serveStatic({ path: `./${pathname}`, - manifest + manifest: config.manifest! })(c as any, next); if (res instanceof Response) { const ttl = 60 * 60 * 24 * 365; diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index a238ae0..a367e5d 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -1,8 +1,8 @@ -import { createRuntimeApp } from "adapter"; import { App } from "bknd"; +import { createRuntimeApp } from "bknd/adapter"; import type { CloudflareBkndConfig, Context } from "../index"; -export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) { +export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { const { kv } = config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont }, adminOptions: { html: config.html } }, - env + { env, ctx, ...args } ); if (!cachedConfig) { diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 3787b5c..bd58f85 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -1,7 +1,7 @@ import { DurableObject } from "cloudflare:workers"; -import { createRuntimeApp } from "adapter"; -import type { CloudflareBkndConfig, Context } from "adapter/cloudflare"; import type { App, CreateAppConfig } from "bknd"; +import { createRuntimeApp, makeConfig } from "bknd/adapter"; +import type { CloudflareBkndConfig, Context } from "../index"; export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { const { dobj } = config.bindings?.(ctx.env)!; @@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { const id = dobj.idFromName(key); const stub = dobj.get(id) as unknown as DurableBkndApp; - const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app; + const create_config = makeConfig(config, ctx); const res = await stub.fire(ctx.request, { config: create_config, diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index cb5ece7..ef40987 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,14 +1,14 @@ -import { createRuntimeApp } from "adapter"; import type { App } from "bknd"; +import { createRuntimeApp } from "bknd/adapter"; import type { CloudflareBkndConfig, Context } from "../index"; -export async function makeApp(config: CloudflareBkndConfig, { env }: Context) { +export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { return await createRuntimeApp( { ...config, adminOptions: config.html ? { html: config.html } : undefined }, - env + ctx ); } diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 19bcdef..1c48f8b 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,59 +1,29 @@ -import type { IncomingMessage } from "node:http"; -import { App, type CreateAppConfig, registries } from "bknd"; -import { config as $config } from "core"; +import { App, type CreateAppConfig } from "bknd"; +import { config as $config } from "bknd/core"; import type { MiddlewareHandler } from "hono"; -import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter"; import type { AdminControllerOptions } from "modules/server/AdminController"; -export type BkndConfig = CreateAppConfig & { - app?: CreateAppConfig | ((env: Env) => CreateAppConfig); +export type BkndConfig = CreateAppConfig & { + app?: CreateAppConfig | ((args: Args) => CreateAppConfig); onBuilt?: (app: App) => Promise; beforeBuild?: (app: App) => Promise; buildConfig?: Parameters[0]; }; -export type FrameworkBkndConfig = BkndConfig; +export type FrameworkBkndConfig = BkndConfig; -export type RuntimeBkndConfig = BkndConfig & { +export type RuntimeBkndConfig = BkndConfig & { distPath?: string; }; -export function nodeRequestToRequest(req: IncomingMessage): Request { - let protocol = "http"; - try { - protocol = req.headers["x-forwarded-proto"] as string; - } catch (e) {} - const host = req.headers.host; - const url = `${protocol}://${host}${req.url}`; - const headers = new Headers(); - - for (const [key, value] of Object.entries(req.headers)) { - if (Array.isArray(value)) { - headers.append(key, value.join(", ")); - } else if (value) { - headers.append(key, value); - } - } - - const method = req.method || "GET"; - return new Request(url, { - method, - headers - }); -} - -export function registerLocalMediaAdapter() { - registries.media.register("local", StorageLocalAdapter); -} - -export function makeConfig(config: BkndConfig, env?: Env): CreateAppConfig { +export function makeConfig(config: BkndConfig, args?: Args): CreateAppConfig { let additionalConfig: CreateAppConfig = {}; if ("app" in config && config.app) { if (typeof config.app === "function") { - if (!env) { - throw new Error("env is required when config.app is a function"); + if (!args) { + throw new Error("args is required when config.app is a function"); } - additionalConfig = config.app(env); + additionalConfig = config.app(args); } else { additionalConfig = config.app; } @@ -62,11 +32,11 @@ export function makeConfig(config: BkndConfig, env?: Env): Creat return { ...config, ...additionalConfig }; } -export async function createFrameworkApp( +export async function createFrameworkApp( config: FrameworkBkndConfig, - env?: Env + args?: Args ): Promise { - const app = App.create(makeConfig(config, env)); + const app = App.create(makeConfig(config, args)); if (config.onBuilt) { app.emgr.onEvent( @@ -87,20 +57,14 @@ export async function createFrameworkApp( export async function createRuntimeApp( { serveStatic, - registerLocalMedia, adminOptions, ...config }: RuntimeBkndConfig & { serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; - registerLocalMedia?: boolean; adminOptions?: AdminControllerOptions | false; }, env?: Env ): Promise { - if (registerLocalMedia) { - registerLocalMediaAdapter(); - } - const app = App.create(makeConfig(config, env)); app.emgr.onEvent( diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 65d5ffa..adaf853 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { Api, type App } from "bknd"; -import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index"; +import { nodeRequestToRequest } from "adapter/utils"; +import type { App } from "bknd"; +import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import { Api } from "bknd/client"; export type NextjsBkndConfig = FrameworkBkndConfig & { cleanSearch?: string[]; @@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) { } export function withApi(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) { - return (ctx: GetServerSidePropsContext & { api: Api }) => { - return handler({ ...ctx, api: createApi(ctx) }); + return async (ctx: GetServerSidePropsContext & { api: Api }) => { + const api = createApi(ctx); + await api.verifyAuth(); + return handler({ ...ctx, api }); }; } diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index bc5b7e5..b70a274 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,6 +1,12 @@ -export * from "./node.adapter"; -export { - StorageLocalAdapter, - type LocalAdapterConfig +import { registries } from "bknd"; +import { + type LocalAdapterConfig, + StorageLocalAdapter } from "../../media/storage/adapters/StorageLocalAdapter"; -export { registerLocalMediaAdapter } from "../index"; + +export * from "./node.adapter"; +export { StorageLocalAdapter, type LocalAdapterConfig }; + +export function registerLocalMediaAdapter() { + registries.media.register("local", StorageLocalAdapter); +} diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 4f98466..326ab92 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -1,9 +1,10 @@ import path from "node:path"; import { serve as honoServe } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; +import { registerLocalMediaAdapter } from "adapter/node/index"; import type { App } from "bknd"; -import { config as $config } from "core"; -import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { config as $config } from "bknd/core"; export type NodeBkndConfig = RuntimeBkndConfig & { port?: number; @@ -37,9 +38,9 @@ export function serve({ hostname, fetch: async (req: Request) => { if (!app) { + registerLocalMediaAdapter(); app = await createRuntimeApp({ ...config, - registerLocalMedia: true, serveStatic: serveStatic({ root }) }); } diff --git a/app/src/adapter/remix/AdminPage.tsx b/app/src/adapter/remix/AdminPage.tsx index 9361cc2..5c9e90b 100644 --- a/app/src/adapter/remix/AdminPage.tsx +++ b/app/src/adapter/remix/AdminPage.tsx @@ -1,9 +1,11 @@ +import { useAuth } from "bknd/client"; import type { BkndAdminProps } from "bknd/ui"; import { Suspense, lazy, useEffect, useState } from "react"; export function adminPage(props?: BkndAdminProps) { const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); return () => { + const auth = useAuth(); const [loaded, setLoaded] = useState(false); useEffect(() => { if (typeof window === "undefined") return; @@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) { return ( - + ); }; diff --git a/app/src/adapter/remix/remix.adapter.ts b/app/src/adapter/remix/remix.adapter.ts index c3d0c78..22470bb 100644 --- a/app/src/adapter/remix/remix.adapter.ts +++ b/app/src/adapter/remix/remix.adapter.ts @@ -1,14 +1,37 @@ -import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; import type { App } from "bknd"; +import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import { Api } from "bknd/client"; -export type RemixBkndConfig = FrameworkBkndConfig; +export type RemixBkndConfig = FrameworkBkndConfig; + +type RemixContext = { + request: Request; +}; let app: App; -export function serve(config: RemixBkndConfig = {}) { - return async (args: { request: Request }) => { +export function serve( + config: RemixBkndConfig = {} +) { + return async (args: Args) => { if (!app) { - app = await createFrameworkApp(config); + app = await createFrameworkApp(config, args); } return app.fetch(args.request); }; } + +export function withApi( + handler: (args: Args, api: Api) => Promise +) { + return async (args: Args) => { + if (!args.context.api) { + args.context.api = new Api({ + host: new URL(args.request.url).origin, + headers: args.request.headers + }); + await args.context.api.verifyAuth(); + } + + return handler(args, args.context.api); + }; +} diff --git a/app/src/adapter/utils.ts b/app/src/adapter/utils.ts new file mode 100644 index 0000000..f804133 --- /dev/null +++ b/app/src/adapter/utils.ts @@ -0,0 +1,25 @@ +import type { IncomingMessage } from "node:http"; + +export function nodeRequestToRequest(req: IncomingMessage): Request { + let protocol = "http"; + try { + protocol = req.headers["x-forwarded-proto"] as string; + } catch (e) {} + const host = req.headers.host; + const url = `${protocol}://${host}${req.url}`; + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + headers.append(key, value.join(", ")); + } else if (value) { + headers.append(key, value); + } + } + + const method = req.method || "GET"; + return new Request(url, { + method, + headers + }); +} diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index c8cb43d..dee0603 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,7 +1,8 @@ import { serveStatic } from "@hono/node-server/serve-static"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; -import { type RuntimeBkndConfig, createRuntimeApp } from "adapter"; import type { App } from "bknd"; +import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { devServerConfig } from "./dev-server-config"; export type ViteBkndConfig = RuntimeBkndConfig & { @@ -28,10 +29,10 @@ ${addBkndContext ? "" : ""} } async function createApp(config: ViteBkndConfig = {}, env?: any) { + registerLocalMediaAdapter(); return await createRuntimeApp( { ...config, - registerLocalMedia: true, adminOptions: config.setAdminHtml === false ? undefined diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index d02a258..f5ba882 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi { } async login(strategy: string, input: any) { - const res = await this.post([strategy, "login"], input); + const res = await this.post([strategy, "login"], input, { + credentials: "include" + }); + if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } @@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi { } async register(strategy: string, input: any) { - const res = await this.post([strategy, "register"], input); + const res = await this.post([strategy, "register"], input, { + credentials: "include" + }); + if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 82b50e1..2ba24a5 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -21,6 +21,15 @@ export class AuthController extends Controller { return this.auth.ctx.guard; } + get em() { + return this.auth.ctx.em; + } + + get userRepo() { + const entity_name = this.auth.config.entity_name; + return this.em.repo(entity_name as "users"); + } + private registerStrategyActions(strategy: Strategy, mainHono: Hono) { const actions = strategy.getActions?.(); if (!actions) { @@ -96,7 +105,10 @@ export class AuthController extends Controller { hono.get("/me", auth(), async (c) => { if (this.auth.authenticator.isUserLoggedIn()) { - return c.json({ user: this.auth.authenticator.getUser() }); + const claims = this.auth.authenticator.getUser()!; + const { data: user } = await this.userRepo.findId(claims.id); + + return c.json({ user }); } return c.json({ user: null }, 403); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 7b81ed7..7853dcd 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -259,7 +259,7 @@ export class Authenticator = Record< } async requestCookieRefresh(c: Context) { - if (this.config.cookie.renew) { + if (this.config.cookie.renew && this.isUserLoggedIn()) { const token = await this.getAuthCookie(c); if (token) { await this.setAuthCookie(c, token); @@ -299,8 +299,8 @@ export class Authenticator = Record< } } - private getSuccessPath(c: Context) { - const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); + private getSafeUrl(c: Context, path: string) { + const p = path.replace(/\/+$/, "/"); // nextjs doesn't support non-fq urls // but env could be proxied (stackblitz), so we shouldn't fq every url @@ -312,21 +312,26 @@ export class Authenticator = Record< } async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { - if (this.isJsonRequest(c)) { - return c.json(data); - } - - const successUrl = this.getSuccessPath(c); + 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; diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index d8f8a23..c6a9a37 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,4 +1,5 @@ import type { Authenticator, Strategy } from "auth"; +import { isDebug, tbValidator as tb } from "core"; import { type Static, StringEnum, Type, parse } from "core/utils"; import { hash } from "core/utils"; import { type Context, Hono } from "hono"; @@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy { const hono = new Hono(); return hono - .post("/login", async (c) => { - const body = await authenticator.getBody(c); + .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); + try { + const payload = await this.login(body); + const data = await authenticator.resolve( + "login", + this, + payload.password, + payload + ); - return await authenticator.respond(c, data); - } catch (e) { - return await authenticator.respond(c, e); + return await authenticator.respond(c, data, redirect); + } catch (e) { + return await authenticator.respond(c, e); + } } - }) - .post("/register", async (c) => { - const body = await authenticator.getBody(c); + ) + .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); + const payload = await this.register(body); + const data = await authenticator.resolve( + "register", + this, + payload.password, + payload + ); - return await authenticator.respond(c, data); - }); + return await authenticator.respond(c, data, redirect); + } + ); } getActions(): StrategyActions { diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index ab5b807..2c7c68d 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record { return value !== null && typeof value === "object"; } +export function omitKeys( + obj: T, + keys_: readonly K[] +): Omit> { + const keys = new Set(keys_); + const result = {} as Omit>; + for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { + if (!keys.has(key as K)) { + (result as any)[key] = value; + } + } + return result; +} + export function safelyParseObjectValues(obj: T): T { return Object.entries(obj).reduce((acc, [key, value]) => { try { @@ -266,3 +280,82 @@ export function mergeObjectWith(object, source, customizer) { return object; } + +export function isEqual(value1: any, value2: any): boolean { + // Each type corresponds to a particular comparison algorithm + const getType = (value: any) => { + if (value !== Object(value)) return "primitive"; + if (Array.isArray(value)) return "array"; + if (value instanceof Map) return "map"; + if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value))) + return "plainObject"; + if (value instanceof Function) return "function"; + throw new Error( + `deeply comparing an instance of type ${value1.constructor?.name} is not supported.` + ); + }; + + const type = getType(value1); + if (type !== getType(value2)) { + return false; + } + + if (type === "primitive") { + return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2)); + } else if (type === "array") { + return ( + value1.length === value2.length && + value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i])) + ); + } else if (type === "map") { + // In this particular implementation, map keys are not + // being deeply compared, only map values. + return ( + value1.size === value2.size && + [...value1].every(([iterKey, iterValue]) => { + return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey)); + }) + ); + } else if (type === "plainObject") { + const value1AsMap = new Map(Object.entries(value1)); + const value2AsMap = new Map(Object.entries(value2)); + return ( + value1AsMap.size === value2AsMap.size && + [...value1AsMap].every(([iterKey, iterValue]) => { + return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey)); + }) + ); + } else if (type === "function") { + // just check signature + return value1.toString() === value2.toString(); + } else { + throw new Error("Unreachable"); + } +} + +export function getPath( + object: object, + _path: string | (string | number)[], + defaultValue = undefined +): any { + const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path; + + if (path.length === 0) { + return object; + } + + try { + const [head, ...tail] = path; + if (!head || !(head in object)) { + return defaultValue; + } + + return getPath(object[head], tail, defaultValue); + } catch (error) { + if (typeof defaultValue !== "undefined") { + return defaultValue; + } + + throw new Error(`Invalid path: ${path.join(".")}`); + } +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index c7789dd..a28d7ea 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) { case "SCREAMING_SNAKE_CASE": return snakeToPascalWithSpaces(str.toLowerCase()); case "unknown": - return str; + return ucFirst(str); } } +export function autoFormatString(str: string) { + return identifierToHumanReadable(str); +} export function kebabToPascalWithSpaces(str: string): string { return str.split("-").map(ucFirst).join(" "); diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index a793e33..9e6f2d6 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -21,7 +21,6 @@ import { type ValueErrorIterator } from "@sinclair/typebox/errors"; import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value"; -import { cloneDeep } from "lodash-es"; export type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] @@ -73,7 +72,7 @@ export class TypeInvalidError extends Error { } export function stripMark(obj: O) { - const newObj = cloneDeep(obj); + const newObj = structuredClone(obj); mark(newObj, false); return newObj as O; } diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index 8abf02e..5507c3c 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -5,7 +5,8 @@ import { type StaticDecode, StringEnum, Type, - Value + Value, + isObject } from "core/utils"; import { WhereBuilder, type WhereQuery } from "../entities"; @@ -71,22 +72,51 @@ export type RepoWithSchema = Record< >; export const withSchema = (Self: TSelf) => - Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)])) + Type.Transform( + Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)]) + ) .Decode((value) => { - let _value = typeof value === "string" ? [value] : value; + // images + // images,comments + // ["images","comments"] + // { "images": {} } - if (Array.isArray(value)) { - if (!value.every((v) => typeof v === "string")) { - throw new Error("Invalid 'with' schema"); - } - - _value = value.reduce((acc, v) => { - acc[v] = {}; - return acc; - }, {} as RepoWithSchema); + if (!Array.isArray(value) && isObject(value)) { + console.log("is object"); + return value as RepoWithSchema; } - return _value as RepoWithSchema; + let _value: any = null; + if (typeof value === "string") { + // if stringified object + if (value.match(/^\{/)) { + return JSON.parse(value) as RepoWithSchema; + } + + // if stringified array + if (value.match(/^\[/)) { + _value = JSON.parse(value) as string[]; + + // if comma-separated string + } else if (value.includes(",")) { + _value = value.split(","); + + // if single string + } else { + _value = [value]; + } + } else if (Array.isArray(value)) { + _value = value; + } + + if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) { + throw new Error("Invalid 'with' schema"); + } + + return _value.reduce((acc, v) => { + acc[v] = {}; + return acc; + }, {} as RepoWithSchema); }) .Encode((value) => value); @@ -117,7 +147,7 @@ export type RepoQueryIn = { offset?: number; sort?: string | { by: string; dir: "asc" | "desc" }; select?: string[]; - with?: string[] | Record; + with?: string | string[] | Record; join?: string[]; where?: WhereQuery; }; diff --git a/app/src/index.ts b/app/src/index.ts index 44b1ca5..9593e7c 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -12,8 +12,5 @@ export { export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; -export type * from "./adapter"; -export { Api, type ApiOptions } from "./Api"; - export type { MediaFieldSchema } from "media/AppMedia"; export type { UserFieldSchema } from "auth/AppAuth"; diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 64a52ba..f196c79 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -16,7 +16,8 @@ export function buildMediaSchema() { config: adapter.schema }, { - title: name, + title: adapter.schema.title ?? name, + description: adapter.schema.description, additionalProperties: false } ); diff --git a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts index 771b389..cfb4100 100644 --- a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts @@ -9,7 +9,7 @@ export const cloudinaryAdapterConfig = Type.Object( api_secret: Type.String(), upload_preset: Type.Optional(Type.String()) }, - { title: "Cloudinary" } + { title: "Cloudinary", description: "Cloudinary media storage" } ); export type CloudinaryConfig = Static; diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts index b6c2650..2c142ff 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts @@ -7,7 +7,7 @@ export const localAdapterConfig = Type.Object( { path: Type.String({ default: "./" }) }, - { title: "Local" } + { title: "Local", description: "Local file system storage" } ); export type LocalAdapterConfig = Static; diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index 90c3cb2..b330d64 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object( }) }, { - title: "S3" + title: "AWS S3", + description: "AWS S3 or compatible storage" } ); diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 882cb90..a088170 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -23,9 +23,10 @@ export type ApiResponse = { export type TInput = string | (string | number | PrimaryFieldType)[]; export abstract class ModuleApi { - protected fetcher?: typeof fetch; - - constructor(protected readonly _options: Partial = {}) {} + constructor( + protected readonly _options: Partial = {}, + protected fetcher?: typeof fetch + ) {} protected getDefaultOptions(): Partial { return {}; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index efe7a09..8019c50 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -68,6 +68,12 @@ export type InitialModuleConfigs = } & ModuleConfigs) | PartialRec; +enum Verbosity { + silent = 0, + error = 1, + log = 2 +} + export type ModuleManagerOptions = { initial?: InitialModuleConfigs; eventManager?: EventManager; @@ -85,6 +91,8 @@ export type ModuleManagerOptions = { trustFetched?: boolean; // runs when initial config provided on a fresh database seed?: (ctx: ModuleBuildContext) => Promise; + // wether + verbosity?: Verbosity; }; type ConfigTable = { @@ -135,7 +143,7 @@ export class ModuleManager { private _built = false; private readonly _booted_with?: "provided" | "partial"; - private logger = new DebugLogger(false); + private logger: DebugLogger; constructor( private readonly connection: Connection, @@ -144,6 +152,7 @@ export class ModuleManager { this.__em = new EntityManager([__bknd], this.connection); this.modules = {} as Modules; this.emgr = new EventManager(); + this.logger = new DebugLogger(this.verbosity === Verbosity.log); const context = this.ctx(true); let initial = {} as Partial; @@ -171,6 +180,14 @@ export class ModuleManager { } } + private get verbosity() { + return this.options?.verbosity ?? Verbosity.silent; + } + + isBuilt(): boolean { + return this._built; + } + /** * This is set through module's setListener * It's called everytime a module's config is updated in SchemaObject @@ -241,20 +258,23 @@ export class ModuleManager { const startTime = performance.now(); // disabling console log, because the table might not exist yet - const result = await withDisabledConsole(async () => { - const { data: result } = await this.repo().findOne( - { type: "config" }, - { - sort: { by: "version", dir: "desc" } + const result = await withDisabledConsole( + async () => { + const { data: result } = await this.repo().findOne( + { type: "config" }, + { + sort: { by: "version", dir: "desc" } + } + ); + + if (!result) { + throw BkndError.with("no config"); } - ); - if (!result) { - throw BkndError.with("no config"); - } - - return result as unknown as ConfigTable; - }, ["log", "error", "warn"]); + return result as unknown as ConfigTable; + }, + this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"] + ); this.logger .log("took", performance.now() - startTime, "ms", { diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 7aeb4bb..b7fc900 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -70,7 +70,8 @@ export class AdminController extends Controller { hono.use("*", async (c, next) => { const obj = { user: auth.authenticator?.getUser(), - logout_route: this.withBasePath(authRoutes.logout) + logout_route: this.withBasePath(authRoutes.logout), + color_scheme: configs.server.admin.color_scheme }; const html = await this.getHtml(obj); if (!html) { @@ -190,6 +191,10 @@ export class AdminController extends Controller { /> BKND + {/*