Compare commits

..

1 Commits

Author SHA1 Message Date
dswbx 7d89fe4894 feat: enhance entity field management with configurable order
Added optional `fields_order` to the entity configuration schema, allowing for custom field sorting. Updated `getFields` method to support sorting based on this configuration. Adjusted related methods and UI components to handle field order during updates and serialization. Improved handling of virtual fields in various contexts.
2025-09-20 14:00:19 +02:00
389 changed files with 3991 additions and 15902 deletions
+2 -17
View File
@@ -9,21 +9,6 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -35,11 +20,11 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.3.3" bun-version: "1.2.19"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
run: bun install #--linker=hoisted run: bun install
- name: Build - name: Build
working-directory: ./app working-directory: ./app
+1 -2
View File
@@ -27,8 +27,7 @@ packages/media/.env
.npmrc .npmrc
/.verdaccio /.verdaccio
.idea .idea
.vscode/* .vscode
!.vscode/settings.json
.git_old .git_old
docker/tmp docker/tmp
.debug .debug
-19
View File
@@ -1,19 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [
"**/dist/**",
"**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
],
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
}
}
+77 -169
View File
@@ -1,202 +1,110 @@
# Functional Source License, Version 1.1, MIT Future License
Apache License ## Abbreviation
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION FSL-1.1-MIT
1. Definitions. ## Notice
"License" shall mean the terms and conditions for use, reproduction, Copyright 2025 Dennis Senn
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by ## Terms and Conditions
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all ### Licensor ("We")
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity The party offering the Software under these Terms and Conditions.
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, ### The Software
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical The "Software" is each version of the software that we make available under
transformation or translation of a Source form, including but these Terms and Conditions, as indicated by our inclusion of these Terms and
not limited to compiled object code, generated documentation, Conditions with the Software.
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or ### License Grant
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object Subject to your compliance with this License Grant and the Patents,
form, that is based on (or derived from) the Work and for which the Redistribution and Trademark clauses below, we hereby grant you the right to
editorial revisions, annotations, elaborations, or other modifications use, copy, modify, create derivative works, publicly perform, publicly display
represent, as a whole, an original work of authorship. For the purposes and redistribute the Software for any Permitted Purpose identified below.
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including ### Permitted Purpose
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
on behalf of whom a Contribution has been received by Licensor and means making the Software available to others in a commercial product or
subsequently incorporated within the Work. service that:
2. Grant of Copyright License. Subject to the terms and conditions of 1. substitutes for the Software;
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of 2. substitutes for any other product or service we offer using the Software
this License, each Contributor hereby grants to You a perpetual, that exists as of the date we make the Software available; or
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the 3. offers the same or substantially similar functionality as the Software.
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Permitted Purposes specifically include using the Software:
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices 1. for your internal use and access;
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works 2. for non-commercial education;
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its 3. for non-commercial research; and
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and 4. in connection with professional services that you provide to a licensee
may provide additional or different license terms and conditions using the Software in accordance with these Terms and Conditions.
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, ### Patents
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade To the extent your use for a Permitted Purpose would necessarily infringe our
names, trademarks, service marks, or product names of the Licensor, patents, the license grant above includes a license under our patents. If you
except as required for reasonable and customary use in describing the make a claim against any party that the Software infringes or contributes to
origin of the Work and reproducing the content of the NOTICE file. the infringement of any patent, then your patent license to the Software ends
immediately.
7. Disclaimer of Warranty. Unless required by applicable law or ### Redistribution
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, The Terms and Conditions apply to all copies, modifications and derivatives of
whether in tort (including negligence), contract, or otherwise, the Software.
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing If you redistribute any copies, modifications or derivatives of the Software,
the Work or Derivative Works thereof, You may choose to offer, you must include a copy of or a link to these Terms and Conditions and not
and charge a fee for, acceptance of support, warranty, indemnity, remove any copyright notices provided in or with the Software.
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS ### Disclaimer
APPENDIX: How to apply the Apache License to your work. THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
To apply the Apache License to your work, attach the following IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
boilerplate notice, with the fields enclosed by brackets "[]" SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
replaced with your own identifying information. (Don't include EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Dennis Senn ### Trademarks
Licensed under the Apache License, Version 2.0 (the "License"); Except for displaying the License Details and identifying us as the origin of
you may not use this file except in compliance with the License. the Software, you have no right under these Terms and Conditions to use our
You may obtain a copy of the License at trademarks, trade names, service marks or product names.
http://www.apache.org/licenses/LICENSE-2.0 ## Grant of Future License
Unless required by applicable law or agreed to in writing, software We hereby irrevocably grant you an additional license to use the Software under
distributed under the License is distributed on an "AS IS" BASIS, the MIT license that is effective on the second anniversary of the date we make
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. the Software available. On or after that date, you may use the Software under
See the License for the specific language governing permissions and the MIT license, in which case the following will apply:
limitations under the License.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -19
View File
@@ -8,17 +8,13 @@
</a> </a>
</p> </p>
bknd simplifies app development by providing a fully functional visual backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way.
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc. * **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
* **Databases**: * **Databases**:
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal * SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata * Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku * **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS) * **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
**For documentation and examples, please visit https://docs.bknd.io.** **For documentation and examples, please visit https://docs.bknd.io.**
@@ -28,18 +24,6 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu
> Please keep in mind that **bknd** is still under active development > Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Use Cases
bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to:
- **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend
- **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server.
- **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider
- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead
- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI
- **IoT & Embedded Devices** where minimal footprint matters
## Size ## Size
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
@@ -62,13 +46,13 @@ Creating digital products always requires developing both the backend (the logic
* **Media**: Effortlessly manage and serve all your media files. * **Media**: Effortlessly manage and serve all your media files.
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!) * **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
* 🌐 Built on Web Standards for maximum compatibility * 🌐 Built on Web Standards for maximum compatibility
* 🛠️ MCP server, client and UI built-in to control your backend
* 🏃‍♂️ Multiple run modes * 🏃‍♂️ Multiple run modes
* standalone using the CLI * standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd) * using a JavaScript runtime (Node, Bun, workerd)
* using a React framework (Next.js, React Router, Astro) * using a React framework (Next.js, React Router, Astro)
* 📦 Official API and React SDK with type-safety * 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components * ⚛️ React elements for auto-configured authentication and media components
* 🛠️ MCP server, client and UI built-in to control your backend
## Structure ## Structure
The package is mainly split into 4 parts, each serving a specific purpose: The package is mainly split into 4 parts, each serving a specific purpose:
-1
View File
@@ -20,7 +20,6 @@ VITE_SHOW_ROUTES=
# ===== Test Credentials ===== # ===== Test Credentials =====
RESEND_API_KEY= RESEND_API_KEY=
PLUNK_API_KEY=
R2_TOKEN= R2_TOKEN=
R2_ACCESS_KEY= R2_ACCESS_KEY=
-12
View File
@@ -1,12 +0,0 @@
{
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"search.exclude": {
"**/dist/**": true,
"**/node_modules/**": true
},
"files.exclude": {
"**/dist/**": true
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ describe("App tests", async () => {
expect(Array.from(app.plugins.keys())).toEqual(["test"]); expect(Array.from(app.plugins.keys())).toEqual(["test"]);
}); });
test("drivers", async () => { test.only("drivers", async () => {
const called: string[] = []; const called: string[] = [];
const app = new App(dummyConnection, undefined, { const app = new App(dummyConnection, undefined, {
drivers: { drivers: {
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
hello
+25 -35
View File
@@ -1,49 +1,44 @@
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test"; import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter"; import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("adapter", () => { describe("adapter", () => {
describe("makeConfig", () => { it("makes config", async () => {
it("returns empty config for empty inputs", async () => { expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
const cases: Array<Parameters<typeof adapter.makeConfig>> = [ expect(
[{}], omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
[{}, { env: { TEST: "test" } }], ).toEqual({});
];
for (const args of cases) { // merges everything returned from `app` with the config
const cfg = await adapter.makeConfig(...(args as any)); expect(
expect(stripConnection(cfg)).toEqual({}); omitKeys(
} await adapter.makeConfig(
});
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } }, { env: { TEST: "test" } },
); ),
["connection"],
expect(stripConnection(cfg)).toEqual({ ),
).toEqual({
config: { server: { cors: { origin: "test" } } }, config: { server: { cors: { origin: "test" } } },
}); });
}); });
it("allows all properties in app() result", async () => { it("allows all properties in app function", async () => {
const called = mock(() => null); const called = mock(() => null);
const config = await adapter.makeConfig(
const cfg = await adapter.makeConfig(
{ {
app: (env) => ({ app: (env) => ({
connection: { url: "test" }, connection: { url: "test" },
config: { server: { cors: { origin: "test" } } }, config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const }, options: {
mode: "db",
},
onBuilt: () => { onBuilt: () => {
called(); called();
expect(env).toEqual({ foo: "bar" }); expect(env).toEqual({ foo: "bar" });
@@ -52,17 +47,13 @@ describe("adapter", () => {
}, },
{ foo: "bar" }, { foo: "bar" },
); );
expect(config.connection).toEqual({ url: "test" });
expect(cfg.connection).toEqual({ url: "test" }); expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } }); expect(config.options).toEqual({ mode: "db" });
expect(cfg.options).toEqual({ mode: "db" }); await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
}); });
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp, makeApp: adapter.createFrameworkApp,
label: "framework app", label: "framework app",
@@ -73,4 +64,3 @@ describe("adapter", () => {
label: "runtime app", label: "runtime app",
}); });
}); });
});
+2 -5
View File
@@ -6,16 +6,13 @@ describe("Api", async () => {
it("should construct without options", () => { it("should construct without options", () => {
const api = new Api(); const api = new Api();
expect(api.baseUrl).toBe("http://localhost"); expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
// verified is true, because no token, user, headers or request given
// therefore nothing to check, auth state is verified
expect(api.isAuthVerified()).toBe(true);
}); });
it("should ignore force verify if no claims given", () => { it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true }); const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost"); expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(true); expect(api.isAuthVerified()).toBe(false);
}); });
it("should construct from request (token)", async () => { it("should construct from request (token)", async () => {
+11 -34
View File
@@ -1,7 +1,7 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { describe, expect, it, mock } from "bun:test"; import { describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils"; import { getFileFromContext, isFile, isReadableStream } from "core/utils";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
@@ -15,7 +15,7 @@ const mockedBackend = new Hono()
.get("/file/:name", async (c) => { .get("/file/:name", async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
const file = Bun.file(`${assetsPath}/${name}`); const file = Bun.file(`${assetsPath}/${name}`);
return new Response(new File([await file.bytes()], name, { type: file.type }), { return new Response(file, {
headers: { headers: {
"Content-Type": file.type, "Content-Type": file.type,
"Content-Length": file.size.toString(), "Content-Length": file.size.toString(),
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
const res = await mockedBackend.request("/api/media/file/" + name); const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res); await Bun.write(path, res);
const file = Bun.file(path); const file = await Bun.file(path);
expect(file.size).toBeGreaterThan(0); expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png"); expect(file.type).toBe("image/png");
await file.delete(); await file.delete();
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
expect(isReadableStream(res.body)).toBe(true); expect(isReadableStream(res.body)).toBe(true);
expect(isReadableStream(res.res.body)).toBe(true); expect(isReadableStream(res.res.body)).toBe(true);
const blob = (await res.res.blob()) as File; const blob = await res.res.blob();
expect(isFile(blob)).toBe(true); expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");
@@ -113,7 +113,7 @@ describe("MediaApi", () => {
const res = await api.getFileStream(name); const res = await api.getFileStream(name);
expect(isReadableStream(res)).toBe(true); expect(isReadableStream(res)).toBe(true);
const blob = (await new Response(res).blob()) as File; const blob = await new Response(res).blob();
expect(isFile(blob)).toBe(true); expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");
@@ -154,38 +154,15 @@ describe("MediaApi", () => {
} }
// upload via readable from bun // upload via readable from bun
await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response // upload via readable from response
{ {
const response = (await mockedBackend.request(url)) as Response; const response = (await mockedBackend.request(url)) as Response;
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png"); await matches(
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png",
);
} }
}); });
it("should add overwrite query for entity upload", async (c) => {
const call = mock(() => null);
const hono = new Hono().post(
"/api/media/entity/:entity/:id/:field",
jsc("query", s.object({ overwrite: s.boolean().optional() })),
async (c) => {
const { overwrite } = c.req.valid("query");
expect(overwrite).toBe(true);
call();
return c.json({ ok: true });
},
);
const api = new MediaApi(
{
upload_fetcher: hono.request,
},
hono.request,
);
const file = Bun.file(`${assetsPath}/image.png`);
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
overwrite: true,
});
expect(res.ok).toBe(true);
expect(call).toHaveBeenCalled();
});
}); });
-2
View File
@@ -16,7 +16,6 @@ describe("AppServer", () => {
mcp: { mcp: {
enabled: false, enabled: false,
path: "/api/system/mcp", path: "/api/system/mcp",
logLevel: "warning",
}, },
}); });
} }
@@ -39,7 +38,6 @@ describe("AppServer", () => {
mcp: { mcp: {
enabled: false, enabled: false,
path: "/api/system/mcp", path: "/api/system/mcp",
logLevel: "warning",
}, },
}); });
} }
-131
View File
@@ -1,131 +0,0 @@
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({
connection: getDummyConnection().dummyConnection,
...config,
options: {
...config.options,
mode: "code",
},
});
await app.build();
return app;
}
describe("code-only", () => {
test("should create app with correct manager", async () => {
const app = await createApp();
await app.build();
expect(app.version()).toBeDefined();
expect(app.modules).toBeInstanceOf(ModuleManager);
});
test("should not perform database syncs", async () => {
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
});
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute(),
).toEqual([]);
// only perform when explicitly forced
await app.em.schema().sync({ force: true });
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute()
.then((r) => r.map((r) => r.name)),
).toEqual(["test", "sqlite_sequence"]);
});
test("should not perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
expect(called).not.toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([]);
});
test("should sync and perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
expect(called).toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([{ id: 1, name: "test" }]);
});
test("should not allow to modify config", async () => {
const app = await createApp();
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
expect(() => {
app.modules.configs().auth.enabled = true;
}).toThrow();
});
});
+3 -9
View File
@@ -44,7 +44,6 @@ describe("mcp auth", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {
@@ -201,10 +200,7 @@ describe("mcp auth", async () => {
}, },
return_config: true, return_config: true,
}); });
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([ expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
"read",
"write",
]);
// update role // update role
await tool(server, "config_auth_roles_update", { await tool(server, "config_auth_roles_update", {
@@ -213,15 +209,13 @@ describe("mcp auth", async () => {
permissions: ["read"], permissions: ["read"],
}, },
}); });
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([ expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
"read",
]);
// get role // get role
const getGuestRole = await tool(server, "config_auth_roles_get", { const getGuestRole = await tool(server, "config_auth_roles_get", {
key: "guest", key: "guest",
}); });
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]); expect(getGuestRole.value.permissions).toEqual(["read"]);
// remove role // remove role
await tool(server, "config_auth_roles_remove", { await tool(server, "config_auth_roles_remove", {
-5
View File
@@ -34,11 +34,6 @@ describe("mcp", () => {
}); });
await app.build(); await app.build();
// expect mcp to not be loaded yet
expect(app.mcp).toBeNull();
// after first request, mcp should be loaded
await app.getMcpClient().listTools();
expect(app.mcp?.tools.length).toBeGreaterThan(0); expect(app.mcp?.tools.length).toBeGreaterThan(0);
}); });
}); });
-1
View File
@@ -50,7 +50,6 @@ describe("mcp data", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {
-1
View File
@@ -39,7 +39,6 @@ describe("mcp media", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {
-1
View File
@@ -24,7 +24,6 @@ describe("mcp system", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
}); });
+1 -6
View File
@@ -1,11 +1,7 @@
import { AppEvents } from "App"; import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test"; import { describe, test, expect, beforeAll, mock } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils"; import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/** /**
* - [x] system_config * - [x] system_config
@@ -27,7 +23,6 @@ describe("mcp system", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
}); });
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, test } from "bun:test";
import { code, hybrid } from "modes";
describe("modes", () => {
describe("code", () => {
test("verify base configuration", async () => {
const c = code({}) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["options"]);
expect(config.options.mode).toEqual("code");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
test("keeps overrides", async () => {
const c = code({
connection: {
url: ":memory:",
},
}) as any;
const config = await c.app?.({} as any);
expect(config.connection.url).toEqual(":memory:");
});
});
describe("hybrid", () => {
test("fails if no reader is provided", () => {
// @ts-ignore
expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/);
});
test("verify base configuration", async () => {
const c = hybrid({ reader: async () => ({}) }) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]);
expect(config.options.mode).toEqual("db");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
});
});
+1 -1
View File
@@ -76,7 +76,7 @@ describe("repros", async () => {
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
}); });
test("verify inversedBy", async () => { test.only("verify inversedBy", async () => {
const schema = proto.em( const schema = proto.em(
{ {
products: proto.entity("products", { products: proto.entity("products", {
+1 -39
View File
@@ -1,41 +1,3 @@
import { Authenticator } from "auth/authenticate/Authenticator";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
describe("Authenticator", async () => { describe("Authenticator", async () => {});
test("should return auth cookie headers", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const headers = await auth.getAuthCookieHeader("token");
const cookie = headers.get("Set-Cookie");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
// now expect it to be removed
const headers2 = await auth.removeAuthCookieHeader(headers);
const cookie2 = headers2.get("Set-Cookie");
expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires=");
expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
test("should return auth cookie string", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const cookie = await auth.unsafeGetAuthCookie("token");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
});
+20 -182
View File
@@ -1,35 +1,9 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Guard } from "../../../src/auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
function createGuard(
permissionNames: string[],
roles?: Record<string, Omit<RoleSchema, "name">>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.create(name, { permissions, is_default, implicit_allow });
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
describe("authorize", () => { describe("authorize", () => {
const read = new Permission("read", {
filterable: true,
});
const write = new Permission("write");
test("basic", async () => { test("basic", async () => {
const guard = createGuard( const guard = Guard.create(
["read", "write"], ["read", "write"],
{ {
admin: { admin: {
@@ -42,14 +16,14 @@ describe("authorize", () => {
role: "admin", role: "admin",
}; };
expect(guard.granted(read, user)).toBeUndefined(); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted(write, user)).toBeUndefined(); expect(guard.granted("write", user)).toBe(true);
expect(() => guard.granted(new Permission("something"), {})).toThrow(); expect(() => guard.granted("something")).toThrow();
}); });
test("with default", async () => { test("with default", async () => {
const guard = createGuard( const guard = Guard.create(
["read", "write"], ["read", "write"],
{ {
admin: { admin: {
@@ -63,26 +37,26 @@ describe("authorize", () => {
{ enabled: true }, { enabled: true },
); );
expect(guard.granted(read, {})).toBeUndefined(); expect(guard.granted("read")).toBe(true);
expect(() => guard.granted(write, {})).toThrow(); expect(guard.granted("write")).toBe(false);
const user = { const user = {
role: "admin", role: "admin",
}; };
expect(guard.granted(read, user)).toBeUndefined(); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted(write, user)).toBeUndefined(); expect(guard.granted("write", user)).toBe(true);
}); });
test("guard implicit allow", async () => { test("guard implicit allow", async () => {
const guard = createGuard([], {}, { enabled: false }); const guard = Guard.create([], {}, { enabled: false });
expect(guard.granted(read, {})).toBeUndefined(); expect(guard.granted("read")).toBe(true);
expect(guard.granted(write, {})).toBeUndefined(); expect(guard.granted("write")).toBe(true);
}); });
test("role implicit allow", async () => { test("role implicit allow", async () => {
const guard = createGuard(["read", "write"], { const guard = Guard.create(["read", "write"], {
admin: { admin: {
implicit_allow: true, implicit_allow: true,
}, },
@@ -92,12 +66,12 @@ describe("authorize", () => {
role: "admin", role: "admin",
}; };
expect(guard.granted(read, user)).toBeUndefined(); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted(write, user)).toBeUndefined(); expect(guard.granted("write", user)).toBe(true);
}); });
test("guard with guest role implicit allow", async () => { test("guard with guest role implicit allow", async () => {
const guard = createGuard(["read", "write"], { const guard = Guard.create(["read", "write"], {
guest: { guest: {
implicit_allow: true, implicit_allow: true,
is_default: true, is_default: true,
@@ -105,143 +79,7 @@ describe("authorize", () => {
}); });
expect(guard.getUserRole()?.name).toBe("guest"); expect(guard.getUserRole()?.name).toBe("guest");
expect(guard.granted(read, {})).toBeUndefined(); expect(guard.granted("read")).toBe(true);
expect(guard.granted(write, {})).toBeUndefined(); expect(guard.granted("write")).toBe(true);
});
describe("cases", () => {
test("guest none, member deny if user.enabled is false", () => {
const guard = createGuard(
["read"],
{
guest: {
is_default: true,
},
member: {
permissions: [
{
permission: "read",
policies: [
{
condition: {},
effect: "filter",
filter: {
type: "member",
},
},
{
condition: {
"user.enabled": false,
},
effect: "deny",
},
],
},
],
},
},
{ enabled: true },
);
expect(() => guard.granted(read, { role: "guest" })).toThrow();
// member is allowed, because default role permission effect is allow
// and no deny policy is met
expect(guard.granted(read, { role: "member" })).toBeUndefined();
// member is allowed, because deny policy is not met
expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined();
// member is denied, because deny policy is met
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
// get the filter for member role
expect(guard.filters(read, { role: "member" }).filter).toEqual({
type: "member",
});
// get filter for guest
expect(guard.filters(read, {}).filter).toBeUndefined();
});
test("guest should only read posts that are public", () => {
const read = new Permission(
"read",
{
// make this permission filterable
// without this, `filter` policies have no effect
filterable: true,
},
// expect the context to match this schema
// otherwise exit with 500 to ensure proper policy checking
s.object({
entity: s.string(),
}),
);
const guard = createGuard(
["read"],
{
guest: {
// this permission is applied if no (or invalid) role is provided
is_default: true,
permissions: [
{
permission: "read",
// effect deny means only having this permission, doesn't guarantee access
effect: "deny",
policies: [
{
// only if this condition is met
condition: {
entity: {
$in: ["posts"],
},
},
// the effect is allow
effect: "allow",
},
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
public: true,
},
},
],
},
],
},
// members should be allowed to read all
member: {
permissions: [
{
permission: "read",
},
],
},
},
{ enabled: true },
);
// guest can only read posts
expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined();
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
// and guests can only read public posts
expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
public: true,
});
// member can read posts and users
expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined();
expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined();
// member should not have a filter
expect(
guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
).toBeUndefined();
});
}); });
}); });
@@ -1,327 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import * as proto from "data/prototype";
import { mergeObject } from "core/utils/objects";
import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({
config: mergeObject(
{
data: proto
.em(
{
users: proto.systemEntity("users", {}),
posts: proto.entity("posts", {
title: proto.text(),
content: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
},
({ relation }, { users, posts, comments }) => {
relation(posts).manyToOne(users);
relation(comments).manyToOne(posts);
},
)
.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
},
config,
),
});
await app.build();
return app;
}
async function createUsers(app: App, users: CreateUserPayload[]) {
return Promise.all(
users.map(async (user) => {
return await app.createUser(user);
}),
);
}
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
const results = {} as any;
for (const [entity, data] of Object.entries(fixtures)) {
results[entity] = await app.em
.mutator(entity as any)
.insertMany(data)
.then((result) => result.data);
}
return results;
}
describe("data permissions", async () => {
const app = await makeApp({
server: {
mcp: {
enabled: true,
},
},
auth: {
guard: {
enabled: true,
},
roles: {
guest: {
is_default: true,
permissions: [
{
permission: "system.access.api",
},
{
permission: "data.entity.read",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.create",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.update",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.delete",
policies: [
{
condition: { entity: "posts" },
},
{
condition: { entity: "posts" },
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
],
},
},
},
});
const users = [
{ email: "foo@example.com", password: "password" },
{ email: "bar@example.com", password: "password" },
];
const fixtures = {
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 },
{ content: "post 3", users_id: null },
],
comments: [
{ content: "comment 1", posts_id: 1 },
{ content: "comment 2", posts_id: 2 },
{ content: "comment 3", posts_id: 3 },
],
};
await createUsers(app, users);
const results = await loadFixtures(app, fixtures);
describe("http", async () => {
it("read many", async () => {
// many only includes posts with users_id is null
const res = await app.server.request("/api/data/entity/posts");
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
// same with /query
{
const res = await app.server.request("/api/data/entity/posts/query", {
method: "POST",
});
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
}
});
it("read one", async () => {
// one only includes posts with users_id is null
{
const res = await app.server.request("/api/data/entity/posts/1");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(404);
expect(data).toBeUndefined();
}
// read one by allowed id
{
const res = await app.server.request("/api/data/entity/posts/3");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.posts[2]);
}
});
it("read many by reference", async () => {
const res = await app.server.request("/api/data/entity/posts/1/comments");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
});
it("mutation create one", async () => {
// not allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
}
// allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4", users_id: null }),
});
expect(res.status).toBe(201);
}
});
it("mutation update one", async () => {
// update one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "PATCH",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
{
// update one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "PATCH",
body: JSON.stringify({ content: "post 3 (updated)" }),
});
expect(res.status).toBe(200);
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
}
});
it("mutation update many", async () => {
// update many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
update: { content: "post 4" },
where: { users_id: { $isnull: 0 } },
}),
});
expect(res.status).toBe(200); // because filtered
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
expect(_data.every((u: any) => u === null)).toBe(true);
// verify
const data = await app.em
.repo("posts")
.findMany({ select: ["content", "users_id"] })
.then((r) => r.data);
// expect non null users_id to not have content "post 4"
expect(
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
).toBe(true);
// expect null users_id to have content "post 4"
expect(
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
).toBe(true);
});
const count = async () => {
const {
data: { count: _count },
} = await app.em.repo("posts").count();
return _count;
};
it("mutation delete one", async () => {
const initial = await count();
// delete one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "DELETE",
});
expect(res.status).toBe(403);
expect(await count()).toBe(initial);
{
// delete one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await count()).toBe(initial - 1);
}
});
it("mutation delete many", async () => {
// delete many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
where: {},
}),
});
expect(res.status).toBe(200);
// only deleted posts with users_id is null
const remaining = await app.em
.repo("posts")
.findMany()
.then((r) => r.data);
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
});
});
});
@@ -1,40 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { em, entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const schema = em(
{
posts: entity("posts", {
title: text(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
({ relation }, { posts, comments }) => {
relation(posts).manyToOne(comments);
},
);
describe("DataController (auth)", () => {
test("reading schema.json", async () => {
const { request } = await createAuthTestApp(
{
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
request: new Request("http://localhost/api/data/schema.json"),
},
{
config: { data: schema.toJSON() },
},
);
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
@@ -1,41 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("SystemController (auth)", () => {
test("reading info", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.info"],
request: new Request("http://localhost/api/system/info"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("reading permissions", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.schema.read"],
request: new Request("http://localhost/api/system/permissions"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("access openapi", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.openapi"],
request: new Request("http://localhost/api/system/openapi.json"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
-171
View File
@@ -1,171 +0,0 @@
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import type { RoleSchema } from "auth/authorize/Role";
import { isPlainObject } from "core/utils";
export type AuthTestConfig = {
guest?: RoleSchema;
member?: RoleSchema;
authorized?: RoleSchema;
};
export async function createAuthTestApp(
testConfig: {
permission: AuthTestConfig | string | string[];
request: Request;
},
config: Partial<CreateAppConfig> = {},
) {
let member: RoleSchema | undefined;
let authorized: RoleSchema | undefined;
let guest: RoleSchema | undefined;
if (isPlainObject(testConfig.permission)) {
if (testConfig.permission.guest)
guest = {
...testConfig.permission.guest,
is_default: true,
};
if (testConfig.permission.member) member = testConfig.permission.member;
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
} else {
member = {
permissions: [],
};
authorized = {
permissions: Array.isArray(testConfig.permission)
? testConfig.permission
: [testConfig.permission],
};
guest = {
permissions: [],
is_default: true,
};
}
console.log("authorized", authorized);
const app = createApp({
...config,
config: {
...config.config,
auth: {
...config.config?.auth,
enabled: true,
guard: {
enabled: true,
...config.config?.auth?.guard,
},
jwt: {
...config.config?.auth?.jwt,
secret: "secret",
},
roles: {
...config.config?.auth?.roles,
guest,
member,
authorized,
admin: {
implicit_allow: true,
},
},
},
},
});
await app.build();
const users = {
guest: null,
member: await app.createUser({
email: "member@test.com",
password: "12345678",
role: "member",
}),
authorized: await app.createUser({
email: "authorized@test.com",
password: "12345678",
role: "authorized",
}),
admin: await app.createUser({
email: "admin@test.com",
password: "12345678",
role: "admin",
}),
} as const;
const tokens = {} as Record<keyof typeof users, string>;
for (const [key, user] of Object.entries(users)) {
if (user) {
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
}
}
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
const headers = new Headers(init.headers ?? {});
if (user in tokens) {
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
}
const res = await app.server.request(input, {
...init,
headers,
});
let data: any;
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
data = await res.json();
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
data = await res.text();
}
return {
status: res.status,
ok: res.ok,
headers: Object.fromEntries(res.headers.entries()),
data,
};
}
const requestFn = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async (input: string, init: RequestInit = {}) => {
return makeRequest(prop, input, init);
};
},
},
) as {
[K in keyof typeof users]: (
input: string,
init?: RequestInit,
) => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
const request = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async () => {
return makeRequest(prop, testConfig.request.url, {
headers: testConfig.request.headers,
method: testConfig.request.method,
body: testConfig.request.body,
});
};
},
},
) as {
[K in keyof typeof users]: () => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
return { app, users, request, requestFn };
}
@@ -1,543 +0,0 @@
import { describe, it, expect } from "bun:test";
import { s } from "bknd/utils";
import { Permission } from "auth/authorize/Permission";
import { Policy } from "auth/authorize/Policy";
import { Hono } from "hono";
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
import { auth } from "auth/middlewares/auth.middleware";
import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
import { Role, RolePermission } from "auth/authorize/Role";
import { Exception } from "bknd";
import { convert } from "core/object/query/object-query";
describe("Permission", () => {
it("works with minimal schema", () => {
expect(() => new Permission("test")).not.toThrow();
});
it("parses context", () => {
const p = new Permission(
"test3",
{
filterable: true,
},
s.object({
a: s.string(),
}),
);
// @ts-expect-error
expect(() => p.parseContext({ a: [] })).toThrow();
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
// @ts-expect-error
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
});
});
describe("Policy", () => {
it("works with minimal schema", () => {
expect(() => new Policy().toJSON()).not.toThrow();
});
it("checks condition", () => {
const p = new Policy({
condition: {
a: 1,
},
});
expect(p.meetsCondition({ a: 1 })).toBe(true);
expect(p.meetsCondition({ a: 2 })).toBe(false);
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
expect(p.meetsCondition({})).toBe(false);
const p2 = new Policy({
condition: {
a: { $gt: 1 },
$or: {
b: { $lt: 2 },
},
},
});
expect(p2.meetsCondition({ a: 2 })).toBe(true);
expect(p2.meetsCondition({ a: 1 })).toBe(false);
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
});
it("filters", () => {
const p = new Policy({
filter: {
age: { $gt: 18 },
},
});
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
expect(p.meetsFilter({ age: 19 })).toBe(true);
expect(p.meetsFilter({ age: 17 })).toBe(false);
expect(p.meetsFilter({ age: 12 })).toBe(false);
});
it("replaces placeholders", () => {
const p = new Policy({
condition: {
a: "@auth.username",
},
filter: {
a: "@auth.username",
},
});
const vars = { auth: { username: "test" } };
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
expect(p.meetsCondition({ a: "test2" })).toBe(false);
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
expect(p.meetsFilter({ a: "test2" })).toBe(false);
});
});
describe("Guard", () => {
it("collects filters", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
]);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
// if no user context given, filter cannot be applied
expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
});
it("collects filters for default role", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
],
true,
);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 1 },
).filter,
).toEqual({ foo: "bar" });
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 2 },
).filter,
).toBeUndefined();
// if no user context given, the default role is applied
// hence it can be found
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
});
it("merges filters correctly", () => {
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
foo: { $eq: "bar", $lt: 1 },
});
// overwrite base $or with priority
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
$or: {
foo: {
$eq: "bar",
},
},
foo: {
$eq: "bar",
},
});
// ignore base $or if priority has different key
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
$or: {
other: {
$eq: "one",
},
},
foo: {
$eq: "bar",
},
});
});
});
describe("permission middleware", () => {
const makeApp = (
permissions: Permission<any, any, any, any>[],
roles: Role[] = [],
config: Partial<GuardConfig> = {},
) => {
const app = {
module: {
auth: {
enabled: true,
},
},
modules: {
ctx: () => ({
guard: new Guard(permissions, roles, {
enabled: true,
...config,
}),
}),
},
};
return new Hono()
.use(async (c, next) => {
// @ts-expect-error
c.set("app", app);
await next();
})
.use(auth())
.onError((err, c) => {
if (err instanceof Exception) {
return c.json(err.toJSON(), err.code as any);
}
return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500);
});
};
it("allows if guard is disabled", async () => {
const p = new Permission("test");
const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) =>
c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
expect(await res.text()).toBe("test");
});
it("denies if guard is enabled", async () => {
const p = new Permission("test");
const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user has (plain) role", async () => {
const p = new Permission("test");
const r = Role.create("test", { permissions: [p.name] });
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("allows if user has role with policy", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $gte: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("denies if user with role doesn't meet condition", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(
p,
[
new Policy({
condition: {
a: { $lt: 1 },
},
// default effect is allow
}),
],
// change default effect to deny if no condition is met
"deny",
),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user with role doesn't meet condition (from middleware)", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("throws if permission context is invalid", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
// expecting 500 because bknd should have handled it correctly
expect(res.status).toBe(500);
});
it("checks context on routes with permissions", async () => {
const make = (user: any) => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
return makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user });
await next();
})
.get(
"/valid",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid",
permission(p, {
// @ts-expect-error
context: (c) => ({
b: "1",
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid2",
permission(p, {
// @ts-expect-error
context: (c) => ({}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid3",
// @ts-expect-error
permission(p),
async (c) => c.text("test"),
);
};
const hono = make({ id: 0, role: "test" });
const valid = await hono.request("/valid");
expect(valid.status).toBe(200);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
{
const hono = make(null);
const valid = await hono.request("/valid");
expect(valid.status).toBe(403);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
}
});
});
describe("Role", () => {
it("serializes and deserializes", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
effect: "deny",
filter: {
b: { $lt: 1 },
},
}),
]),
],
true,
);
const json = JSON.parse(JSON.stringify(r.toJSON()));
const r2 = Role.create(p.name, json);
expect(r2.toJSON()).toEqual(r.toJSON());
});
});
@@ -1,13 +0,0 @@
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { describe, expect, it } from "bun:test";
describe("PasswordStrategy", () => {
it("should enforce provided minimum length", async () => {
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
expect(strategy.verify("password")({} as any)).rejects.toThrow();
expect(
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
).resolves.toBeUndefined();
});
});
@@ -66,14 +66,4 @@ describe("object-query", () => {
expect(result).toBe(expected); expect(result).toBe(expected);
} }
}); });
test("paths", () => {
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
expect(result).toBe(true);
});
test("empty filters", () => {
const result = validate({}, { user: { age: 17 } });
expect(result).toBe(true);
});
}); });
-120
View File
@@ -1,120 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
makeValidator,
exp,
Expression,
isPrimitive,
type Primitive,
} from "../../../src/core/object/query/query";
describe("query", () => {
test("isPrimitive", () => {
expect(isPrimitive(1)).toBe(true);
expect(isPrimitive("1")).toBe(true);
expect(isPrimitive(true)).toBe(true);
expect(isPrimitive(false)).toBe(true);
// not primitives
expect(isPrimitive(null)).toBe(false);
expect(isPrimitive(undefined)).toBe(false);
expect(isPrimitive([])).toBe(false);
expect(isPrimitive({})).toBe(false);
expect(isPrimitive(Symbol("test"))).toBe(false);
expect(isPrimitive(new Date())).toBe(false);
expect(isPrimitive(new Error())).toBe(false);
expect(isPrimitive(new Set())).toBe(false);
expect(isPrimitive(new Map())).toBe(false);
});
test("strict expression creation", () => {
// @ts-expect-error
expect(() => exp()).toThrow();
// @ts-expect-error
expect(() => exp("")).toThrow();
// @ts-expect-error
expect(() => exp("invalid")).toThrow();
// @ts-expect-error
expect(() => exp("$eq")).toThrow();
// @ts-expect-error
expect(() => exp("$eq", 1)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null, 1)).toThrow();
expect(
exp(
"$eq",
() => true,
() => null,
),
).toBeInstanceOf(Expression);
});
test("$eq is required", () => {
expect(() => makeValidator([])).toThrow();
expect(() =>
makeValidator([
exp(
"$valid",
() => true,
() => null,
),
]),
).toThrow();
expect(
makeValidator([
exp(
"$eq",
() => true,
() => null,
),
]),
).toBeDefined();
});
test("validates filter structure", () => {
const validator = makeValidator([
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(e, a) => e === a,
),
exp(
"$like",
(v: string) => typeof v === "string",
(e, a) => e === a,
),
]);
// @ts-expect-error intentionally typed as union of given expression keys
expect(validator.expressionKeys).toEqual(["$eq", "$like"]);
// @ts-expect-error "$and" is not allowed
expect(() => validator.convert({ $and: {} })).toThrow();
// @ts-expect-error "$or" must be an object
expect(() => validator.convert({ $or: [] })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow();
// @ts-expect-error "null" is not a valid value
expect(() => validator.convert({ foo: null })).toThrow();
// @ts-expect-error only primitives are allowed for $eq
expect(() => validator.convert({ foo: { $eq: [] } })).toThrow();
// @ts-expect-error only strings are allowed for $like
expect(() => validator.convert({ foo: { $like: 1 } })).toThrow();
// undefined values are ignored
expect(validator.convert({ foo: undefined })).toEqual({});
expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } });
});
});
-205
View File
@@ -194,182 +194,6 @@ describe("Core Utils", async () => {
expect(result).toEqual(expected); expect(result).toEqual(expected);
} }
}); });
test("recursivelyReplacePlaceholders", () => {
// test basic replacement with simple pattern
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
const variables1 = { name: "John" };
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
// test the specific example from the user request
const obj2 = { some: "value", here: "@auth.user" };
const variables2 = { auth: { user: "what" } };
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
expect(result2).toEqual({ some: "value", here: "what" });
// test with arrays
const obj3 = { items: ["@config.name", "static", "@config.version"] };
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
// test with nested objects and deep paths
const obj4 = {
user: "@auth.user.name",
settings: {
theme: "@ui.theme",
nested: {
value: "@deep.nested.value",
},
},
};
const variables4 = {
auth: { user: { name: "Alice" } },
ui: { theme: "dark" },
deep: { nested: { value: "found" } },
};
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
expect(result4).toEqual({
user: "Alice",
settings: {
theme: "dark",
nested: {
value: "found",
},
},
});
// test with missing paths (should return original match)
const obj5 = { value: "@missing.path" };
const variables5 = { existing: "value" };
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
expect(result5).toEqual({ value: "@missing.path" });
// test with non-matching strings (should remain unchanged)
const obj6 = { value: "normal string", other: "not@matching" };
const variables6 = { some: "value" };
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
// test with primitive values (should handle gracefully)
expect(
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
test: { value: "replaced" },
}),
).toBe("replaced");
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
// test type preservation for full string matches
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
const result7 = utils.recursivelyReplacePlaceholders(
{
number: "@test.value",
boolean: "@test.flag",
nullValue: "@test.data",
array: "@test.arr",
},
/^@([a-z\.]+)$/,
variables7,
null,
);
expect(result7).toEqual({
number: 123,
boolean: true,
nullValue: null,
array: [1, 2, 3],
});
// test partial string replacement (should convert to string)
const result8 = utils.recursivelyReplacePlaceholders(
{ message: "The value is @test.value!" },
/@([a-z\.]+)/g,
variables7,
);
expect(result8).toEqual({ message: "The value is 123!" });
// test with fallback parameter
const obj9 = { user: "@user.id", config: "@config.theme" };
const variables9 = {}; // empty context
const result9 = utils.recursivelyReplacePlaceholders(
obj9,
/^@([a-z\.]+)$/,
variables9,
null,
);
expect(result9).toEqual({ user: null, config: null });
// test with fallback for partial matches
const obj10 = { message: "Hello @user.name, welcome!" };
const variables10 = {}; // empty context
const result10 = utils.recursivelyReplacePlaceholders(
obj10,
/@([a-z\.]+)/g,
variables10,
"Guest",
);
expect(result10).toEqual({ message: "Hello Guest, welcome!" });
// test with different fallback types
const obj11 = {
stringFallback: "@missing.string",
numberFallback: "@missing.number",
booleanFallback: "@missing.boolean",
objectFallback: "@missing.object",
};
const variables11 = {};
const result11 = utils.recursivelyReplacePlaceholders(
obj11,
/^@([a-z\.]+)$/,
variables11,
"default",
);
expect(result11).toEqual({
stringFallback: "default",
numberFallback: "default",
booleanFallback: "default",
objectFallback: "default",
});
// test fallback with arrays
const obj12 = { items: ["@item1", "@item2", "static"] };
const variables12 = { item1: "found" }; // item2 is missing
const result12 = utils.recursivelyReplacePlaceholders(
obj12,
/^@([a-zA-Z0-9\.]+)$/,
variables12,
"missing",
);
expect(result12).toEqual({ items: ["found", "missing", "static"] });
// test fallback with nested objects
const obj13 = {
user: "@user.id",
settings: {
theme: "@theme.name",
nested: {
value: "@deep.value",
},
},
};
const variables13 = {}; // empty context
const result13 = utils.recursivelyReplacePlaceholders(
obj13,
/^@([a-z\.]+)$/,
variables13,
null,
);
expect(result13).toEqual({
user: null,
settings: {
theme: null,
nested: {
value: null,
},
},
});
});
}); });
describe("file", async () => { describe("file", async () => {
@@ -440,35 +264,6 @@ describe("Core Utils", async () => {
height: 512, height: 512,
}); });
}); });
test("isFileAccepted", () => {
const file = new File([""], "file.txt", {
type: "text/plain",
});
expect(utils.isFileAccepted(file, "text/plain")).toBe(true);
expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true);
expect(utils.isFileAccepted(file, "text/html")).toBe(false);
{
const file = new File([""], "file.jpg", {
type: "image/jpeg",
});
expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true);
expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true);
expect(utils.isFileAccepted(file, "image/png")).toBe(false);
expect(utils.isFileAccepted(file, "image/*")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true);
expect(utils.isFileAccepted(file, ".png")).toBe(false);
}
{
const file = new File([""], "file.png");
expect(utils.isFileAccepted(file, undefined as any)).toBe(true);
}
expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow();
});
}); });
describe("dates", () => { describe("dates", () => {
+2 -2
View File
@@ -30,9 +30,9 @@ describe("some tests", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
expect(query.sql).toBe( expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?', 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
); );
expect(query.parameters).toEqual([1, 1, 0]); expect(query.parameters).toEqual([1, 1]);
expect(query.data).toBeUndefined(); expect(query.data).toBeUndefined();
}); });
-85
View File
@@ -1,85 +0,0 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const credentials = {
host: "localhost",
port: 5430,
user: "postgres",
password: "postgres",
database: "bknd",
};
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
async function isPostgresRunning() {
try {
// Try to actually connect to PostgreSQL
const conn = pg({ pool: new Pool(credentials) });
await conn.ping();
await conn.close();
return true;
} catch (e) {
return false;
}
}
describe("postgres", () => {
beforeAll(async () => {
if (!(await isPostgresRunning())) {
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
await $waitUntil("Postgres is running", isPostgresRunning);
await new Promise((resolve) => setTimeout(resolve, 500));
}
disableConsoleLog();
});
afterAll(async () => {
if (await isPostgresRunning()) {
try {
await $`docker stop bknd-test-postgres`;
} catch (e) {}
}
enableConsoleLog();
});
describe.serial.each([
["pg", () => pg({ pool: new Pool(credentials) })],
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
])("%s", (name, createConnection) => {
connectionTestSuite(
{
...bunTestRunner,
test: test.serial,
},
{
makeConnection: () => {
const connection = createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection);
await connection.close();
},
};
},
rawDialectDetails: [],
disableConsoleLog: false,
},
);
});
});
@@ -124,81 +124,6 @@ describe("[Repository]", async () => {
.then((r) => [r.count, r.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]); ).resolves.toEqual([undefined, undefined]);
}); });
test("auto join", async () => {
const schema = $em(
{
posts: $entity("posts", {
title: $text(),
content: $text(),
}),
comments: $entity("comments", {
content: $text(),
}),
another: $entity("another", {
title: $text(),
}),
},
({ relation }, { posts, comments }) => {
relation(comments).manyToOne(posts);
},
);
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
await em
.mutator("comments")
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
const res = await em.repo("comments").findMany({
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
{
// manual join should still work
const res = await em.repo("comments").findMany({
join: ["posts"],
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
}
// inexistent should be detected and thrown
expect(
em.repo("comments").findMany({
where: {
"random.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
// existing alias, but not a relation should throw
expect(
em.repo("comments").findMany({
where: {
"another.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
});
}); });
describe("[data] Repository (Events)", async () => { describe("[data] Repository (Events)", async () => {
+1 -36
View File
@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { randomString } from "core/utils"; import { randomString } from "core/utils";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { TextField, EntityIndex } from "data/fields"; import { TextField, EntityIndex } from "data/fields";
@@ -268,39 +268,4 @@ describe("SchemaManager tests", async () => {
const diffAfter = await em.schema().getDiff(); const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0); expect(diffAfter.length).toBe(0);
}); });
test("returns statements", async () => {
const amount = 5;
const entities = new Array(amount)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const statements = await em.schema().sync({ force: true });
expect(statements.length).toBe(amount);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
});
test("batches statements", async () => {
const { dummyConnection } = getDummyConnection();
const entities = new Array(20)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const spy = spyOn(em.connection, "executeQueries");
const statements = await em.schema().sync();
expect(statements.length).toBe(entities.length);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
await em.schema().sync({ force: true });
expect(spy).toHaveBeenCalledTimes(1);
const tables = await em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute();
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
});
}); });
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: true, hasDefaultValue: false,
comment: undefined, comment: undefined,
}, },
{ {
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: true, hasDefaultValue: false,
comment: undefined, comment: undefined,
}, },
{ {
@@ -7,7 +7,7 @@ describe("[data] JsonField", async () => {
const field = new JsonField("test"); const field = new JsonField("test");
fieldTestSuite(bunTestRunner, JsonField, { fieldTestSuite(bunTestRunner, JsonField, {
defaultValue: { a: 1 }, defaultValue: { a: 1 },
//sampleValues: ["string", { test: 1 }, 1], sampleValues: ["string", { test: 1 }, 1],
schemaType: "text", schemaType: "text",
}); });
@@ -33,9 +33,9 @@ describe("[data] JsonField", async () => {
}); });
test("getValue", async () => { test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 }); expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
expect(field.getValue("string", "form")).toBe("string"); expect(field.getValue("string", "form")).toBe('"string"');
expect(field.getValue(1, "form")).toBe(1); expect(field.getValue(1, "form")).toBe("1");
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 }); expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
expect(field.getValue('"string"', "submit")).toBe("string"); expect(field.getValue('"string"', "submit")).toBe("string");
@@ -43,5 +43,6 @@ describe("[data] JsonField", async () => {
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}'); expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
expect(field.getValue("string", "table")).toBe('"string"'); expect(field.getValue("string", "table")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
}); });
}); });
@@ -1,15 +1,9 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares"; import { auth } from "../../src/auth/middlewares";
import { import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
mergeObject,
randomString,
secureRandomString,
withDisabledConsole,
} from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import type { AppAuthSchema } from "auth/auth-schema";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -68,12 +62,12 @@ const configs = {
}, },
}; };
function createAuthApp(config?: Partial<AppAuthSchema>) { function createAuthApp() {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
config: { config: {
auth: mergeObject(configs.auth, config ?? {}), auth: configs.auth,
}, },
}); });
@@ -138,16 +132,6 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
return { res, data }; return { res, data };
}, },
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
const res = (await app.server.request("/api/auth/password/register", {
method: "POST",
headers: headers(),
body: body(user),
})) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data };
},
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => { me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", { const res = (await app.server.request("/api/auth/me", {
method: "GET", method: "GET",
@@ -261,61 +245,4 @@ describe("integration auth", () => {
expect(await $fns.me()).toEqual({ user: null as any }); expect(await $fns.me()).toEqual({ user: null as any });
} }
}); });
it("should register users with default role", async () => {
const app = createAuthApp({ default_role_register: "guest" });
await app.build();
const $fns = fns(app);
// takes default role
expect(
await app
.createUser({
email: "test@bknd.io",
password: "12345678",
})
.then((r) => r.role),
).toBe("guest");
// throws error if role doesn't exist
expect(
app.createUser({
email: "test@bknd.io",
password: "12345678",
role: "doesnt exist",
}),
).rejects.toThrow();
// takes role if provided
expect(
await app
.createUser({
email: "test2@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.role),
).toBe("admin");
// registering with role is not allowed
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.res.ok),
).toBe(false);
// takes default role
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
})
.then((r) => r.data.user.role),
).toBe("guest");
});
}); });
-118
View File
@@ -8,7 +8,6 @@ import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import * as proto from "data/prototype";
beforeAll(() => { beforeAll(() => {
disableConsoleLog(); disableConsoleLog();
@@ -95,121 +94,4 @@ describe("MediaController", () => {
expect(res.status).toBe(413); expect(res.status).toBe(413);
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false); expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
}); });
test("audio files", async () => {
const app = await makeApp();
const file = Bun.file(`${assetsPath}/test.mp3`);
const name = makeName("mp3");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
expect(result.data.mime_type).toStartWith("audio/mpeg");
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("text files", async () => {
const app = await makeApp();
const file = Bun.file(`${assetsPath}/test.txt`);
const name = makeName("txt");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
expect(result.data.mime_type).toStartWith("text/plain");
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("entity upload with max_items and overwrite", async () => {
const app = createApp({
config: {
media: mergeObject(
{
enabled: true,
adapter: {
type: "local",
config: {
path: assetsTmpPath,
},
},
},
{},
),
data: {
entities: {
posts: proto
.entity("posts", {
title: proto.text(),
cover: proto.medium(),
})
.toJSON(),
},
},
},
});
await app.build();
// create a post first
const createRes = await app.server.request("/api/data/entity/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test Post" }),
});
expect(createRes.status).toBe(201);
const { data: post } = (await createRes.json()) as any;
const file = Bun.file(path);
const uploadedFiles: string[] = [];
// upload first file to entity (should succeed)
const res1 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
method: "POST",
body: file,
});
expect(res1.status).toBe(201);
const result1 = (await res1.json()) as any;
uploadedFiles.push(result1.name);
// upload second file without overwrite (should fail - max_items reached)
const res2 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
method: "POST",
body: file,
});
expect(res2.status).toBe(400);
const result2 = (await res2.json()) as any;
expect(result2.error).toContain("Max items");
// upload third file with overwrite=true (should succeed and delete old file)
const res3 = await app.server.request(
`/api/media/entity/posts/${post.id}/cover?overwrite=true`,
{
method: "POST",
body: file,
},
);
expect(res3.status).toBe(201);
const result3 = (await res3.json()) as any;
uploadedFiles.push(result3.name);
// verify old file was deleted from storage
const oldFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[0]);
expect(await oldFile.exists()).toBe(false);
// verify new file exists
const newFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[1]);
expect(await newFile.exists()).toBe(true);
// cleanup
await newFile.delete();
});
}); });
-37
View File
@@ -71,8 +71,6 @@ describe("media/mime-types", () => {
["application/zip", "zip"], ["application/zip", "zip"],
["text/tab-separated-values", "tsv"], ["text/tab-separated-values", "tsv"],
["application/zip", "zip"], ["application/zip", "zip"],
["application/pdf", "pdf"],
["audio/mpeg", "mp3"],
] as const; ] as const;
for (const [mime, ext] of tests) { for (const [mime, ext] of tests) {
@@ -90,9 +88,6 @@ describe("media/mime-types", () => {
["image.jpeg", "jpeg"], ["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"],
["file.pdf", "pdf"],
["file.mp3", "mp3"],
["robots.txt", "txt"],
] as const; ] as const;
for (const [filename, ext] of tests) { for (const [filename, ext] of tests) {
@@ -107,36 +102,4 @@ describe("media/mime-types", () => {
const [, ext] = getRandomizedFilename(file).split("."); const [, ext] = getRandomizedFilename(file).split(".");
expect(ext).toBe("jpg"); expect(ext).toBe("jpg");
}); });
test("getRandomizedFilename with body", async () => {
// should keep "pdf"
const [, ext] = getRandomizedFilename(
new File([""], "file.pdf", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("pdf");
{
// no ext, should use "pdf" only for known formats
const [, ext] = getRandomizedFilename(
new File([""], "file", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("pdf");
}
{
// wrong ext, should keep the wrong one
const [, ext] = getRandomizedFilename(
new File([""], "file.what", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("what");
}
{
// txt
const [, ext] = getRandomizedFilename(
new File([""], "file.txt", { type: "text/plain" }),
).split(".");
expect(ext).toBe("txt");
}
});
}); });
+12 -28
View File
@@ -10,6 +10,12 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("AppAuth", () => { describe("AppAuth", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
});
moduleTestSuite(AppAuth); moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext; let ctx: ModuleBuildContext;
@@ -33,9 +39,11 @@ describe("AppAuth", () => {
await auth.build(); await auth.build();
const oldConfig = auth.toJSON(true); const oldConfig = auth.toJSON(true);
//console.log(oldConfig);
await auth.schema().patch("enabled", true); await auth.schema().patch("enabled", true);
await auth.build(); await auth.build();
const newConfig = auth.toJSON(true); const newConfig = auth.toJSON(true);
//console.log(newConfig);
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
}); });
@@ -61,6 +69,7 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -71,6 +80,7 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -109,6 +119,7 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -119,6 +130,7 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -223,32 +235,4 @@ describe("AppAuth", () => {
} }
} }
}); });
test("default role for registration must be a valid role", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "123456",
},
allow_register: true,
roles: {
guest: {
is_default: true,
},
},
},
},
});
await app.build();
const auth = app.module.auth;
// doesn't allow invalid role
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
// allows valid role
await auth.schema().patch("default_role_register", "guest");
expect(auth.toJSON().default_role_register).toBe("guest");
});
}); });
+1 -5
View File
@@ -1,14 +1,10 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/media/AppMedia"; import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppMedia", () => { describe("AppMedia", () => {
test.skip("...", () => { test.skip("...", () => {
+1 -5
View File
@@ -1,11 +1,7 @@
import { it, expect, describe, beforeAll, afterAll } from "bun:test"; import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager"; import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations"; import { TABLE_NAME } from "modules/db/migrations";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("DbModuleManager", () => { describe("DbModuleManager", () => {
it("should extract secrets", async () => { it("should extract secrets", async () => {
+12 -1
View File
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype"; import { entity, text } from "data/prototype";
beforeAll(() => disableConsoleLog()); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
@@ -82,6 +82,7 @@ describe("ModuleManager", async () => {
}, },
}, },
} as any; } as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely; const db = dummyConnection.kysely;
@@ -96,6 +97,10 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
/* console.log({
json,
configs: mm2.configs(),
}); */
//expect(stripMark(json)).toEqual(stripMark(mm2.configs())); //expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
@@ -223,6 +228,8 @@ describe("ModuleManager", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -268,6 +275,7 @@ describe("ModuleManager", async () => {
} }
override async build() { override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value && this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value); throw new Error("value must be positive, given: " + this.config.value);
} }
@@ -288,6 +296,9 @@ describe("ModuleManager", async () => {
} }
} }
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => { test("it builds", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection); const mm = new TestModuleManager(dummyConnection);
@@ -1,22 +1,18 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { App, type InitialModuleConfigs, createApp } from "/"; import { type InitialModuleConfigs, createApp } from "../../../src";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json"; import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json"; import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json"; import v8_2 from "./samples/v8-2.json";
import v9 from "./samples/v9.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog()); beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
// app expects migratable config to be present in database // app expects migratable config to be present in database
async function createVersionedApp( async function createVersionedApp(config: InitialModuleConfigs | any) {
config: InitialModuleConfigs | any,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version"); if (!("version" in config)) throw new Error("config must have a version");
@@ -42,10 +38,6 @@ async function createVersionedApp(
}) })
.execute(); .execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
}); });
@@ -53,19 +45,6 @@ async function createVersionedApp(
return app; return app;
} }
async function getRawConfig(
app: App,
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
) {
const db = app.em.connection.kysely;
return await db
.selectFrom("__bknd")
.selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
describe("Migrations", () => { describe("Migrations", () => {
/** /**
* updated auth strategies to have "enabled" prop * updated auth strategies to have "enabled" prop
@@ -103,30 +82,4 @@ describe("Migrations", () => {
// @ts-expect-error // @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined(); expect(app.toJSON(true).server.admin).toBeUndefined();
}); });
test("migration from 9 to 10", async () => {
expect(v9.version).toBe(9);
const app = await createVersionedApp(v9);
expect(app.version()).toBeGreaterThan(9);
// @ts-expect-error
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
"^^s3.secret_access_key^^",
);
const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"],
})) as any;
expect(config.json.auth.jwt.secret).toBe("");
expect(config.json.media.adapter.config.access_key).toBe("");
expect(config.json.media.adapter.config.secret_access_key).toBe("");
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
"^^s3.secret_access_key^^",
);
});
}); });
@@ -1,612 +0,0 @@
{
"version": 9,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": { "enabled": false, "path": "/api/system/mcp" }
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": { "type": "text", "config": { "required": true } },
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": { "type": "text", "config": { "required": false } },
"size": { "type": "number", "config": { "required": false } },
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": { "type": "text", "config": { "required": false } },
"modified_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"reference": { "type": "text", "config": { "required": false } },
"entity_id": { "type": "number", "config": { "required": false } },
"metadata": { "type": "json", "config": { "required": false } }
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"email": { "type": "text", "config": { "required": true } },
"strategy": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["password"] },
"required": true,
"hidden": ["update", "form"],
"fillable": ["create"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["admin", "guest"] },
"required": false
}
},
"age": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
},
"required": false
}
},
"height": { "type": "number", "config": { "required": false } },
"gender": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["male", "female"] },
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"avatars": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"identifier": { "type": "text", "config": { "required": false } },
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"started_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"input": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"tryons": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"avatars_id": {
"type": "relation",
"config": {
"label": "Avatars",
"required": false,
"reference": "avatars",
"target": "avatars",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "tryons",
"max_items": 1
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Products",
"required": false,
"reference": "products",
"target": "products",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"enabled": { "type": "boolean", "config": { "required": false } },
"title": { "type": "text", "config": { "required": false } },
"url": { "type": "text", "config": { "required": false } },
"image": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products",
"max_items": 1
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"garment_type": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["auto", "tops", "bottoms", "one-pieces"]
},
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sites": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"origin": {
"type": "text",
"config": {
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
"required": true
}
},
"name": { "type": "text", "config": { "required": false } },
"active": { "type": "boolean", "config": { "required": false } },
"logo": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "sites",
"max_items": 1
}
},
"instructions": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": { "rows": "2" }
},
"required": false,
"hidden": ["table"]
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sessions": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": { "format": "uuid", "fillable": false, "required": false }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": true }
},
"claimed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"url": { "type": "text", "config": { "required": false } },
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
}
},
"relations": {
"poly_avatars_media_input": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "input" }
},
"poly_avatars_media_output": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "output" }
},
"n1_avatars_users": {
"type": "n:1",
"source": "avatars",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_avatars": {
"type": "n:1",
"source": "tryons",
"target": "avatars",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_users": {
"type": "n:1",
"source": "tryons",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_tryons_media_output": {
"type": "poly",
"source": "tryons",
"target": "media",
"config": { "mappedBy": "output", "targetCardinality": 1 }
},
"poly_products_media_image": {
"type": "poly",
"source": "products",
"target": "media",
"config": { "mappedBy": "image", "targetCardinality": 1 }
},
"n1_tryons_products": {
"type": "n:1",
"source": "tryons",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_sites_media_logo": {
"type": "poly",
"source": "sites",
"target": "media",
"config": { "mappedBy": "logo", "targetCardinality": 1 }
},
"n1_sessions_sites": {
"type": "n:1",
"source": "sessions",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_sessions_users": {
"type": "n:1",
"source": "sessions",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_products_sites": {
"type": "n:1",
"source": "products",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_sites_origin_active": {
"entity": "sites",
"fields": ["origin", "active"],
"unique": false
},
"idx_sites_active": {
"entity": "sites",
"fields": ["active"],
"unique": false
},
"idx_products_url": {
"entity": "products",
"fields": ["url"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "^^jwt.secret^^",
"alg": "HS256",
"expires": 999999999,
"issuer": "issuer",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "none",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/admin",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": { "hashing": "sha256" }
}
},
"guard": { "enabled": false },
"roles": {
"admin": { "implicit_allow": true },
"guest": { "is_default": true }
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": { "body_max_size": 0 },
"adapter": {
"type": "s3",
"config": {
"access_key": "^^s3.access_key^^",
"secret_access_key": "^^s3.secret_access_key^^",
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
}
}
},
"flows": { "basepath": "/api/flows", "flows": {} }
}
+21 -1
View File
@@ -1,9 +1,29 @@
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors"; import c from "picocolors";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild";
const deps = Object.keys(pkg.dependencies); const deps = Object.keys(pkg.dependencies);
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps]; const external = ["jsonv-ts/*", "wrangler", ...deps];
if (process.env.DEBUG) {
const result = await esbuild.build({
entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli",
platform: "node",
minify: true,
format: "esm",
metafile: true,
bundle: true,
external,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
process.exit(0);
}
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"], entrypoints: ["./src/cli/index.ts"],
+6 -85
View File
@@ -2,8 +2,6 @@ import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors"; import c from "picocolors";
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -27,18 +25,7 @@ const define = {
if (clean) { if (clean) {
console.info("Cleaning dist (w/o static)"); console.info("Cleaning dist (w/o static)");
// Cross-platform clean: remove all files/folders in dist except static await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
const distPath = join(import.meta.dir, "dist");
try {
const entries = readdirSync(distPath);
for (const entry of entries) {
if (entry === "static") continue;
const entryPath = join(distPath, entry);
rmSync(entryPath, { recursive: true, force: true });
}
} catch (e) {
// dist may not exist yet, ignore
}
} }
let types_running = false; let types_running = false;
@@ -96,21 +83,14 @@ async function buildApi() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves watch,
watch: false,
define, define,
entry: [ entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"],
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist", outDir: "dist",
external: [...external], external: [...external],
metafile: true, metafile: true,
target: "esnext", target: "esnext",
platform: "browser", platform: "browser",
removeNodeProtocol: false,
format: ["esm"], format: ["esm"],
splitting: false, splitting: false,
loader: { loader: {
@@ -135,7 +115,7 @@ async function buildUi() {
const base = { const base = {
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
define, define,
external: [ external: [
...external, ...external,
@@ -194,15 +174,12 @@ async function buildUiElements() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
define, define,
entry: ["src/ui/elements/index.ts"], entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
"ui/client", "ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
@@ -239,14 +216,13 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
return { return {
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
entry: [`src/adapter/${adapter}/index.ts`], entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"], format: ["esm"],
platform: "neutral", platform: "neutral",
outDir: `dist/adapter/${adapter}`, outDir: `dist/adapter/${adapter}`,
metafile: true, metafile: true,
splitting: false, splitting: false,
removeNodeProtocol: false,
onSuccess: async () => { onSuccess: async () => {
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
@@ -282,11 +258,6 @@ async function buildAdapters() {
// specific adatpers // specific adatpers
tsup.build(baseConfig("react-router")), tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build( tsup.build(
baseConfig("bun", { baseConfig("bun", {
external: [/^bun\:.*/], external: [/^bun\:.*/],
@@ -301,7 +272,6 @@ async function buildAdapters() {
), ),
tsup.build( tsup.build(
baseConfig("cloudflare/proxy", { baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"], entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare", outDir: "dist/adapter/cloudflare",
metafile: false, metafile: false,
@@ -319,11 +289,6 @@ async function buildAdapters() {
platform: "node", platform: "node",
}), }),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
tsup.build({ tsup.build({
...baseConfig("node"), ...baseConfig("node"),
platform: "node", platform: "node",
@@ -354,48 +319,4 @@ async function buildAdapters() {
]); ]);
} }
async function buildAll() {
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]); await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
}
// initial build
await buildAll();
// custom watcher since tsup's watch is broken in 8.3.5+
if (watch) {
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false;
const rebuild = async () => {
if (isBuilding) return;
isBuilding = true;
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
try {
await buildAll();
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
} catch (e) {
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
}
isBuilding = false;
};
const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100);
};
// watch src directory recursively
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
if (!filename) return;
// ignore non-source files
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
return;
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
debouncedRebuild();
});
// keep process alive
await new Promise(() => {});
}
+6
View File
@@ -0,0 +1,6 @@
[install]
#registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true
console.depth = 10
+3 -6
View File
@@ -3,14 +3,11 @@ import { createApp } from "bknd/adapter/bun";
async function generate() { async function generate() {
console.info("Generating MCP documentation..."); console.info("Generating MCP documentation...");
const app = await createApp({ const app = await createApp({
connection: {
url: ":memory:",
},
config: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
path: "/mcp2", path: "/mcp",
}, },
}, },
auth: { auth: {
@@ -28,9 +25,9 @@ async function generate() {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
const { tools, resources } = app.mcp!.toJSON(); const res = await app.server.request("/mcp?explain=1");
const { tools, resources } = await res.json();
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2)); await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
console.info("MCP documentation generated."); console.info("MCP documentation generated.");
+52 -80
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.20.0", "version": "0.18.0-rc.4",
"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": {
@@ -13,7 +13,7 @@
"bugs": { "bugs": {
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"packageManager": "bun@1.3.3", "packageManager": "bun@1.2.19",
"engines": { "engines": {
"node": ">=22.13" "node": ">=22.13"
}, },
@@ -40,112 +40,102 @@
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest:coverage": "vitest run --coverage", "test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts", "test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui", "test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug", "test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report", "test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
"docs:build-assets": "bun internal/docs.build-assets.ts" "docs:build-assets": "bun internal/docs.build-assets.ts"
}, },
"license": "Apache-2.0", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.2", "@hono/swagger-ui": "^0.5.1",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.25.2", "@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.9.2", "@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.2",
"dayjs": "^1.11.19", "dayjs": "^1.11.13",
"fast-xml-parser": "^5.3.1", "fast-xml-parser": "^5.0.8",
"hono": "4.10.4", "hono": "4.8.3",
"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",
"jsonv-ts": "^0.10.1", "jsonv-ts": "0.8.2",
"kysely": "0.28.8", "kysely": "0.27.6",
"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.6", "picocolors": "^1.1.1",
"use-sync-external-store": "^1.6.0", "swr": "^2.3.3"
"zustand": "^4"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.922.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.6", "@bluwy/giget-core": "^0.1.2",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@cloudflare/vitest-pool-workers": "^0.10.4", "@cloudflare/vitest-pool-workers": "^0.9.3",
"@cloudflare/workers-types": "^4.20251014.0", "@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/vite-dev-server": "^0.23.0", "@hono/vite-dev-server": "^0.21.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.9",
"@mantine/modals": "^7.17.1", "@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1", "@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.56.1", "@playwright/test": "^1.51.1",
"@rjsf/core": "5.22.2", "@rjsf/core": "5.22.2",
"@rjsf/utils": "5.22.0",
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@tabler/icons-react": "3.35.0", "@tabler/icons-react": "3.18.0",
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.0.12",
"@tanstack/react-store": "^0.8.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/node": "^24.10.0", "@types/node": "^22.13.10",
"@types/pg": "^8.15.6",
"@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": "^5.1.0", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "3.0.9", "@vitest/coverage-v8": "^3.0.9",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"commander": "^14.0.2", "dotenv": "^16.4.7",
"dotenv": "^17.2.3",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.1.0", "jsdom": "^26.0.0",
"kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1", "kysely-generic-sqlite": "^1.2.1",
"kysely-postgres-js": "^2.0.0",
"libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0", "libsql-stateless-easy": "^1.8.0",
"miniflare": "^4.20251011.2", "open": "^10.1.0",
"open": "^10.2.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"pg": "^8.16.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"postgres": "^3.4.7", "posthog-js-lite": "^3.4.2",
"posthog-js-lite": "^3.6.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.54.2",
"react-icons": "5.5.0", "react-icons": "5.2.1",
"react-json-view-lite": "^2.5.0", "react-json-view-lite": "^2.4.1",
"sql-formatter": "^15.6.10", "sql-formatter": "^15.4.11",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.11",
"tsup": "^8.5.0", "tsup": "^8.4.0",
"tsx": "^4.20.6", "tsx": "^4.19.3",
"uuid": "^13.0.0", "uuid": "^11.1.0",
"vite": "^7.1.12", "vite": "^6.3.5",
"vite-plugin-circular-dependency": "^0.5.0", "vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "3.0.9", "vitest": "^3.0.9",
"wouter": "^3.7.1", "wouter": "^3.6.0",
"wrangler": "^4.45.4" "wrangler": "^4.37.1",
"miniflare": "^4.20250913.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.19.6" "@hono/node-server": "^1.14.3"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=19", "react": ">=19",
@@ -190,11 +180,6 @@
"import": "./dist/plugins/index.js", "import": "./dist/plugins/index.js",
"require": "./dist/plugins/index.js" "require": "./dist/plugins/index.js"
}, },
"./modes": {
"types": "./dist/types/modes/index.d.ts",
"import": "./dist/modes/index.js",
"require": "./dist/modes/index.js"
},
"./adapter/sqlite": { "./adapter/sqlite": {
"types": "./dist/types/adapter/sqlite/edge.d.ts", "types": "./dist/types/adapter/sqlite/edge.d.ts",
"import": { "import": {
@@ -253,21 +238,11 @@
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.js" "require": "./dist/adapter/astro/index.js"
}, },
"./adapter/sveltekit": {
"types": "./dist/types/adapter/sveltekit/index.d.ts",
"import": "./dist/adapter/sveltekit/index.js",
"require": "./dist/adapter/sveltekit/index.js"
},
"./adapter/aws": { "./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts", "types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js", "import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js" "require": "./dist/adapter/aws/index.js"
}, },
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./dist/main.css": "./dist/ui/main.css", "./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css", "./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json", "./dist/manifest.json": "./dist/static/.vite/manifest.json",
@@ -285,7 +260,6 @@
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"], "adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"], "adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"], "adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"] "adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
} }
}, },
@@ -315,8 +289,6 @@
"remix", "remix",
"react-router", "react-router",
"astro", "astro",
"sveltekit",
"svelte",
"bun", "bun",
"node" "node"
] ]
+32 -46
View File
@@ -40,7 +40,6 @@ export type ApiOptions = {
data?: SubApiOptions<DataApiOptions>; data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>; auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>; media?: SubApiOptions<MediaApiOptions>;
credentials?: RequestCredentials;
} & ( } & (
| { | {
token?: string; token?: string;
@@ -61,14 +60,14 @@ export class Api {
private token?: string; private token?: string;
private user?: TApiUser; private user?: TApiUser;
private verified = false; private verified = false;
public token_transport: "header" | "cookie" | "none" = "header"; private token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi; public system!: SystemApi;
public data!: DataApi; public data!: DataApi;
public auth!: AuthApi; public auth!: AuthApi;
public media!: MediaApi; public media!: MediaApi;
constructor(public options: ApiOptions = {}) { constructor(private options: ApiOptions = {}) {
// only mark verified if forced // only mark verified if forced
this.verified = options.verified === true; this.verified = options.verified === true;
@@ -130,45 +129,29 @@ export class Api {
} else if (this.storage) { } else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => { this.storage.getItem(this.tokenKey).then((token) => {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(token ? String(token) : undefined, { this.updateToken(token ? String(token) : undefined);
verified: true,
trigger: false,
});
}); });
} }
} }
/**
* Make storage async to allow async storages even if sync given
* @private
*/
private get storage() { private get storage() {
const storage = this.options.storage; if (!this.options.storage) return null;
return new Proxy(
{},
{
get(_, prop) {
return (...args: any[]) => {
const response = storage ? storage[prop](...args) : undefined;
if (response instanceof Promise) {
return response;
}
return { return {
// biome-ignore lint/suspicious/noThenProperty: it's a promise :) getItem: async (key: string) => {
then: (fn) => fn(response), return await this.options.storage!.getItem(key);
};
};
}, },
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
}, },
) as any; removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
} }
updateToken( updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
token?: string,
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
) {
this.token = token; this.token = token;
this.verified = opts?.verified === true; this.verified = false;
if (token) { if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any; this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
@@ -176,22 +159,21 @@ export class Api {
this.user = undefined; this.user = undefined;
} }
const emit = () => {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
}
};
if (this.storage) { if (this.storage) {
const key = this.tokenKey; const key = this.tokenKey;
if (token) { if (token) {
this.storage.setItem(key, token).then(emit); this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} else { } else {
this.storage.removeItem(key).then(emit); this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} }
} else { } else {
if (opts?.trigger !== false) { if (opts?.trigger !== false) {
emit(); this.options.onAuthStateChange?.(this.getAuthState());
} }
} }
@@ -200,7 +182,6 @@ export class Api {
private markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
this.verified = verfied; this.verified = verfied;
this.options.onAuthStateChange?.(this.getAuthState());
return this; return this;
} }
@@ -227,6 +208,11 @@ export class Api {
} }
async verifyAuth() { async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try { try {
const { ok, data } = await this.auth.me(); const { ok, data } = await this.auth.me();
const user = data?.user; const user = data?.user;
@@ -235,10 +221,10 @@ export class Api {
} }
this.user = user; this.user = user;
} catch (e) {
this.updateToken(undefined);
} finally {
this.markAuthVerified(true); this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
} }
} }
@@ -253,7 +239,6 @@ export class Api {
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport, token_transport: this.token_transport,
verbose: this.options.verbose, verbose: this.options.verbose,
credentials: this.options.credentials,
}); });
} }
@@ -272,9 +257,10 @@ export class Api {
this.auth = new AuthApi( this.auth = new AuthApi(
{ {
...baseParams, ...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth, ...this.options.auth,
onTokenUpdate: (token, verified) => { onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true, verified, trigger: true }); this.updateToken(token, { rebuild: true });
this.options.auth?.onTokenUpdate?.(token); this.options.auth?.onTokenUpdate?.(token);
}, },
}, },
+5 -12
View File
@@ -5,6 +5,7 @@ import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
type InitialModuleConfigs,
type ModuleConfigs, type ModuleConfigs,
type Modules, type Modules,
ModuleManager, ModuleManager,
@@ -243,9 +244,6 @@ export class App<
} }
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
if (!this.isBuilt()) {
console.error("App is not built yet, run build() first");
}
return this.server.fetch as any; return this.server.fetch as any;
} }
@@ -294,7 +292,6 @@ export class App<
return this.module.auth.createUser(p); return this.module.auth.createUser(p);
} }
// @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners
getApi(options?: LocalApiOptions) { getApi(options?: LocalApiOptions) {
const fetcher = this.server.request as typeof fetch; const fetcher = this.server.request as typeof fetch;
if (options && options instanceof Request) { if (options && options instanceof Request) {
@@ -305,14 +302,13 @@ export class App<
} }
getMcpClient() { getMcpClient() {
const config = this.modules.get("server").config.mcp; if (!this.mcp) {
if (!config.enabled) {
throw new Error("MCP is not enabled"); throw new Error("MCP is not enabled");
} }
const mcpPath = this.modules.get("server").config.mcp.path;
const url = new URL(config.path, "http://localhost").toString();
return new McpClient({ return new McpClient({
url, url: "http://localhost" + mcpPath,
fetch: this.server.request, fetch: this.server.request,
}); });
} }
@@ -380,15 +376,12 @@ export class App<
if (results.length > 0) { if (results.length > 0) {
for (const { name, result } of results) { for (const { name, result } of results) {
if (result) { if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result); ctx.helper.ensureSchema(result);
if (ctx.flags.sync_required) {
$console.log(`[Plugin:${name}] schema, sync required`);
} }
} }
} }
} }
await this.options?.manager?.onModulesBuilt?.(ctx);
}
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {
+3 -6
View File
@@ -8,15 +8,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>( export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {}, config: AstroBkndConfig<Env> = {},
args: Env = import.meta.env as Env, args: Env = {} as Env,
) { ) {
return await createFrameworkApp(config, args); return await createFrameworkApp(config, args ?? import.meta.env);
} }
export function serve<Env = AstroEnv>( export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
config: AstroBkndConfig<Env> = {},
args: Env = import.meta.env as Env,
) {
return async (fnArgs: TAstro) => { return async (fnArgs: TAstro) => {
return (await getApp(config, args)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
}; };
-153
View File
@@ -1,153 +0,0 @@
import {
createContext,
lazy,
Suspense,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { checksum } from "bknd/utils";
import { App, registries, sqlocal, type BkndConfig } from "bknd";
import { Route, Router, Switch } from "wouter";
import { ClientProvider } from "bknd/client";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig, DatabasePath } from "sqlocal";
import { OpfsStorageAdapter } from "bknd/adapter/browser";
import type { BkndAdminConfig } from "bknd/ui";
const Admin = lazy(() =>
Promise.all([
import("bknd/ui"),
// @ts-ignore
import("bknd/dist/styles.css"),
]).then(([mod]) => ({
default: mod.Admin,
})),
);
function safeViewTransition(fn: () => void) {
if (document.startViewTransition) {
document.startViewTransition(fn);
} else {
fn();
}
}
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
BkndConfig<Args>,
"connection" | "app"
> & {
adminConfig?: BkndAdminConfig;
connection?: ClientConfig | DatabasePath;
};
export type BkndBrowserAppProps = {
children: ReactNode;
header?: ReactNode;
loading?: ReactNode;
notFound?: ReactNode;
} & BrowserBkndConfig;
const BkndBrowserAppContext = createContext<{
app: App;
hash: string;
}>(undefined!);
export function BkndBrowserApp({
children,
adminConfig,
header,
loading,
notFound,
...config
}: BkndBrowserAppProps) {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
async function onBuilt(app: App) {
safeViewTransition(async () => {
setApp(app);
setHash(await checksum(app.toJSON()));
});
}
useEffect(() => {
setup({ ...config, adminConfig })
.then((app) => onBuilt(app as any))
.catch(console.error);
}, []);
if (!app) {
return (
loading ?? (
<Center>
<span style={{ opacity: 0.2 }}>Loading...</span>
</Center>
)
);
}
return (
<BkndBrowserAppContext.Provider value={{ app, hash }}>
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
{header}
<Router key={hash}>
<Switch>
{children}
<Route path={adminRoutePath}>
<Suspense>
<Admin config={adminConfig} />
</Suspense>
</Route>
<Route path="*">
{notFound ?? (
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
)}
</Route>
</Switch>
</Router>
</ClientProvider>
</BkndBrowserAppContext.Provider>
);
}
export function useApp() {
return useContext(BkndBrowserAppContext);
}
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
style={{
width: "100%",
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
...(props.style ?? {}),
}}
/>
);
let initialized = false;
async function setup(config: BrowserBkndConfig = {}) {
if (initialized) return;
initialized = true;
registries.media.register("opfs", OpfsStorageAdapter);
const app = App.create({
...config,
// @ts-ignore
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
});
await config.beforeBuild?.(app);
await app.build({ sync: true });
await config.onBuilt?.(app);
return app;
}
@@ -1,34 +0,0 @@
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
// @ts-ignore
import { assetsPath } from "../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
describe("OpfsStorageAdapter", async () => {
let mockRoot: MockFileSystemDirectoryHandle;
let testSuiteAdapter: OpfsStorageAdapter;
const _mock = spyOn(global, "navigator");
beforeAll(() => {
// mock navigator.storage.getDirectory()
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
const mockNavigator = {
storage: {
getDirectory: vi.fn().mockResolvedValue(mockRoot),
},
};
// @ts-ignore
_mock.mockReturnValue(mockNavigator);
testSuiteAdapter = new OpfsStorageAdapter();
});
afterAll(() => {
_mock.mockRestore();
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
});
@@ -1,265 +0,0 @@
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
import { StorageAdapter, guessMimeType } from "bknd";
import { parse, s, isFile, isBlob } from "bknd/utils";
export const opfsAdapterConfig = s.object(
{
root: s.string({ default: "" }).optional(),
},
{
title: "OPFS",
description: "Origin Private File System storage",
additionalProperties: false,
},
);
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
/**
* Storage adapter for OPFS (Origin Private File System)
* Provides browser-based file storage using the File System Access API
*/
export class OpfsStorageAdapter extends StorageAdapter {
private config: OpfsAdapterConfig;
private rootPromise: Promise<FileSystemDirectoryHandle>;
constructor(config: Partial<OpfsAdapterConfig> = {}) {
super();
this.config = parse(opfsAdapterConfig, config);
this.rootPromise = this.initializeRoot();
}
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
if (!this.config.root) {
return opfsRoot;
}
// navigate to or create nested directory structure
const parts = this.config.root.split("/").filter(Boolean);
let current = opfsRoot;
for (const part of parts) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
getSchema() {
return opfsAdapterConfig;
}
getName(): string {
return "opfs";
}
async listObjects(prefix?: string): Promise<FileListObject[]> {
const root = await this.rootPromise;
const files: FileListObject[] = [];
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
if (!prefix || name.startsWith(prefix)) {
const file = await (handle as FileSystemFileHandle).getFile();
files.push({
key: name,
last_modified: new Date(file.lastModified),
size: file.size,
});
}
}
}
return files;
}
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
// wrap the hex string in quotes for ETag format
return `"${hashHex}"`;
}
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) {
throw new Error("Body is empty");
}
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
try {
let contentBuffer: ArrayBuffer;
if (isFile(body)) {
contentBuffer = await body.arrayBuffer();
await writable.write(contentBuffer);
} else if (body instanceof ReadableStream) {
const chunks: Uint8Array[] = [];
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
await writable.write(value);
}
} finally {
reader.releaseLock();
}
// compute total size and combine chunks for etag
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
contentBuffer = combined.buffer;
} else if (isBlob(body)) {
contentBuffer = await (body as Blob).arrayBuffer();
await writable.write(contentBuffer);
} else {
// body is ArrayBuffer or ArrayBufferView
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
contentBuffer = view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength,
) as ArrayBuffer;
} else {
contentBuffer = body as ArrayBuffer;
}
await writable.write(body);
}
await writable.close();
return await this.computeEtagFromArrayBuffer(contentBuffer);
} catch (error) {
await writable.abort();
throw error;
}
}
async deleteObject(key: string): Promise<void> {
try {
const root = await this.rootPromise;
await root.removeEntry(key);
} catch {
// file doesn't exist, which is fine
}
}
async objectExists(key: string): Promise<boolean> {
try {
const root = await this.rootPromise;
await root.getFileHandle(key);
return true;
} catch {
return false;
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
const fileSize = file.size;
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const arrayBuffer = await file.arrayBuffer();
const chunk = arrayBuffer.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.byteLength.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// normal request - return entire file
const content = await file.arrayBuffer();
responseHeaders.set("Content-Length", content.byteLength.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch {
// handle file reading errors
return new Response("", { status: 404 });
}
}
getObjectUrl(_key: string): string {
throw new Error("Method not implemented.");
}
async getObjectMeta(key: string): Promise<FileMeta> {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
return {
type: guessMimeType(key) || "application/octet-stream",
size: file.size,
};
}
toJSON(_secrets?: boolean) {
return {
type: this.getName(),
config: this.config,
};
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./OpfsStorageAdapter";
export * from "./BkndBrowserApp";
-136
View File
@@ -1,136 +0,0 @@
// mock OPFS API for testing
class MockFileSystemFileHandle {
kind: "file" = "file";
name: string;
private content: ArrayBuffer;
private lastModified: number;
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
this.name = name;
this.content = content;
this.lastModified = Date.now();
}
async getFile(): Promise<File> {
return new File([this.content], this.name, {
lastModified: this.lastModified,
type: this.guessMimeType(),
});
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: any) {
if (data instanceof ArrayBuffer) {
handle.content = data;
} else if (ArrayBuffer.isView(data)) {
handle.content = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
} else if (data instanceof Blob) {
handle.content = await data.arrayBuffer();
}
handle.lastModified = Date.now();
},
async close() {},
async abort() {},
async seek(_position: number) {},
async truncate(_size: number) {},
} as FileSystemWritableFileStream;
}
private guessMimeType(): string {
const ext = this.name.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
txt: "text/plain",
json: "application/json",
pdf: "application/pdf",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
}
export class MockFileSystemDirectoryHandle {
kind: "directory" = "directory";
name: string;
private files: Map<string, MockFileSystemFileHandle> = new Map();
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
constructor(name: string = "root") {
this.name = name;
}
async getFileHandle(
name: string,
options?: FileSystemGetFileOptions,
): Promise<FileSystemFileHandle> {
if (this.files.has(name)) {
return this.files.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemFileHandle(name);
this.files.set(name, handle);
return handle as any;
}
throw new Error(`File not found: ${name}`);
}
async getDirectoryHandle(
name: string,
options?: FileSystemGetDirectoryOptions,
): Promise<FileSystemDirectoryHandle> {
if (this.directories.has(name)) {
return this.directories.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemDirectoryHandle(name);
this.directories.set(name, handle);
return handle as any;
}
throw new Error(`Directory not found: ${name}`);
}
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
this.files.delete(name);
this.directories.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, handle] of this.files) {
yield [name, handle as any];
}
for (const [name, handle] of this.directories) {
yield [name, handle as any];
}
}
async *keys(): AsyncIterableIterator<string> {
for (const name of this.files.keys()) {
yield name;
}
for (const name of this.directories.keys()) {
yield name;
}
}
async *values(): AsyncIterableIterator<FileSystemHandle> {
for (const handle of this.files.values()) {
yield handle as any;
}
for (const handle of this.directories.values()) {
yield handle as any;
}
}
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
return this.entries();
}
}
+10 -12
View File
@@ -1,16 +1,18 @@
/// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "."; import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd"; import { config, type App } from "bknd";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
type BunEnv = Bun.Env; type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
Omit<Bun.Serve.Options<undefined, string>, "fetch">;
export async function createApp<Env = BunEnv>( export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {}, { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = Bun.env as Env, args: Env = {} as Env,
) { ) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -24,18 +26,18 @@ export async function createApp<Env = BunEnv>(
}), }),
...config, ...config,
}, },
args, args ?? (process.env as Env),
); );
} }
export function createHandler<Env = BunEnv>( export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {}, config: BunBkndConfig<Env> = {},
args: Env = Bun.env as Env, args: Env = {} as Env,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args); app = await createApp(config, args ?? (process.env as Env));
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -43,7 +45,6 @@ export function createHandler<Env = BunEnv>(
export function serve<Env = BunEnv>( export function serve<Env = BunEnv>(
{ {
app,
distPath, distPath,
connection, connection,
config: _config, config: _config,
@@ -53,17 +54,15 @@ export function serve<Env = BunEnv>(
buildConfig, buildConfig,
adminOptions, adminOptions,
serveStatic, serveStatic,
beforeBuild,
...serveOptions ...serveOptions
}: BunBkndConfig<Env> = {}, }: BunBkndConfig<Env> = {},
args: Env = Bun.env as Env, args: Env = {} as Env,
) { ) {
Bun.serve({ Bun.serve({
...(serveOptions as any), ...serveOptions,
port, port,
fetch: createHandler( fetch: createHandler(
{ {
app,
connection, connection,
config: _config, config: _config,
options, options,
@@ -72,7 +71,6 @@ export function serve<Env = BunEnv>(
adminOptions, adminOptions,
distPath, distPath,
serveStatic, serveStatic,
beforeBuild,
}, },
args, args,
), ),
-8
View File
@@ -1,11 +1,3 @@
export * from "./bun.adapter"; export * from "./bun.adapter";
export * from "../node/storage"; export * from "../node/storage";
export * from "./connection/BunSqliteConnection"; export * from "./connection/BunSqliteConnection";
export async function writer(path: string, content: string) {
await Bun.write(path, content);
}
export async function reader(path: string) {
return await Bun.file(path).text();
}
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog); /* beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog); */
describe("cf adapter", () => { describe("cf adapter", () => {
const DB_URL = ":memory:"; const DB_URL = ":memory:";
@@ -3,7 +3,7 @@
import type { RuntimeBkndConfig } 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 type { App, MaybePromise } from "bknd"; import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import { createRuntimeApp } from "bknd/adapter"; import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
@@ -37,30 +37,26 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {}, config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {}, ctx: Partial<CloudflareContext<Env>> = {},
) { ) {
const appConfig = await makeConfig(config, ctx); const appConfig = await makeConfig(
return await createRuntimeApp<Env>(
{ {
...appConfig, ...config,
onBuilt: async (app) => { onBuilt: async (app) => {
if (ctx.ctx) { if (ctx.ctx) {
registerAsyncsExecutionContext(app, ctx?.ctx); registerAsyncsExecutionContext(app, ctx?.ctx);
} }
await appConfig.onBuilt?.(app); await config.onBuilt?.(app);
}, },
}, },
ctx?.env, ctx,
); );
return await createRuntimeApp<Env>(appConfig, ctx?.env);
} }
// compatiblity // compatiblity
export const getFresh = createApp; export const getFresh = createApp;
let app: App | undefined;
export function serve<Env extends CloudflareEnv = CloudflareEnv>( export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {}, config: CloudflareBkndConfig<Env> = {},
serveOptions?: (args: Env) => {
warm?: boolean;
},
) { ) {
return { return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) { async fetch(request: Request, env: Env, ctx: ExecutionContext) {
@@ -96,11 +92,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
} }
} }
const { warm } = serveOptions?.(env) ?? {};
if (!app || warm !== true) {
const context = { request, env, ctx } as CloudflareContext<Env>; const context = { request, env, ctx } as CloudflareContext<Env>;
app = await createApp(config, context); const app = await createApp(config, context);
}
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
+1 -1
View File
@@ -16,7 +16,7 @@ export {
type GetBindingType, type GetBindingType,
type BindingMap, type BindingMap,
} from "./bindings"; } from "./bindings";
export { constants, makeConfig, type CloudflareContext } from "./config"; export { constants, type CloudflareContext } from "./config";
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter"; export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
export { registries } from "bknd"; export { registries } from "bknd";
export { devFsVitePlugin, devFsWrite } from "./vite"; export { devFsVitePlugin, devFsWrite } from "./vite";
+22 -38
View File
@@ -18,29 +18,6 @@ export type WithPlatformProxyOptions = {
proxyOptions?: GetPlatformProxyOptions; proxyOptions?: GetPlatformProxyOptions;
}; };
async function getPlatformProxy(opts?: GetPlatformProxyOptions) {
try {
const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then(
(pkg) => pkg.default,
);
$console.log("Using wrangler version", version);
const { getPlatformProxy } = await import("wrangler");
return getPlatformProxy(opts);
} catch (e) {
$console.error("Failed to import wrangler", String(e));
const resolved = import.meta.resolve("wrangler");
$console.log("Wrangler resolved to", resolved);
const file = resolved?.split("/").pop();
if (file?.endsWith(".json")) {
$console.error(
"You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml",
);
}
}
process.exit(1);
}
export function withPlatformProxy<Env extends CloudflareEnv>( export function withPlatformProxy<Env extends CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {}, config: CloudflareBkndConfig<Env> = {},
opts?: WithPlatformProxyOptions, opts?: WithPlatformProxyOptions,
@@ -54,6 +31,7 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
async function getEnv(env?: Env): Promise<Env> { async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) { if (use_proxy) {
if (!proxy) { if (!proxy) {
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
proxy = await getPlatformProxy(opts?.proxyOptions); proxy = await getPlatformProxy(opts?.proxyOptions);
process.on("exit", () => { process.on("exit", () => {
proxy?.dispose(); proxy?.dispose();
@@ -65,20 +43,7 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
} }
return { return {
// @ts-ignore ...config,
app: async (_env) => {
const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
const appConfig = typeof config.app === "function" ? await config.app(env) : config;
const connection =
use_proxy && binding
? d1Sqlite({
binding: binding.value as any,
})
: appConfig.connection;
return {
...appConfig,
beforeBuild: async (app, registries) => { beforeBuild: async (app, registries) => {
if (!use_proxy) return; if (!use_proxy) return;
const env = await getEnv(); const env = await getEnv();
@@ -88,8 +53,27 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
bindings: async (env) => { bindings: async (env) => {
return (await config?.bindings?.(await getEnv(env))) || {}; return (await config?.bindings?.(await getEnv(env))) || {};
}, },
connection, // @ts-ignore
app: async (_env) => {
const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
if (config?.app === undefined && use_proxy && binding) {
return {
connection: d1Sqlite({
binding: binding.value,
}),
}; };
} else if (typeof config?.app === "function") {
const appConfig = await config?.app(env);
if (binding) {
appConfig.connection = d1Sqlite({
binding: binding.value,
}) as any;
}
return appConfig;
}
return config?.app || {};
}, },
} satisfies CloudflareBkndConfig<Env>; } satisfies CloudflareBkndConfig<Env>;
} }
@@ -49,8 +49,6 @@ export function registerMedia(
* @todo: add tests (bun tests won't work, need node native tests) * @todo: add tests (bun tests won't work, need node native tests)
*/ */
export class StorageR2Adapter extends StorageAdapter { export class StorageR2Adapter extends StorageAdapter {
public keyPrefix: string = "";
constructor(private readonly bucket: R2Bucket) { constructor(private readonly bucket: R2Bucket) {
super(); super();
} }
@@ -177,9 +175,6 @@ export class StorageR2Adapter extends StorageAdapter {
} }
protected getKey(key: string) { protected getKey(key: string) {
if (this.keyPrefix.length > 0) {
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
}
return key; return key;
} }
@@ -28,8 +28,7 @@ describe("StorageR2Adapter", async () => {
const buffer = readFileSync(path.join(basePath, "image.png")); const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" }); const file = new File([buffer], "image.png", { type: "image/png" });
// miniflare doesn't support range requests await adapterTestSuite(viTestRunner, adapter, file);
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
}); });
afterAll(async () => { afterAll(async () => {
+18 -35
View File
@@ -6,24 +6,18 @@ import {
guessMimeType, guessMimeType,
type MaybePromise, type MaybePromise,
registries as $registries, registries as $registries,
type Merge,
} from "bknd"; } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import type { Context, MiddlewareHandler, Next } from "hono"; import type { Context, MiddlewareHandler, Next } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
export type BkndConfig<Args = any, Additional = {}> = Merge< export type BkndConfig<Args = any> = CreateAppConfig & {
CreateAppConfig & app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
Omit<Additional, "app"> & {
app?:
| Omit<BkndConfig<Args, Additional>, "app">
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
onBuilt?: (app: App) => MaybePromise<void>; onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>; beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
} };
>;
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
@@ -57,10 +51,11 @@ export async function makeConfig<Args = DefaultArgs>(
return { ...rest, ...additionalConfig }; return { ...rest, ...additionalConfig };
} }
// a map that contains all apps by id
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>( export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config, config: Config = {} as Config,
args?: Args, args?: Args,
): Promise<{ app: App; config: BkndConfig<Args> }> { ): Promise<App> {
await config.beforeBuild?.(undefined, $registries); await config.beforeBuild?.(undefined, $registries);
const appConfig = await makeConfig(config, args); const appConfig = await makeConfig(config, args);
@@ -70,37 +65,34 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
connection = config.connection; connection = config.connection;
} else { } else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite; const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: "file:data.db" }; const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any; connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url); $console.info(`Using ${connection!.name} connection`, conf.url);
} }
appConfig.connection = connection; appConfig.connection = connection;
} }
return { return App.create(appConfig);
app: App.create(appConfig),
config: appConfig,
};
} }
export async function createFrameworkApp<Args = DefaultArgs>( export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {}, config: FrameworkBkndConfig = {},
args?: Args, args?: Args,
): Promise<App> { ): Promise<App> {
const { app, config: appConfig } = await createAdapterApp(config, args); const app = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
if (config.onBuilt) { if (config.onBuilt) {
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { async () => {
await appConfig.onBuilt?.(app); await config.onBuilt?.(app);
}, },
"sync", "sync",
); );
} }
await appConfig.beforeBuild?.(app, $registries); await config.beforeBuild?.(app, $registries);
await app.build(config.buildConfig); await app.build(config.buildConfig);
} }
@@ -111,7 +103,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {}, { serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args, args?: Args,
): Promise<App> { ): Promise<App> {
const { app, config: appConfig } = await createAdapterApp(config, args); const app = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
app.emgr.onEvent( app.emgr.onEvent(
@@ -124,7 +116,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
app.modules.server.get(path, handler); app.modules.server.get(path, handler);
} }
await appConfig.onBuilt?.(app); await config.onBuilt?.(app);
if (adminOptions !== false) { if (adminOptions !== false) {
app.registerAdminController(adminOptions); app.registerAdminController(adminOptions);
} }
@@ -132,7 +124,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
"sync", "sync",
); );
await appConfig.beforeBuild?.(app, $registries); await config.beforeBuild?.(app, $registries);
await app.build(config.buildConfig); await app.build(config.buildConfig);
} }
@@ -155,32 +147,23 @@ export async function createRuntimeApp<Args = DefaultArgs>(
* }); * });
* ``` * ```
*/ */
export function serveStaticViaImport(opts?: { export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
manifest?: Manifest;
appendRaw?: boolean;
package?: string;
}) {
let files: string[] | undefined; let files: string[] | undefined;
const pkg = opts?.package ?? "bknd";
// @ts-ignore // @ts-ignore
return async (c: Context, next: Next) => { return async (c: Context, next: Next) => {
if (!files) { if (!files) {
const manifest = const manifest =
opts?.manifest || opts?.manifest ||
(( ((await import("bknd/dist/manifest.json", { with: { type: "json" } }))
await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, { .default as Manifest);
with: { type: "json" },
})
).default as Manifest);
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]); files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
} }
const path = c.req.path.substring(1); const path = c.req.path.substring(1);
if (files.includes(path)) { if (files.includes(path)) {
try { try {
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`; const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
const content = await import(/* @vite-ignore */ url, {
with: { type: "text" }, with: { type: "text" },
}).then((m) => m.default); }).then((m) => m.default);
@@ -193,7 +176,7 @@ export function serveStaticViaImport(opts?: {
}); });
} }
} catch (e) { } catch (e) {
console.error(`Error serving static file "${path}":`, String(e)); console.error("Error serving static file:", e);
return c.text("File not found", 404); return c.text("File not found", 404);
} }
} }
+4 -7
View File
@@ -1,6 +1,5 @@
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter"; import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils"; import { isNode } from "bknd/utils";
// @ts-expect-error next is not installed
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"]; type NextjsEnv = NextApiRequest["env"];
@@ -10,18 +9,16 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>( export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>, config: NextjsBkndConfig<Env>,
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
return await createFrameworkApp(config, args); return await createFrameworkApp(config, args ?? (process.env as Env));
} }
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
if (!cleanRequest) return req; if (!cleanRequest) return req;
const url = new URL(req.url); const url = new URL(req.url);
cleanRequest?.searchParams?.forEach((k) => { cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
url.searchParams.delete(k);
});
if (isNode()) { if (isNode()) {
return new Request(url.toString(), { return new Request(url.toString(), {
@@ -42,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>( export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {}, { cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
return async (req: Request) => { return async (req: Request) => {
const app = await getApp(config, args); const app = await getApp(config, args);
-10
View File
@@ -1,13 +1,3 @@
import { readFile, writeFile } from "node:fs/promises";
export * from "./node.adapter"; export * from "./node.adapter";
export * from "./storage"; export * from "./storage";
export * from "./connection/NodeSqliteConnection"; export * from "./connection/NodeSqliteConnection";
export async function writer(path: string, content: string) {
await writeFile(path, content);
}
export async function reader(path: string) {
return await readFile(path, "utf-8");
}
+7 -6
View File
@@ -17,14 +17,14 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>( export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {}, { distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
); );
if (relativeDistPath) { if (relativeDistPath) {
$console.warn("relativeDistPath is deprecated, please use distPath instead"); console.warn("relativeDistPath is deprecated, please use distPath instead");
} }
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -33,18 +33,19 @@ export async function createApp<Env = NodeEnv>(
serveStatic: serveStatic({ root }), serveStatic: serveStatic({ root }),
...config, ...config,
}, },
args, // @ts-ignore
args ?? { env: process.env },
); );
} }
export function createHandler<Env = NodeEnv>( export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {}, config: NodeBkndConfig<Env> = {},
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args); app = await createApp(config, args ?? (process.env as Env));
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -52,7 +53,7 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>( export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {}, { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
honoServe( honoServe(
{ {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
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 { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
@@ -80,79 +80,18 @@ export class StorageLocalAdapter extends StorageAdapter {
} }
} }
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// Parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// Validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
try { try {
const filePath = `${this.config.path}/${key}`; const content = await readFile(`${this.config.path}/${key}`);
const stats = await stat(filePath);
const fileSize = stats.size;
const mimeType = guessMimeType(key); const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// Invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const content = await readFile(filePath, { encoding: null });
const chunk = content.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.length.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// Normal request - return entire file
const content = await readFile(filePath);
responseHeaders.set("Content-Length", content.length.toString());
return new Response(content, { return new Response(content, {
status: 200, status: 200,
headers: responseHeaders, headers: {
"Content-Type": mimeType || "application/octet-stream",
"Content-Length": content.length.toString(),
},
}); });
}
} catch (error) { } catch (error) {
// Handle file reading errors // Handle file reading errors
return new Response("", { status: 404 }); return new Response("", { status: 404 });
@@ -8,14 +8,14 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>( export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>, config: ReactRouterBkndConfig<Env>,
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
return await createFrameworkApp(config, args); return await createFrameworkApp(config, args ?? process.env);
} }
export function serve<Env = ReactRouterEnv>( export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {}, config: ReactRouterBkndConfig<Env> = {},
args: Env = process.env as Env, args: Env = {} as Env,
) { ) {
return async (fnArgs: ReactRouterFunctionArgs) => { return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
-1
View File
@@ -1 +0,0 @@
export * from "./sveltekit.adapter";
@@ -1,16 +0,0 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as sveltekit from "./sveltekit.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("sveltekit adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: (c, a) => sveltekit.getApp(c as any, a ?? ({} as any)),
makeHandler: (c, a) => (request: Request) =>
sveltekit.serve(c as any, a ?? ({} as any))({ request }),
});
});
@@ -1,33 +0,0 @@
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
type TSvelteKit = {
request: Request;
};
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
/**
* Get bknd app instance
* @param config - bknd configuration
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
*/
export async function getApp<Env>(
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
args: Env,
) {
return await createRuntimeApp(config, args);
}
/**
* Create request handler for hooks.server.ts
* @param config - bknd configuration
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
*/
export function serve<Env>(
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
args: Env,
) {
return async (fnArgs: TSvelteKit) => {
return (await getApp(config, args)).fetch(fnArgs.request);
};
}
+3 -46
View File
@@ -2,7 +2,7 @@ import type { DB, PrimaryFieldType } from "bknd";
import * as AuthPermissions from "auth/auth-permissions"; import * as AuthPermissions from "auth/auth-permissions";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { $console, secureRandomString, transformObject, pickKeys } from "bknd/utils"; import { $console, secureRandomString, transformObject } from "bknd/utils";
import type { Entity, EntityManager } from "data/entities"; import type { Entity, EntityManager } from "data/entities";
import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
@@ -46,22 +46,6 @@ export class AppAuth extends Module<AppAuthSchema> {
to.strategies!.password!.enabled = true; to.strategies!.password!.enabled = true;
} }
if (to.default_role_register && to.default_role_register?.length > 0) {
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
if (!valid_to_role) {
const msg = `Default role for registration not found: ${to.default_role_register}`;
// if changing to a new value
if (from.default_role_register !== to.default_role_register) {
throw new Error(msg);
}
// resetting gracefully, since role doesn't exist anymore
$console.warn(`${msg}, resetting to undefined`);
to.default_role_register = undefined;
}
}
return to; return to;
} }
@@ -77,7 +61,7 @@ export class AppAuth extends Module<AppAuthSchema> {
// register roles // register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => { const roles = transformObject(this.config.roles ?? {}, (role, name) => {
return Role.create(name, role); return Role.create({ name, ...role });
}); });
this.ctx.guard.setRoles(Object.values(roles)); this.ctx.guard.setRoles(Object.values(roles));
this.ctx.guard.setConfig(this.config.guard ?? {}); this.ctx.guard.setConfig(this.config.guard ?? {});
@@ -98,7 +82,6 @@ export class AppAuth extends Module<AppAuthSchema> {
this._authenticator = new Authenticator(strategies, new AppUserPool(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,
default_role_register: this.config.default_role_register,
}); });
this.registerEntities(); this.registerEntities();
@@ -130,19 +113,6 @@ export class AppAuth extends Module<AppAuthSchema> {
return authConfigSchema; return authConfigSchema;
} }
getGuardContextSchema() {
const userschema = this.getUsersEntity().toSchema() as any;
return {
type: "object",
properties: {
user: {
type: "object",
properties: pickKeys(userschema.properties, this.config.jwt.fields as any),
},
},
};
}
get authenticator(): Authenticator { get authenticator(): Authenticator {
this.throwIfNotBuilt(); this.throwIfNotBuilt();
return this._authenticator!; return this._authenticator!;
@@ -188,20 +158,10 @@ export class AppAuth extends Module<AppAuthSchema> {
} catch (e) {} } catch (e) {}
} }
async createUser({ async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
email,
password,
role,
...additional
}: CreateUserPayload): Promise<DB["users"]> {
if (!this.enabled) { if (!this.enabled) {
throw new Error("Cannot create user, auth not enabled"); throw new Error("Cannot create user, auth not enabled");
} }
if (role) {
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
throw new Error(`Role "${role}" not found`);
}
}
const strategy = "password" as const; const strategy = "password" as const;
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
@@ -210,7 +170,6 @@ export class AppAuth extends Module<AppAuthSchema> {
mutator.__unstable_toggleSystemEntityCreation(false); mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({ const { data: created } = await mutator.insertOne({
...(additional as any), ...(additional as any),
role: role || this.config.default_role_register || undefined,
email, email,
strategy, strategy,
strategy_value, strategy_value,
@@ -251,12 +210,10 @@ export class AppAuth extends Module<AppAuthSchema> {
} }
const strategies = this.authenticator.getStrategies(); const strategies = this.authenticator.getStrategies();
const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()]));
return { return {
...this.config, ...this.config,
...this.authenticator.toJSON(secrets), ...this.authenticator.toJSON(secrets),
roles,
strategies: transformObject(strategies, (strategy) => ({ strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy), enabled: this.isStrategyEnabled(strategy),
...strategy.toJSON(secrets), ...strategy.toJSON(secrets),
+10 -11
View File
@@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
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, verified?: boolean) => void | Promise<void>; onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit"; credentials?: "include" | "same-origin" | "omit";
}; };
@@ -17,19 +17,23 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input); const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: this.options.credentials,
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token, true); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input); const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: this.options.credentials,
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token, true); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
@@ -67,11 +71,6 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async logout() { async logout() {
return this.get(["logout"], undefined, { await this.options.onTokenUpdate?.(undefined);
headers: {
// this way bknd detects a json request and doesn't redirect back
Accept: "application/json",
},
}).then(() => this.options.onTokenUpdate?.(undefined, true));
} }
} }
+12 -19
View File
@@ -13,7 +13,6 @@ import {
InvalidSchemaError, InvalidSchemaError,
transformObject, transformObject,
mcpTool, mcpTool,
$console,
} from "bknd/utils"; } from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
@@ -61,10 +60,7 @@ export class AuthController extends Controller {
if (create) { if (create) {
hono.post( hono.post(
"/create", "/create",
permission(AuthPermissions.createUser, {}), permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
permission(DataPermissions.entityCreate, {
context: (c) => ({ entity: this.auth.config.entity_name }),
}),
describeRoute({ describeRoute({
summary: "Create a new user", summary: "Create a new user",
tags: ["auth"], tags: ["auth"],
@@ -211,7 +207,7 @@ export class AuthController extends Controller {
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]); const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
const getUser = async (params: { id?: string | number; email?: string }) => { const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined; let user: DB["users"] | undefined = undefined;
if (params.id) { if (params.id) {
const { data } = await this.userRepo.findId(params.id); const { data } = await this.userRepo.findId(params.id);
user = data; user = data;
@@ -226,16 +222,14 @@ export class AuthController extends Controller {
}; };
const roles = Object.keys(this.auth.config.roles ?? {}); const roles = Object.keys(this.auth.config.roles ?? {});
try {
const actions = this.auth.authenticator.strategy("password").getActions();
if (actions.create) {
const schema = actions.create.schema;
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_create", "auth_user_create",
{ {
description: "Create a new user", description: "Create a new user",
inputSchema: s.object({ inputSchema: s.object({
...schema.properties, email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
role: s role: s
.string({ .string({
enum: roles.length > 0 ? roles : undefined, enum: roles.length > 0 ? roles : undefined,
@@ -244,17 +238,14 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.createUser); await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
return c.json(await this.auth.createUser(params)); return c.json(await this.auth.createUser(params));
}, },
); );
}
} catch (e) {
$console.warn("error creating auth_user_create tool", e);
}
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_token", "auth_user_token",
{ {
description: "Get a user token", description: "Get a user token",
@@ -264,7 +255,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.createToken); await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
const user = await getUser(params); const user = await getUser(params);
return c.json({ user, token: await this.auth.authenticator.jwt(user) }); return c.json({ user, token: await this.auth.authenticator.jwt(user) });
@@ -272,6 +263,7 @@ export class AuthController extends Controller {
); );
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_password_change", "auth_user_password_change",
{ {
description: "Change a user's password", description: "Change a user's password",
@@ -282,7 +274,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.changePassword); await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
const user = await getUser(params); const user = await getUser(params);
if (!(await this.auth.changePassword(user.id, params.password))) { if (!(await this.auth.changePassword(user.id, params.password))) {
@@ -293,6 +285,7 @@ export class AuthController extends Controller {
); );
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_password_test", "auth_user_password_test",
{ {
description: "Test a user's password", description: "Test a user's password",
@@ -302,7 +295,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword); await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator); const controller = pw.getController(this.auth.authenticator);
+1 -1
View File
@@ -1,4 +1,4 @@
import { Permission } from "auth/authorize/Permission"; import { Permission } from "core/security/Permission";
export const createUser = new Permission("auth.user.create"); export const createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update"); //export const updateUser = new Permission("auth.user.update");
+6 -5
View File
@@ -1,7 +1,6 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { roleSchema } from "auth/authorize/Role"; import { objectTransform, s } from "bknd/utils";
import { objectTransform, omitKeys, pick, s } from "bknd/utils";
import { $object, $record } from "modules/mcp"; import { $object, $record } from "modules/mcp";
export const Strategies = { export const Strategies = {
@@ -41,8 +40,11 @@ export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth
const guardConfigSchema = s.object({ const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(), enabled: s.boolean({ default: false }).optional(),
}); });
export const guardRoleSchema = s.strictObject({
export const guardRoleSchema = roleSchema; permissions: s.array(s.string()).optional(),
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export const authConfigSchema = $object( export const authConfigSchema = $object(
"config_auth", "config_auth",
@@ -51,7 +53,6 @@ export const authConfigSchema = $object(
basepath: s.string({ default: "/api/auth" }), basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }), entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(), allow_register: s.boolean({ default: true }).optional(),
default_role_register: s.string().optional(),
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
strategies: $record( strategies: $record(
+6 -39
View File
@@ -6,8 +6,10 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import { type CookieOptions, serializeSigned } from "hono/utils/cookie"; import { type CookieOptions, serializeSigned } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
import { InvalidConditionsException } from "auth/errors"; import { InvalidConditionsException } from "auth/errors";
import { s, parse, secret, runtimeSupports, truncate, $console, pickKeys } from "bknd/utils"; import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
import { $object } from "modules/mcp";
import type { AuthStrategy } from "./strategies/Strategy"; import type { AuthStrategy } from "./strategies/Strategy";
type Input = any; // workaround type Input = any; // workaround
@@ -42,7 +44,6 @@ export interface UserPool {
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
export const cookieConfig = s export const cookieConfig = s
.strictObject({ .strictObject({
domain: s.string().optional(),
path: s.string({ default: "/" }), path: s.string({ default: "/" }),
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
secure: s.boolean({ default: true }), secure: s.boolean({ default: true }),
@@ -74,7 +75,6 @@ export const jwtConfig = s.strictObject(
export const authenticatorConfig = s.object({ export const authenticatorConfig = s.object({
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
default_role_register: s.string().optional(),
}); });
type AuthConfig = s.Static<typeof authenticatorConfig>; type AuthConfig = s.Static<typeof authenticatorConfig>;
@@ -165,13 +165,9 @@ export class Authenticator<
if (!("strategy_value" in profile)) { if (!("strategy_value" in profile)) {
throw new InvalidConditionsException("Profile must have a strategy value"); throw new InvalidConditionsException("Profile must have a strategy value");
} }
if ("role" in profile) {
throw new InvalidConditionsException("Role cannot be provided during registration");
}
const user = await this.userPool.create(strategy.getName(), { const user = await this.userPool.create(strategy.getName(), {
...profile, ...profile,
role: this.config.default_role_register,
strategy_value: profile.strategy_value, strategy_value: profile.strategy_value,
}); });
@@ -233,7 +229,7 @@ export class Authenticator<
// @todo: add jwt tests // @todo: add jwt tests
async jwt(_user: SafeUser | ProfileExchange): Promise<string> { async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
const user = pickKeys(_user, this.config.jwt.fields as any); const user = pick(_user, this.config.jwt.fields);
const payload: JWTPayload = { const payload: JWTPayload = {
...user, ...user,
@@ -259,7 +255,7 @@ export class Authenticator<
} }
async safeAuthResponse(_user: User): Promise<AuthResponse> { async safeAuthResponse(_user: User): Promise<AuthResponse> {
const user = pickKeys(_user, this.config.jwt.fields as any) as SafeUser; const user = pick(_user, this.config.jwt.fields) as SafeUser;
return { return {
user, user,
token: await this.jwt(user), token: await this.jwt(user),
@@ -294,7 +290,6 @@ export class Authenticator<
return { return {
...cookieConfig, ...cookieConfig,
domain: cookieConfig.domain ?? undefined,
expires: new Date(Date.now() + expires * 1000), expires: new Date(Date.now() + expires * 1000),
}; };
} }
@@ -332,31 +327,6 @@ export class Authenticator<
await setSignedCookie(c, "auth", token, secret, this.cookieOptions); await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
} }
async getAuthCookieHeader(token: string, headers = new Headers()) {
const c = {
header: (key: string, value: string) => {
headers.set(key, value);
},
};
await this.setAuthCookie(c as any, token);
return headers;
}
async removeAuthCookieHeader(headers = new Headers()) {
const c = {
header: (key: string, value: string) => {
headers.set(key, value);
},
req: {
raw: {
headers,
},
},
};
this.deleteAuthCookie(c as any);
return headers;
}
async unsafeGetAuthCookie(token: string): Promise<string | undefined> { async unsafeGetAuthCookie(token: string): Promise<string | undefined> {
// this works for as long as cookieOptions.prefix is not set // this works for as long as cookieOptions.prefix is not set
return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions); return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions);
@@ -384,10 +354,7 @@ export class Authenticator<
// @todo: move this to a server helper // @todo: move this to a server helper
isJsonRequest(c: Context): boolean { isJsonRequest(c: Context): boolean {
return ( return c.req.header("Content-Type") === "application/json";
c.req.header("Content-Type") === "application/json" ||
c.req.header("Accept") === "application/json"
);
} }
async getBody(c: Context) { async getBody(c: Context) {
@@ -1,7 +1,7 @@
import type { User } from "bknd"; import type { User } from "bknd";
import type { Authenticator } from "auth/authenticate/Authenticator"; import type { Authenticator } from "auth/authenticate/Authenticator";
import { InvalidCredentialsException } from "auth/errors"; import { InvalidCredentialsException } from "auth/errors";
import { hash, $console, s, parse, jsc, describeRoute } from "bknd/utils"; import { hash, $console, s, parse, jsc } from "bknd/utils";
import { Hono } from "hono"; import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import { AuthStrategy } from "./Strategy"; import { AuthStrategy } from "./Strategy";
@@ -10,7 +10,6 @@ const schema = s
.object({ .object({
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }), hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
rounds: s.number({ minimum: 1, maximum: 10 }).optional(), rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
minLength: s.number({ default: 8, minimum: 1 }).optional(),
}) })
.strict(); .strict();
@@ -38,7 +37,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
format: "email", format: "email",
}), }),
password: s.string({ password: s.string({
minLength: this.config.minLength, minLength: 8, // @todo: this should be configurable
}), }),
}); });
} }
@@ -66,21 +65,12 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
return await bcryptCompare(compare, actual); return await bcryptCompare(compare, actual);
} }
return actual === compare; return false;
} }
verify(password: string) { verify(password: string) {
return async (user: User) => { return async (user: User) => {
if (!user || !user.strategy_value) { const compare = await this.compare(user?.strategy_value!, password);
throw new InvalidCredentialsException();
}
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
$console.debug("PasswordStrategy: Invalid password", password);
throw new InvalidCredentialsException();
}
const compare = await this.compare(user.strategy_value, password);
if (compare !== true) { if (compare !== true) {
throw new InvalidCredentialsException(); throw new InvalidCredentialsException();
} }
@@ -94,14 +84,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
}); });
const payloadSchema = this.getPayloadSchema(); const payloadSchema = this.getPayloadSchema();
hono.post( hono.post("/login", jsc("query", redirectQuerySchema), async (c) => {
"/login",
describeRoute({
summary: "Login with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try { try {
const body = parse(payloadSchema, await authenticator.getBody(c), { const body = parse(payloadSchema, await authenticator.getBody(c), {
onError: (errors) => { onError: (errors) => {
@@ -117,17 +100,9 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
} catch (e) { } catch (e) {
return authenticator.respondWithError(c, e as any); return authenticator.respondWithError(c, e as any);
} }
}, });
);
hono.post( hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
"/register",
describeRoute({
summary: "Register a new user with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try { try {
const { redirect } = c.req.valid("query"); const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse( const { password, email, ...body } = parse(
@@ -153,8 +128,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
} catch (e) { } catch (e) {
return authenticator.respondWithError(c, e as any); return authenticator.respondWithError(c, e as any);
} }
}, });
);
return hono; return hono;
} }
+69 -238
View File
@@ -1,12 +1,9 @@
import { Exception } from "core/errors"; import { Exception } from "core/errors";
import { $console, mergeObject, type s } from "bknd/utils"; import { $console, objectTransform } from "bknd/utils";
import type { Permission, PermissionContext } from "auth/authorize/Permission"; import { Permission } from "core/security/Permission";
import type { Context } from "hono"; import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
import type { Role } from "./Role"; import { Role } from "./Role";
import { HttpStatus } from "bknd/utils";
import type { Policy, PolicySchema } from "./Policy";
import { convert, type ObjectQuery } from "core/object/query/object-query";
export type GuardUserContext = { export type GuardUserContext = {
role?: string | null; role?: string | null;
@@ -15,43 +12,41 @@ export type GuardUserContext = {
export type GuardConfig = { export type GuardConfig = {
enabled?: boolean; enabled?: boolean;
context?: object;
}; };
export type GuardContext = Context<ServerEnv> | GuardUserContext; export type GuardContext = Context<ServerEnv> | GuardUserContext;
export class GuardPermissionsException extends Exception {
override name = "PermissionsException";
override code = HttpStatus.FORBIDDEN;
constructor(
public permission: Permission,
public policy?: Policy,
public description?: string,
) {
super(`Permission "${permission.name}" not granted`);
}
override toJSON(): any {
return {
...super.toJSON(),
description: this.description,
permission: this.permission.name,
policy: this.policy?.toJSON(),
};
}
}
export class Guard { export class Guard {
constructor( permissions: Permission[];
public permissions: Permission<any, any, any, any>[] = [], roles?: Role[];
public roles: Role[] = [], config?: GuardConfig;
public config?: GuardConfig,
) { constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
this.permissions = permissions; this.permissions = permissions;
this.roles = roles; this.roles = roles;
this.config = config; this.config = config;
} }
static create(
permissionNames: string[],
roles?: Record<
string,
{
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}
>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
getPermissionNames(): string[] { getPermissionNames(): string[] {
return this.permissions.map((permission) => permission.name); return this.permissions.map((permission) => permission.name);
} }
@@ -78,7 +73,7 @@ export class Guard {
return this; return this;
} }
registerPermission(permission: Permission<any, any, any, any>) { registerPermission(permission: Permission) {
if (this.permissions.find((p) => p.name === permission.name)) { if (this.permissions.find((p) => p.name === permission.name)) {
throw new Error(`Permission ${permission.name} already exists`); throw new Error(`Permission ${permission.name} already exists`);
} }
@@ -87,13 +82,9 @@ export class Guard {
return this; return this;
} }
registerPermissions(permissions: Record<string, Permission<any, any, any, any>>); registerPermissions(permissions: Record<string, Permission>);
registerPermissions(permissions: Permission<any, any, any, any>[]); registerPermissions(permissions: Permission[]);
registerPermissions( registerPermissions(permissions: Permission[] | Record<string, Permission>) {
permissions:
| Permission<any, any, any, any>[]
| Record<string, Permission<any, any, any, any>>,
) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions); const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) { for (const permission of p) {
@@ -126,216 +117,56 @@ export class Guard {
return this.config?.enabled === true; return this.config?.enabled === true;
} }
private collect(permission: Permission, c: GuardContext | undefined, context: any) { hasPermission(permission: Permission, user?: GuardUserContext): boolean;
const user = c && "get" in c ? c.get("auth")?.user : c; hasPermission(name: string, user?: GuardUserContext): boolean;
const ctx = { hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
...((context ?? {}) as any),
...this.config?.context,
user,
};
const exists = this.permissionExists(permission.name);
const role = this.getUserRole(user);
const rolePermission = role?.permissions.find(
(rolePermission) => rolePermission.permission.name === permission.name,
);
return {
ctx,
user,
exists,
role,
rolePermission,
};
}
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): void;
granted<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext): void;
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): void {
if (!this.isEnabled()) { if (!this.isEnabled()) {
return; return true;
}
const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
// validate context
let ctx = Object.assign({}, _ctx);
if (permission.context) {
ctx = permission.parseContext(ctx);
} }
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
$console.debug("guard: checking permission", { $console.debug("guard: checking permission", {
name: permission.name, name,
context: ctx, user: { id: user?.id, role: user?.role },
}); });
const exists = this.permissionExists(name);
if (!exists) { if (!exists) {
throw new GuardPermissionsException( throw new Error(`Permission ${name} does not exist`);
permission,
undefined,
`Permission ${permission.name} does not exist`,
);
} }
const role = this.getUserRole(user);
if (!role) { if (!role) {
throw new GuardPermissionsException(permission, undefined, "User has no role"); $console.debug("guard: user has no role, denying");
}
if (!rolePermission) {
if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return;
}
throw new GuardPermissionsException(
permission,
undefined,
`Role "${role.name}" does not have required permission`,
);
}
if (rolePermission?.policies.length > 0) {
$console.debug("guard: rolePermission has policies, checking");
// set the default effect of the role permission
let allowed = rolePermission.effect === "allow";
for (const policy of rolePermission.policies) {
$console.debug("guard: checking policy", { policy: policy.toJSON(), ctx });
// skip filter policies
if (policy.content.effect === "filter") continue;
// if condition is met, check the effect
const meets = policy.meetsCondition(ctx);
if (meets) {
$console.debug("guard: policy meets condition");
// if deny, then break early
if (policy.content.effect === "deny") {
$console.debug("guard: policy is deny, setting allowed to false");
allowed = false;
break;
// if allow, set allow but continue checking
} else if (policy.content.effect === "allow") {
allowed = true;
}
} else {
$console.debug("guard: policy does not meet condition");
}
}
if (!allowed) {
throw new GuardPermissionsException(permission, undefined, "Policy condition unmet");
}
}
$console.debug("guard allowing", {
permission: permission.name,
role: role.name,
});
}
filters<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
);
filters<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext);
filters<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
) {
if (!permission.isFilterable()) {
throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
}
const {
ctx: _ctx,
exists,
role,
user,
rolePermission,
} = this.collect(permission, c, context);
// validate context
let ctx = Object.assign(
{
user,
},
_ctx,
);
if (permission.context) {
ctx = permission.parseContext(ctx, {
coerceDropUnknown: false,
});
}
const filters: PolicySchema["filter"][] = [];
const policies: Policy[] = [];
if (exists && role && rolePermission && rolePermission.policies.length > 0) {
for (const policy of rolePermission.policies) {
if (policy.content.effect === "filter") {
const meets = policy.meetsCondition(ctx);
if (meets) {
policies.push(policy);
filters.push(policy.getReplacedFilter(ctx));
}
}
}
}
const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined;
return {
filters,
filter,
policies,
merge: (givenFilter: object | undefined) => {
return mergeFilters(givenFilter ?? {}, filter ?? {});
},
matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => {
const subjects = Array.isArray(subject) ? subject : [subject];
if (policies.length > 0) {
for (const policy of policies) {
for (const subject of subjects) {
if (!policy.meetsFilter(subject, ctx)) {
if (opts?.throwOnError) {
throw new GuardPermissionsException(
permission,
policy,
"Policy filter not met",
);
}
return false; return false;
} } else if (role.implicit_allow === true) {
} $console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
}
}
return true; return true;
},
};
}
} }
export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) { const rolePermission = role.permissions.find(
const base_converted = convert(base); (rolePermission) => rolePermission.permission.name === name,
const priority_converted = convert(priority); );
const merged = mergeObject(base_converted, priority_converted);
// in case priority filter is also contained in base's $and, merge priority in $console.debug("guard: rolePermission, allowing?", {
if ("$or" in base_converted && base_converted.$or) { permission: name,
const $ors = base_converted.$or as ObjectQuery; role: role.name,
const priority_keys = Object.keys(priority_converted); allowing: !!rolePermission,
for (const key of priority_keys) { });
if (key in $ors) { return !!rolePermission;
merged.$or[key] = mergeObject($ors[key], priority_converted[key]);
}
}
} }
return merged; granted(permission: Permission | string, c?: GuardContext): boolean {
const user = c && "get" in c ? c.get("auth")?.user : c;
return this.hasPermission(permission as any, user);
}
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
if (!this.granted(permission, c)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403,
);
}
}
} }
-77
View File
@@ -1,77 +0,0 @@
import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils";
export const permissionOptionsSchema = s
.strictObject({
description: s.string(),
filterable: s.boolean(),
})
.partial();
export type TPermission = {
name: string;
description?: string;
filterable?: boolean;
context?: any;
};
export type PermissionOptions = s.Static<typeof permissionOptionsSchema>;
export type PermissionContext<P extends Permission<any, any, any, any>> = P extends Permission<
any,
any,
infer Context,
any
>
? Context extends s.ObjectSchema
? s.Static<Context>
: never
: never;
export class InvalidPermissionContextError extends InvalidSchemaError {
override name = "InvalidPermissionContextError";
// changing to internal server error because it's an unexpected behavior
override code = HttpStatus.INTERNAL_SERVER_ERROR;
static from(e: InvalidSchemaError) {
return new InvalidPermissionContextError(e.schema, e.value, e.errors);
}
}
export class Permission<
Name extends string = string,
Options extends PermissionOptions = {},
Context extends s.ObjectSchema | undefined = undefined,
ContextValue = Context extends s.ObjectSchema ? s.Static<Context> : undefined,
> {
constructor(
public name: Name,
public options: Options = {} as Options,
public context: Context = undefined as Context,
) {}
isFilterable() {
return this.options.filterable === true;
}
parseContext(ctx: ContextValue, opts?: ParseOptions) {
// @todo: allow additional properties
if (!this.context) return ctx;
try {
return this.context ? parse(this.context!, ctx, opts) : undefined;
} catch (e) {
if (e instanceof InvalidSchemaError) {
throw InvalidPermissionContextError.from(e);
}
throw e;
}
}
toJSON() {
return {
name: this.name,
...this.options,
context: this.context,
};
}
}
-52
View File
@@ -1,52 +0,0 @@
import { s, parse, recursivelyReplacePlaceholders } from "bknd/utils";
import * as query from "core/object/query/object-query";
export const policySchema = s
.strictObject({
description: s.string(),
condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
// @todo: potentially remove this, and invert from rolePermission.effect
effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }),
filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
})
.partial();
export type PolicySchema = s.Static<typeof policySchema>;
export class Policy<Schema extends PolicySchema = PolicySchema> {
public content: Schema;
constructor(content?: Schema) {
this.content = parse(policySchema, content ?? {}, {
withDefaults: true,
}) as Schema;
}
replace(context: object, vars?: Record<string, any>, fallback?: any) {
return vars
? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars, fallback)
: context;
}
getReplacedFilter(context: object, fallback?: any) {
if (!this.content.filter) return context;
return this.replace(this.content.filter!, context, fallback);
}
meetsCondition(context: object, vars?: Record<string, any>) {
if (!this.content.condition) return true;
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
if (!this.content.filter) return true;
return query.validate(this.replace(this.content.filter!, vars), subject);
}
getFiltered<Given extends any[]>(given: Given): Given {
return given.filter((item) => this.meetsFilter(item)) as Given;
}
toJSON() {
return this.content;
}
}
+27 -48
View File
@@ -1,39 +1,10 @@
import { s } from "bknd/utils"; import { Permission } from "core/security/Permission";
import { Permission } from "./Permission";
import { Policy, policySchema } from "./Policy";
// default effect is allow for backward compatibility
const defaultEffect = "allow";
export const rolePermissionSchema = s.strictObject({
permission: s.string(),
effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(),
policies: s.array(policySchema).optional(),
});
export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>;
export const roleSchema = s.strictObject({
// @todo: remove anyOf, add migration
permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(),
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export type RoleSchema = s.Static<typeof roleSchema>;
export class RolePermission { export class RolePermission {
constructor( constructor(
public permission: Permission<any, any, any, any>, public permission: Permission,
public policies: Policy[] = [], public config?: any,
public effect: "allow" | "deny" = defaultEffect,
) {} ) {}
toJSON() {
return {
permission: this.permission.name,
policies: this.policies.map((p) => p.toJSON()),
effect: this.effect,
};
}
} }
export class Role { export class Role {
@@ -44,23 +15,31 @@ export class Role {
public implicit_allow: boolean = false, public implicit_allow: boolean = false,
) {} ) {}
static create(name: string, config: RoleSchema) { static createWithPermissionNames(
const permissions = name: string,
config.permissions?.map((p: string | RolePermissionSchema) => { permissionNames: string[],
if (typeof p === "string") { is_default: boolean = false,
return new RolePermission(new Permission(p), []); implicit_allow: boolean = false,
} ) {
const policies = p.policies?.map((policy) => new Policy(policy)); return new Role(
return new RolePermission(new Permission(p.permission), policies, p.effect); name,
}) ?? []; permissionNames.map((name) => new RolePermission(new Permission(name))),
return new Role(name, permissions, config.is_default, config.implicit_allow); is_default,
implicit_allow,
);
} }
toJSON() { static create(config: {
return { name: string;
permissions: this.permissions.map((p) => p.toJSON()), permissions?: string[];
is_default: this.is_default, is_default?: boolean;
implicit_allow: this.implicit_allow, implicit_allow?: boolean;
}; }) {
return new Role(
config.name,
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
config.is_default,
config.implicit_allow,
);
} }
} }
@@ -1,3 +1,4 @@
import type { Permission } from "core/security/Permission";
import { $console, patternMatch } from "bknd/utils"; import { $console, patternMatch } from "bknd/utils";
import type { Context } from "hono"; import type { Context } from "hono";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
@@ -48,7 +49,7 @@ export const auth = (options?: {
// make sure to only register once // make sure to only register once
if (authCtx.registered) { if (authCtx.registered) {
skipped = true; skipped = true;
$console.debug(`auth middleware already registered for ${getPath(c)}`); $console.warn(`auth middleware already registered for ${getPath(c)}`);
} else { } else {
authCtx.registered = true; authCtx.registered = true;
@@ -66,3 +67,48 @@ export const auth = (options?: {
authCtx.resolved = false; authCtx.resolved = false;
authCtx.user = undefined; authCtx.user = undefined;
}); });
export const permission = (
permission: Permission | Permission[],
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
},
) =>
// @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined
if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p, c))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p, c));
}
}
await next();
});
@@ -1,94 +0,0 @@
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import { $console, threw } from "bknd/utils";
import type { Context, Hono } from "hono";
import type { RouterRoute } from "hono/types";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller";
import type { MaybePromise } from "core/types";
import { GuardPermissionsException } from "auth/authorize/Guard";
function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
return new URL(req.url).pathname;
}
const permissionSymbol = Symbol.for("permission");
type PermissionMiddlewareOptions<P extends Permission<any, any, any, any>> = {
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
} & (P extends Permission<any, any, infer PC, any>
? PC extends undefined
? {
context?: never;
}
: {
context: (c: Context<ServerEnv>) => MaybePromise<PermissionContext<P>>;
}
: {
context?: never;
});
export function permission<P extends Permission<any, any, any, any>>(
permission: P,
options: PermissionMiddlewareOptions<P>,
) {
// @ts-ignore (middlewares do not always return)
const handler = createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined
if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const context = (await options?.context?.(c)) ?? ({} as any);
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) {
returned = await options?.onDenied?.(c);
} else {
returned = await options?.onGranted?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
guard.granted(permission, c, context);
}
}
await next();
});
return Object.assign(handler, {
[permissionSymbol]: { permission, options },
});
}
export function getPermissionRoutes(hono: Hono<any>) {
const routes: {
route: RouterRoute;
permission: Permission;
options: PermissionMiddlewareOptions<Permission>;
}[] = [];
for (const route of hono.routes) {
if (permissionSymbol in route.handler) {
routes.push({
route,
...(route.handler[permissionSymbol] as any),
});
}
}
return routes;
}
+2 -6
View File
@@ -10,7 +10,6 @@ import color from "picocolors";
import { overridePackageJson, updateBkndPackages } from "./npm"; import { overridePackageJson, updateBkndPackages } from "./npm";
import { type Template, templates, type TemplateSetupCtx } from "./templates"; import { type Template, templates, type TemplateSetupCtx } from "./templates";
import { createScoped, flush } from "cli/utils/telemetry"; import { createScoped, flush } from "cli/utils/telemetry";
import path from "node:path";
const config = { const config = {
types: { types: {
@@ -21,7 +20,6 @@ const config = {
node: "Node.js", node: "Node.js",
bun: "Bun", bun: "Bun",
cloudflare: "Cloudflare", cloudflare: "Cloudflare",
deno: "Deno",
aws: "AWS Lambda", aws: "AWS Lambda",
}, },
framework: { framework: {
@@ -261,8 +259,7 @@ async function action(options: {
} }
} }
// update package name if there is a package.json // update package name
if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) {
await overridePackageJson( await overridePackageJson(
(pkg) => ({ (pkg) => ({
...pkg, ...pkg,
@@ -271,9 +268,8 @@ async function action(options: {
{ dir: ctx.dir }, { dir: ctx.dir },
); );
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
}
if (template.installDeps !== false) { {
const install = const install =
options.yes ?? options.yes ??
(await $p.confirm({ (await $p.confirm({
-2
View File
@@ -93,7 +93,6 @@ export async function replacePackageJsonVersions(
} }
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) { export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
try {
const versions = { const versions = {
bknd: await sysGetVersion(), bknd: await sysGetVersion(),
...(map ?? {}), ...(map ?? {}),
@@ -107,5 +106,4 @@ export async function updateBkndPackages(dir?: string, map?: Record<string, stri
}, },
{ dir }, { dir },
); );
} catch (e) {}
} }
@@ -1,21 +0,0 @@
import { overrideJson } from "cli/commands/create/npm";
import type { Template } from "cli/commands/create/templates";
import { getVersion } from "cli/utils/sys";
export const deno = {
key: "deno",
title: "Deno Basic",
integration: "deno",
description: "A basic bknd Deno server with static assets",
path: "gh:bknd-io/bknd/examples/deno",
installDeps: false,
ref: true,
setup: async (ctx) => {
const version = await getVersion();
await overrideJson(
"deno.json",
(json) => ({ ...json, links: undefined, imports: { bknd: `npm:bknd@${version}` } }),
{ dir: ctx.dir },
);
},
} satisfies Template;
@@ -1,4 +1,3 @@
import { deno } from "cli/commands/create/templates/deno";
import { cloudflare } from "./cloudflare"; import { cloudflare } from "./cloudflare";
export type TemplateSetupCtx = { export type TemplateSetupCtx = {
@@ -16,7 +15,6 @@ export type Integration =
| "react-router" | "react-router"
| "astro" | "astro"
| "aws" | "aws"
| "deno"
| "custom"; | "custom";
type TemplateScripts = "install" | "dev" | "build" | "start"; type TemplateScripts = "install" | "dev" | "build" | "start";
@@ -36,11 +34,6 @@ export type Template = {
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd * adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
*/ */
ref?: true | string; ref?: true | string;
/**
* control whether to install dependencies automatically
* e.g. on deno, this is not needed
*/
installDeps?: boolean;
scripts?: Partial<Record<TemplateScripts, string>>; scripts?: Partial<Record<TemplateScripts, string>>;
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>; preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>; postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
@@ -97,5 +90,4 @@ export const templates: Template[] = [
path: "gh:bknd-io/bknd/examples/aws-lambda", path: "gh:bknd-io/bknd/examples/aws-lambda",
ref: true, ref: true,
}, },
deno,
]; ];
+3 -9
View File
@@ -17,7 +17,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
case "node": { case "node": {
const m = await import("@hono/node-server/serve-static"); const m = await import("@hono/node-server/serve-static");
const root = getRelativeDistPath() + "/static"; const root = getRelativeDistPath() + "/static";
$console.debug("Serving static files from", root); $console.log("Serving static files from", root);
return m.serveStatic({ return m.serveStatic({
// somehow different for node // somehow different for node
root, root,
@@ -27,7 +27,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
case "bun": { case "bun": {
const m = await import("hono/bun"); const m = await import("hono/bun");
const root = path.resolve(getRelativeDistPath(), "static"); const root = path.resolve(getRelativeDistPath(), "static");
$console.debug("Serving static files from", root); $console.log("Serving static files from", root);
return m.serveStatic({ return m.serveStatic({
root, root,
onNotFound, onNotFound,
@@ -67,10 +67,7 @@ export async function startServer(
$console.info("Server listening on", url); $console.info("Server listening on", url);
if (options.open) { if (options.open) {
const p = await open(url, { wait: false }); await open(url);
p.on("error", () => {
$console.warn("Couldn't open url in browser");
});
} }
} }
@@ -79,9 +76,6 @@ export async function getConfigPath(filePath?: string) {
const config_path = path.resolve(process.cwd(), filePath); const config_path = path.resolve(process.cwd(), filePath);
if (await fileExists(config_path)) { if (await fileExists(config_path)) {
return config_path; return config_path;
} else {
$console.error(`Config file could not be resolved: ${config_path}`);
process.exit(1);
} }
} }
+3 -5
View File
@@ -2,8 +2,9 @@ import type { Config } from "@libsql/client/node";
import { StorageLocalAdapter } from "adapter/node/storage"; import { StorageLocalAdapter } from "adapter/node/storage";
import type { CliBkndConfig, CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd"; import { config, type App, type CreateAppConfig, type MaybePromise } from "bknd";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { registries } from "modules/registries";
import c from "picocolors"; import c from "picocolors";
import path from "node:path"; import path from "node:path";
import { import {
@@ -110,10 +111,7 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
// try to use an in-memory connection // try to use an in-memory connection
} else if (options.memory) { } else if (options.memory) {
console.info("Using", c.cyan("in-memory"), "connection"); console.info("Using", c.cyan("in-memory"), "connection");
app = await makeApp({ app = await makeApp({ server: { platform: options.server } });
server: { platform: options.server },
connection: { url: ":memory:" },
});
// finally try to use env variables // finally try to use env variables
} else { } else {
+1 -16
View File
@@ -8,7 +8,6 @@ export const sync: CliCommand = (program) => {
withConfigOptions(program.command("sync")) withConfigOptions(program.command("sync"))
.description("sync database") .description("sync database")
.option("--force", "perform database syncing operations") .option("--force", "perform database syncing operations")
.option("--seed", "perform seeding operations")
.option("--drop", "include destructive DDL operations") .option("--drop", "include destructive DDL operations")
.option("--out <file>", "output file") .option("--out <file>", "output file")
.option("--sql", "use sql output") .option("--sql", "use sql output")
@@ -30,22 +29,8 @@ export const sync: CliCommand = (program) => {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql)); console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop }); await schema.sync({ force: true, drop: options.drop });
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); console.info(`\n${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`); console.info(`${c.green("Database synced")}`);
if (options.seed) {
console.info(c.dim("\nExecuting seed..."));
const seed = app.options?.seed;
if (seed) {
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
}
}
} else { } else {
if (options.out) { if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2); const output = options.sql ? sql : JSON.stringify(stmts, null, 2);

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