mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c26d646bd | |||
| 4859b99954 | |||
| 3f55c0f2c5 | |||
| 9ec31373e1 | |||
| 70b25a9f9b | |||
| 300d86ff7c | |||
| 4c9b662f6f | |||
| a862cfdcf1 | |||
| de62f4e729 | |||
| 4575d89cfe | |||
| 0ca0828581 | |||
| 07a57ebf67 | |||
| e9f1241ec3 | |||
| 80903d4ffa | |||
| c8290802e9 | |||
| ac6cd4a900 | |||
| a91ddb1ec3 | |||
| 5417aa174e | |||
| 6093f4f46f | |||
| 2178e0ee8b | |||
| be39e8a391 | |||
| b57f362e3a | |||
| e055e477ae | |||
| 422f7893b5 | |||
| 0a50f9850c | |||
| 42f340b189 | |||
| ef41b71921 | |||
| 2847e64b77 | |||
| 28390b0b84 | |||
| 0b58cadbd0 | |||
| 574b37abcd | |||
| 0dbf71e6b5 | |||
| fd1f0f7c54 | |||
| ebad3d15ec | |||
| 2fd5e71574 | |||
| b787837dd2 | |||
| 1fc6e810ae | |||
| 88cc406002 | |||
| 511f639d7a | |||
| 0df17221df | |||
| 347fe0f6ce | |||
| f3c6cd7620 | |||
| 649c465bd0 | |||
| 67604b344a | |||
| aedae8e757 | |||
| 166409fdf4 | |||
| f2aad9caac | |||
| 292e4595ea | |||
| 869031bbfa | |||
| 88e5c06e9d | |||
| cfb4b0e336 | |||
| 2d56b54e0c | |||
| 5d4a77fb10 | |||
| eb0822bbff | |||
| 38902ebcba | |||
| 22e43c2523 | |||
| e68e5792be | |||
| 511c6539fb | |||
| 9070f96571 | |||
| 0347efa592 | |||
| 1b8ce41837 | |||
| 6624927286 | |||
| 803f42a72b | |||
| 7e5c28d621 | |||
| 2f88c2216c | |||
| b89c090f87 | |||
| f4a7cde487 | |||
| a2d83d01a4 | |||
| 66392d094d | |||
| 0352c72fb6 | |||
| 3f9be3a418 | |||
| fd3dd310a5 | |||
| e6ff5c3f0b | |||
| 5377ac1a41 | |||
| 0e870cda81 | |||
| b784e1c1c4 | |||
| 90f93caff4 | |||
| d6dcfe3acc | |||
| b974fe7ec7 | |||
| 6483ff74bb | |||
| 27e2064f0c | |||
| 5cf91bb35a | |||
| 81533d855a | |||
| db58911df3 | |||
| 2540c83382 | |||
| 569d021316 | |||
| ba9713587c | |||
| eecaeb7c06 | |||
| 610e263477 | |||
| 0f54e8267f | |||
| 1fdee8435d | |||
| 1eeb23232a | |||
| 6102759da8 | |||
| 6cea581e42 | |||
| 5e553a7fce | |||
| 5e71fc8947 | |||
| d1ba638cd5 | |||
| d31416f85d | |||
| 55082e9d0e | |||
| 0d74625270 | |||
| 800f14ede2 | |||
| 560379bd89 | |||
| daafee2c06 | |||
| ab3e8ce55f | |||
| a655c990ed | |||
| 2c976adb77 | |||
| 1128ac500d | |||
| 06d7558c3c | |||
| 7344b1cf3d | |||
| ace9c1b2b9 | |||
| 0629e1bc50 | |||
| 832eb6ac31 | |||
| aa8bf156b0 | |||
| 83c1c86eff | |||
| ffe53d3fb5 | |||
| 49aee37199 | |||
| 54eee8cd34 | |||
| 5e62e681e7 | |||
| 99c1645411 | |||
| 564eab23af | |||
| f2da54c92b | |||
| cd262097dc | |||
| d1726b23f1 |
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: "1.2.19"
|
bun-version: "1.3.1"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@ packages/media/.env
|
|||||||
.npmrc
|
.npmrc
|
||||||
/.verdaccio
|
/.verdaccio
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
.git_old
|
.git_old
|
||||||
docker/tmp
|
docker/tmp
|
||||||
.debug
|
.debug
|
||||||
|
|||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,17 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
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!
|
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!
|
||||||
|
|
||||||
|
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
|
* **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.**
|
||||||
|
|
||||||
@@ -24,6 +28,18 @@ bknd simplifies app development by providing a fully functional backend for data
|
|||||||
> 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
|
||||||

|

|
||||||

|

|
||||||
@@ -46,13 +62,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:
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"typescript.preferences.includePackageJsonAutoImports": "off",
|
||||||
|
"typescript.suggest.autoImports": true,
|
||||||
|
"typescript.preferences.importModuleSpecifier": "relative",
|
||||||
|
"search.exclude": {
|
||||||
|
"**/dist/**": true,
|
||||||
|
"**/node_modules/**": true
|
||||||
|
},
|
||||||
|
"files.exclude": {
|
||||||
|
"**/dist/**": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.only("drivers", async () => {
|
test("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.
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
@@ -6,13 +6,16 @@ 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(false);
|
expect(api.isAuthVerified()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should construct from request (token)", async () => {
|
it("should construct from request (token)", async () => {
|
||||||
|
|||||||
@@ -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(file, {
|
return new Response(new File([await file.bytes()], name, { type: file.type }), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": file.type,
|
"Content-Type": file.type,
|
||||||
"Content-Length": file.size.toString(),
|
"Content-Length": file.size.toString(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("AppServer", () => {
|
|||||||
mcp: {
|
mcp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
path: "/api/system/mcp",
|
path: "/api/system/mcp",
|
||||||
|
logLevel: "warning",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ describe("AppServer", () => {
|
|||||||
mcp: {
|
mcp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
path: "/api/system/mcp",
|
path: "/api/system/mcp",
|
||||||
|
logLevel: "warning",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, mock, test } 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";
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ 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) => {
|
||||||
@@ -200,7 +201,10 @@ describe("mcp auth", async () => {
|
|||||||
},
|
},
|
||||||
return_config: true,
|
return_config: true,
|
||||||
});
|
});
|
||||||
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
|
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
]);
|
||||||
|
|
||||||
// update role
|
// update role
|
||||||
await tool(server, "config_auth_roles_update", {
|
await tool(server, "config_auth_roles_update", {
|
||||||
@@ -209,13 +213,15 @@ describe("mcp auth", async () => {
|
|||||||
permissions: ["read"],
|
permissions: ["read"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
|
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([
|
||||||
|
"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).toEqual(["read"]);
|
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
|
||||||
|
|
||||||
// remove role
|
// remove role
|
||||||
await tool(server, "config_auth_roles_remove", {
|
await tool(server, "config_auth_roles_remove", {
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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) => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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) => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe("mcp system", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe("mcp system", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.only("verify inversedBy", async () => {
|
test("verify inversedBy", async () => {
|
||||||
const schema = proto.em(
|
const schema = proto.em(
|
||||||
{
|
{
|
||||||
products: proto.entity("products", {
|
products: proto.entity("products", {
|
||||||
|
|||||||
@@ -1,3 +1,41 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Guard } from "../../../src/auth/authorize/Guard";
|
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
||||||
|
import { Permission } from "auth/authorize/Permission";
|
||||||
|
import { Role, type RoleSchema } from "auth/authorize/Role";
|
||||||
|
import { objectTransform, s } from "bknd/utils";
|
||||||
|
|
||||||
|
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 = Guard.create(
|
const guard = createGuard(
|
||||||
["read", "write"],
|
["read", "write"],
|
||||||
{
|
{
|
||||||
admin: {
|
admin: {
|
||||||
@@ -16,14 +38,14 @@ describe("authorize", () => {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBeUndefined();
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBeUndefined();
|
||||||
|
|
||||||
expect(() => guard.granted("something")).toThrow();
|
expect(() => guard.granted(new Permission("something"), {})).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with default", async () => {
|
test("with default", async () => {
|
||||||
const guard = Guard.create(
|
const guard = createGuard(
|
||||||
["read", "write"],
|
["read", "write"],
|
||||||
{
|
{
|
||||||
admin: {
|
admin: {
|
||||||
@@ -37,26 +59,26 @@ describe("authorize", () => {
|
|||||||
{ enabled: true },
|
{ enabled: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read, {})).toBeUndefined();
|
||||||
expect(guard.granted("write")).toBe(false);
|
expect(() => guard.granted(write, {})).toThrow();
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBeUndefined();
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard implicit allow", async () => {
|
test("guard implicit allow", async () => {
|
||||||
const guard = Guard.create([], {}, { enabled: false });
|
const guard = createGuard([], {}, { enabled: false });
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read, {})).toBeUndefined();
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted(write, {})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("role implicit allow", async () => {
|
test("role implicit allow", async () => {
|
||||||
const guard = Guard.create(["read", "write"], {
|
const guard = createGuard(["read", "write"], {
|
||||||
admin: {
|
admin: {
|
||||||
implicit_allow: true,
|
implicit_allow: true,
|
||||||
},
|
},
|
||||||
@@ -66,12 +88,12 @@ describe("authorize", () => {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBeUndefined();
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard with guest role implicit allow", async () => {
|
test("guard with guest role implicit allow", async () => {
|
||||||
const guard = Guard.create(["read", "write"], {
|
const guard = createGuard(["read", "write"], {
|
||||||
guest: {
|
guest: {
|
||||||
implicit_allow: true,
|
implicit_allow: true,
|
||||||
is_default: true,
|
is_default: true,
|
||||||
@@ -79,7 +101,143 @@ describe("authorize", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(guard.getUserRole()?.name).toBe("guest");
|
expect(guard.getUserRole()?.name).toBe("guest");
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read, {})).toBeUndefined();
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted(write, {})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { SystemController } from "modules/server/SystemController";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
|
import type { CreateAppConfig } from "App";
|
||||||
|
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
|
||||||
|
|
||||||
|
async function makeApp(config: Partial<CreateAppConfig> = {}) {
|
||||||
|
const app = createApp(config);
|
||||||
|
await app.build();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skip("SystemController", () => {
|
||||||
|
it("...", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
const controller = new SystemController(app);
|
||||||
|
const hono = controller.getController();
|
||||||
|
console.log(getPermissionRoutes(hono));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,4 +66,14 @@ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,6 +194,182 @@ 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 () => {
|
||||||
@@ -264,6 +440,35 @@ 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", () => {
|
||||||
|
|||||||
@@ -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" = ? limit ?',
|
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?',
|
||||||
);
|
);
|
||||||
expect(query.parameters).toEqual([1, 1]);
|
expect(query.parameters).toEqual([1, 1, 0]);
|
||||||
expect(query.data).toBeUndefined();
|
expect(query.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, spyOn, 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,4 +268,39 @@ 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 */
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")).toBe('{\n "test": 1\n}');
|
expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
|
||||||
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,6 +43,5 @@ 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,6 +1,6 @@
|
|||||||
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/auth/middlewares";
|
import { auth } from "../../src/modules/middlewares";
|
||||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
import { 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";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper";
|
|||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
disableConsoleLog();
|
//disableConsoleLog();
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
});
|
});
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -94,4 +94,38 @@ 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ 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) {
|
||||||
@@ -88,6 +90,9 @@ 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) {
|
||||||
@@ -102,4 +107,36 @@ 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { type InitialModuleConfigs, createApp } from "../../../src";
|
import { App, type InitialModuleConfigs, createApp } from "/";
|
||||||
|
|
||||||
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(config: InitialModuleConfigs | any) {
|
async function createVersionedApp(
|
||||||
|
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");
|
||||||
@@ -38,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
|||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
if (opts?.beforeCreateApp) {
|
||||||
|
await opts.beforeCreateApp(db);
|
||||||
|
}
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
});
|
});
|
||||||
@@ -45,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
|||||||
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
|
||||||
@@ -82,4 +103,30 @@ 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^^",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,612 @@
|
|||||||
|
{
|
||||||
|
"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": {} }
|
||||||
|
}
|
||||||
+1
-21
@@ -1,29 +1,9 @@
|
|||||||
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", ...deps];
|
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...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"],
|
||||||
|
|||||||
+20
-3
@@ -1,3 +1,4 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noConsole: . */
|
||||||
import { $ } from "bun";
|
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" };
|
||||||
@@ -85,12 +86,18 @@ async function buildApi() {
|
|||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
define,
|
define,
|
||||||
entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"],
|
entry: [
|
||||||
|
"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: {
|
||||||
@@ -223,6 +230,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
|||||||
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"));
|
||||||
@@ -260,7 +268,7 @@ async function buildAdapters() {
|
|||||||
tsup.build(baseConfig("react-router")),
|
tsup.build(baseConfig("react-router")),
|
||||||
tsup.build(
|
tsup.build(
|
||||||
baseConfig("bun", {
|
baseConfig("bun", {
|
||||||
external: [/^bun\:.*/],
|
external: [/^bun:.*/],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
tsup.build(baseConfig("astro")),
|
tsup.build(baseConfig("astro")),
|
||||||
@@ -272,12 +280,21 @@ 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,
|
||||||
external: [/bknd/, "wrangler", "node:process"],
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
tsup.build(
|
||||||
|
baseConfig("cloudflare/vite", {
|
||||||
|
target: "esnext",
|
||||||
|
entry: ["src/adapter/cloudflare/vite.ts"],
|
||||||
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("vite"),
|
||||||
@@ -314,7 +331,7 @@ async function buildAdapters() {
|
|||||||
entry: ["src/adapter/sqlite/bun.ts"],
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
outDir: "dist/adapter/sqlite",
|
outDir: "dist/adapter/sqlite",
|
||||||
metafile: false,
|
metafile: false,
|
||||||
external: [/^bun\:.*/],
|
external: [/^bun:.*/],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[install]
|
|
||||||
#registry = "http://localhost:4873"
|
|
||||||
|
|
||||||
[test]
|
|
||||||
coverageSkipTestFiles = true
|
|
||||||
console.depth = 10
|
|
||||||
@@ -3,11 +3,14 @@ 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: "/mcp",
|
path: "/mcp2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -25,9 +28,9 @@ async function generate() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
|
|
||||||
const res = await app.server.request("/mcp?explain=1");
|
const { tools, resources } = app.mcp!.toJSON();
|
||||||
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.");
|
||||||
|
|||||||
+66
-51
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.18.0-rc.4",
|
"version": "0.19.0",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -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.2.19",
|
"packageManager": "bun@1.3.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.13"
|
"node": ">=22.13"
|
||||||
},
|
},
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"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": "bun run e2e/adapters.ts",
|
"test:e2e:adapters": "NODE_NO_WARNINGS=1 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",
|
||||||
@@ -49,93 +49,98 @@
|
|||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hono/swagger-ui": "^0.5.1",
|
"@hono/swagger-ui": "^0.5.2",
|
||||||
"@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.23.10",
|
"@uiw/react-codemirror": "^4.25.2",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.9.2",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.19",
|
||||||
"fast-xml-parser": "^5.0.8",
|
"fast-xml-parser": "^5.3.1",
|
||||||
"hono": "4.8.3",
|
"hono": "4.10.4",
|
||||||
"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.8.2",
|
"jsonv-ts": "0.9.3",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.28.8",
|
||||||
"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",
|
||||||
"radix-ui": "^1.1.3",
|
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"swr": "^2.3.3"
|
"radix-ui": "^1.1.3",
|
||||||
|
"swr": "^2.3.6",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
|
"zustand": "^4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.922.0",
|
||||||
"@bluwy/giget-core": "^0.1.2",
|
"@bluwy/giget-core": "^0.1.6",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@cloudflare/vitest-pool-workers": "^0.9.3",
|
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||||
"@cloudflare/workers-types": "^4.20250606.0",
|
"@cloudflare/workers-types": "^4.20251014.0",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hono/vite-dev-server": "^0.21.0",
|
"@hono/vite-dev-server": "^0.23.0",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@libsql/client": "^0.15.9",
|
"@libsql/client": "^0.15.15",
|
||||||
"@mantine/modals": "^7.17.1",
|
"@mantine/modals": "^7.17.1",
|
||||||
"@mantine/notifications": "^7.17.1",
|
"@mantine/notifications": "^7.17.1",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.56.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.18.0",
|
"@tabler/icons-react": "3.35.0",
|
||||||
"@tailwindcss/postcss": "^4.0.12",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@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": "^22.13.10",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"@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",
|
||||||
"dotenv": "^16.4.7",
|
"commander": "^14.0.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.1.0",
|
||||||
"kysely-d1": "^0.3.0",
|
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
|
"libsql": "^0.5.22",
|
||||||
"libsql-stateless-easy": "^1.8.0",
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
"open": "^10.1.0",
|
"miniflare": "^4.20251011.2",
|
||||||
|
"open": "^10.2.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"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.54.2",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-icons": "5.2.1",
|
"react-icons": "5.5.0",
|
||||||
"react-json-view-lite": "^2.4.1",
|
"react-json-view-lite": "^2.5.0",
|
||||||
"sql-formatter": "^15.4.11",
|
"sql-formatter": "^15.6.10",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.0.12",
|
"tailwindcss": "^4.1.16",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsc-alias": "^1.8.11",
|
"tsc-alias": "^1.8.16",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.5.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.20.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^13.0.0",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.12",
|
||||||
"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.6.0",
|
"wouter": "^3.7.1",
|
||||||
"wrangler": "^4.37.1",
|
"wrangler": "^4.45.4"
|
||||||
"miniflare": "^4.20250913.0"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.14.3"
|
"@hono/node-server": "^1.19.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=19",
|
"react": ">=19",
|
||||||
@@ -180,6 +185,11 @@
|
|||||||
"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": {
|
||||||
@@ -204,6 +214,11 @@
|
|||||||
"import": "./dist/adapter/cloudflare/proxy.js",
|
"import": "./dist/adapter/cloudflare/proxy.js",
|
||||||
"require": "./dist/adapter/cloudflare/proxy.js"
|
"require": "./dist/adapter/cloudflare/proxy.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/cloudflare/vite": {
|
||||||
|
"types": "./dist/types/adapter/cloudflare/vite.d.ts",
|
||||||
|
"import": "./dist/adapter/cloudflare/vite.js",
|
||||||
|
"require": "./dist/adapter/cloudflare/vite.js"
|
||||||
|
},
|
||||||
"./adapter": {
|
"./adapter": {
|
||||||
"types": "./dist/types/adapter/index.d.ts",
|
"types": "./dist/types/adapter/index.d.ts",
|
||||||
"import": "./dist/adapter/index.js"
|
"import": "./dist/adapter/index.js"
|
||||||
|
|||||||
+46
-32
@@ -40,6 +40,7 @@ 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;
|
||||||
@@ -67,7 +68,7 @@ export class Api {
|
|||||||
public auth!: AuthApi;
|
public auth!: AuthApi;
|
||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private options: ApiOptions = {}) {
|
constructor(public options: ApiOptions = {}) {
|
||||||
// only mark verified if forced
|
// only mark verified if forced
|
||||||
this.verified = options.verified === true;
|
this.verified = options.verified === true;
|
||||||
|
|
||||||
@@ -129,29 +130,45 @@ 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() {
|
||||||
if (!this.options.storage) return null;
|
const storage = this.options.storage;
|
||||||
return {
|
return new Proxy(
|
||||||
getItem: async (key: string) => {
|
{},
|
||||||
return await this.options.storage!.getItem(key);
|
{
|
||||||
|
get(_, prop) {
|
||||||
|
return (...args: any[]) => {
|
||||||
|
const response = storage ? storage[prop](...args) : undefined;
|
||||||
|
if (response instanceof Promise) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
|
||||||
|
then: (fn) => fn(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setItem: async (key: string, value: string) => {
|
) as any;
|
||||||
return await this.options.storage!.setItem(key, value);
|
|
||||||
},
|
|
||||||
removeItem: async (key: string) => {
|
|
||||||
return await this.options.storage!.removeItem(key);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
updateToken(
|
||||||
|
token?: string,
|
||||||
|
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
|
||||||
|
) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.verified = false;
|
this.verified = opts?.verified === true;
|
||||||
|
|
||||||
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;
|
||||||
@@ -159,21 +176,22 @@ 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(() => {
|
this.storage.setItem(key, token).then(emit);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.storage.removeItem(key).then(() => {
|
this.storage.removeItem(key).then(emit);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (opts?.trigger !== false) {
|
if (opts?.trigger !== false) {
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +200,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +227,6 @@ 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;
|
||||||
@@ -221,10 +235,10 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.markAuthVerified(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.markAuthVerified(false);
|
|
||||||
this.updateToken(undefined);
|
this.updateToken(undefined);
|
||||||
|
} finally {
|
||||||
|
this.markAuthVerified(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +253,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,10 +272,9 @@ 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) => {
|
onTokenUpdate: (token, verified) => {
|
||||||
this.updateToken(token, { rebuild: true });
|
this.updateToken(token, { rebuild: true, verified, trigger: true });
|
||||||
this.options.auth?.onTokenUpdate?.(token);
|
this.options.auth?.onTokenUpdate?.(token);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-5
@@ -5,7 +5,6 @@ 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,
|
||||||
@@ -244,6 +243,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +294,7 @@ 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) {
|
||||||
@@ -302,13 +305,14 @@ export class App<
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMcpClient() {
|
getMcpClient() {
|
||||||
if (!this.mcp) {
|
const config = this.modules.get("server").config.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: "http://localhost" + mcpPath,
|
url,
|
||||||
fetch: this.server.request,
|
fetch: this.server.request,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -376,11 +380,14 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ 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 = {} as Env,
|
args: Env = import.meta.env as Env,
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args ?? import.meta.env);
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
|
export function serve<Env = AstroEnv>(
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
/// <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> & Omit<ServeOptions, "fetch">;
|
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> &
|
||||||
|
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 = {} as Env,
|
args: Env = Bun.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();
|
||||||
@@ -26,18 +24,18 @@ export async function createApp<Env = BunEnv>(
|
|||||||
}),
|
}),
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
args ?? (process.env as Env),
|
args,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHandler<Env = BunEnv>(
|
export function createHandler<Env = BunEnv>(
|
||||||
config: BunBkndConfig<Env> = {},
|
config: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = Bun.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 ?? (process.env as Env));
|
app = await createApp(config, args);
|
||||||
}
|
}
|
||||||
return app.fetch(req);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
@@ -54,12 +52,13 @@ export function serve<Env = BunEnv>(
|
|||||||
buildConfig,
|
buildConfig,
|
||||||
adminOptions,
|
adminOptions,
|
||||||
serveStatic,
|
serveStatic,
|
||||||
|
beforeBuild,
|
||||||
...serveOptions
|
...serveOptions
|
||||||
}: BunBkndConfig<Env> = {},
|
}: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = Bun.env as Env,
|
||||||
) {
|
) {
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
...serveOptions,
|
...(serveOptions as any),
|
||||||
port,
|
port,
|
||||||
fetch: createHandler(
|
fetch: createHandler(
|
||||||
{
|
{
|
||||||
@@ -71,6 +70,7 @@ export function serve<Env = BunEnv>(
|
|||||||
adminOptions,
|
adminOptions,
|
||||||
distPath,
|
distPath,
|
||||||
serveStatic,
|
serveStatic,
|
||||||
|
beforeBuild,
|
||||||
},
|
},
|
||||||
args,
|
args,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { inspect } from "node:util";
|
|
||||||
|
|
||||||
export type BindingTypeMap = {
|
export type BindingTypeMap = {
|
||||||
D1Database: D1Database;
|
D1Database: D1Database;
|
||||||
KVNamespace: KVNamespace;
|
KVNamespace: KVNamespace;
|
||||||
@@ -10,21 +8,40 @@ export type BindingTypeMap = {
|
|||||||
export type GetBindingType = keyof BindingTypeMap;
|
export type GetBindingType = keyof BindingTypeMap;
|
||||||
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
||||||
|
|
||||||
|
export const bindingMatchers = {
|
||||||
|
// D1Database instance doesn't stringify to [object D1Database] (yet)
|
||||||
|
D1Database: (value: object): value is D1Database => {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
if (String(value) === "[object D1Database]") return true;
|
||||||
|
if (Object.keys(value).length !== 2) return false;
|
||||||
|
return ["alwaysPrimarySession", "fetcher"].every((key) => key in value);
|
||||||
|
},
|
||||||
|
KVNamespace: (value: object): value is KVNamespace =>
|
||||||
|
String(value) === "[object KvNamespace]" && Object.keys(value).length === 0,
|
||||||
|
DurableObjectNamespace: (value: object): value is DurableObjectNamespace =>
|
||||||
|
String(value) === "[object DurableObjectNamespace]" && Object.keys(value).length === 0,
|
||||||
|
R2Bucket: (value: object): value is R2Bucket =>
|
||||||
|
String(value) === "[object R2Bucket]" && Object.keys(value).length === 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
||||||
const bindings: BindingMap<T>[] = [];
|
const bindings: BindingMap<T>[] = [];
|
||||||
|
if (!(type in bindingMatchers)) {
|
||||||
|
console.error(`No binding matcher for type ${type}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matcher = bindingMatchers[type];
|
||||||
|
|
||||||
for (const key in env) {
|
for (const key in env) {
|
||||||
try {
|
const value = env[key];
|
||||||
if (
|
if (typeof value !== "object" || value === null) continue;
|
||||||
(env[key] as any).constructor.name === type ||
|
if (!matcher(value)) continue;
|
||||||
String(env[key]) === `[object ${type}]` ||
|
|
||||||
inspect(env[key]).includes(type)
|
bindings.push({
|
||||||
) {
|
key,
|
||||||
bindings.push({
|
value,
|
||||||
key,
|
} as any);
|
||||||
value: env[key] as BindingTypeMap[T],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, afterAll, test, expect, beforeAll } from "vitest";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
import { getBindings, type GetBindingType } from "./bindings";
|
||||||
|
|
||||||
|
let mf: Miniflare | undefined;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
script: `import { DurableObject } from "cloudflare:workers";
|
||||||
|
export default { async fetch() { return new Response(null); } }
|
||||||
|
export class TestObject extends DurableObject {}
|
||||||
|
export class TestObject2 extends DurableObject {}`,
|
||||||
|
r2Buckets: ["BUCKET"],
|
||||||
|
d1Databases: ["DB"],
|
||||||
|
kvNamespaces: ["KV"],
|
||||||
|
durableObjects: {
|
||||||
|
DO_SQLITE: { className: "TestObject", useSQLite: true },
|
||||||
|
DO_KV: { className: "TestObject2" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
mf?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bindings", () => {
|
||||||
|
test("extracts", async () => {
|
||||||
|
const env = {
|
||||||
|
...(await mf!.getBindings()),
|
||||||
|
some: "string",
|
||||||
|
another: 123,
|
||||||
|
array: [1, 2, 3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const $getBindingKeys = (type: GetBindingType) => {
|
||||||
|
const bindings = getBindings(env, type);
|
||||||
|
const keys = bindings?.map((b) => b.key);
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
console.error(`No ${type} binding found`, { bindings });
|
||||||
|
throw new Error(`No ${type} binding found`);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect($getBindingKeys("D1Database")).toEqual(["DB"]);
|
||||||
|
expect($getBindingKeys("KVNamespace")).toEqual(["KV"]);
|
||||||
|
expect($getBindingKeys("DurableObjectNamespace")).toEqual(["DO_SQLITE", "DO_KV"]);
|
||||||
|
expect($getBindingKeys("R2Bucket")).toEqual(["BUCKET"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:";
|
||||||
|
|||||||
@@ -37,19 +37,19 @@ 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(
|
const appConfig = await makeConfig(config, ctx);
|
||||||
|
return await createRuntimeApp<Env>(
|
||||||
{
|
{
|
||||||
...config,
|
...appConfig,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
if (ctx.ctx) {
|
if (ctx.ctx) {
|
||||||
registerAsyncsExecutionContext(app, ctx?.ctx);
|
registerAsyncsExecutionContext(app, ctx?.ctx);
|
||||||
}
|
}
|
||||||
await config.onBuilt?.(app);
|
await appConfig.onBuilt?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ctx,
|
ctx?.env,
|
||||||
);
|
);
|
||||||
return await createRuntimeApp<Env>(appConfig, ctx?.env);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// compatiblity
|
// compatiblity
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
export { constants, type CloudflareContext } from "./config";
|
export {
|
||||||
|
constants,
|
||||||
|
makeConfig,
|
||||||
|
type CloudflareContext,
|
||||||
|
registerAsyncsExecutionContext,
|
||||||
|
} 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";
|
|
||||||
|
|
||||||
// for compatibility with old code
|
// for compatibility with old code
|
||||||
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ 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,
|
||||||
@@ -31,7 +54,6 @@ 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();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function registerMedia(
|
|||||||
env: Record<string, any>,
|
env: Record<string, any>,
|
||||||
registries: typeof $registries = $registries,
|
registries: typeof $registries = $registries,
|
||||||
) {
|
) {
|
||||||
|
if (registries.media.has("r2")) return;
|
||||||
const r2_bindings = getBindings(env, "R2Bucket");
|
const r2_bindings = getBindings(env, "R2Bucket");
|
||||||
|
|
||||||
registries.media.register(
|
registries.media.register(
|
||||||
@@ -49,6 +50,8 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -170,15 +173,18 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
await this.bucket.delete(this.getKey(key));
|
await this.bucket.delete(this.getKey(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
getObjectUrl(key: string): string {
|
getObjectUrl(): string {
|
||||||
throw new Error("Method getObjectUrl not implemented.");
|
throw new Error("Method getObjectUrl not implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getKey(key: string) {
|
protected getKey(key: string) {
|
||||||
|
if (this.keyPrefix.length > 0) {
|
||||||
|
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
|
||||||
|
}
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
type: this.getName(),
|
type: this.getName(),
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ 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" });
|
||||||
|
|
||||||
await adapterTestSuite(viTestRunner, adapter, file);
|
// miniflare doesn't support range requests
|
||||||
|
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// biome-ignore-all lint: .
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { writeFile as nodeWriteFile } from "node:fs/promises";
|
import { writeFile as nodeWriteFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
@@ -48,7 +49,6 @@ export function devFsVitePlugin({
|
|||||||
// Skip our own debug output
|
// Skip our own debug output
|
||||||
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// biome-ignore lint/style/noArguments: <explanation>
|
|
||||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,6 @@ export function devFsVitePlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// biome-ignore lint:
|
|
||||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+37
-21
@@ -6,18 +6,23 @@ 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> = CreateAppConfig & {
|
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||||
app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
|
CreateAppConfig & {
|
||||||
onBuilt?: (app: App) => MaybePromise<void>;
|
app?:
|
||||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
| Merge<Omit<BkndConfig, "app"> & Additional>
|
||||||
buildConfig?: Parameters<App["build"]>[0];
|
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
|
||||||
};
|
onBuilt?: (app: App) => MaybePromise<void>;
|
||||||
|
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||||
|
buildConfig?: Parameters<App["build"]>[0];
|
||||||
|
} & Additional
|
||||||
|
>;
|
||||||
|
|
||||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||||
|
|
||||||
@@ -51,11 +56,10 @@ 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> {
|
): Promise<{ app: App; config: BkndConfig<Args> }> {
|
||||||
await config.beforeBuild?.(undefined, $registries);
|
await config.beforeBuild?.(undefined, $registries);
|
||||||
|
|
||||||
const appConfig = await makeConfig(config, args);
|
const appConfig = await makeConfig(config, args);
|
||||||
@@ -65,34 +69,37 @@ 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: ":memory:" };
|
const conf = appConfig.connection ?? { url: "file:data.db" };
|
||||||
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 App.create(appConfig);
|
return {
|
||||||
|
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 = await createAdapterApp(config, args);
|
const { app, config: appConfig } = 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 config.onBuilt?.(app);
|
await appConfig.onBuilt?.(app);
|
||||||
},
|
},
|
||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.beforeBuild?.(app, $registries);
|
await appConfig.beforeBuild?.(app, $registries);
|
||||||
await app.build(config.buildConfig);
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +110,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 = await createAdapterApp(config, args);
|
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||||
|
|
||||||
if (!app.isBuilt()) {
|
if (!app.isBuilt()) {
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
@@ -116,7 +123,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
|||||||
app.modules.server.get(path, handler);
|
app.modules.server.get(path, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.onBuilt?.(app);
|
await appConfig.onBuilt?.(app);
|
||||||
if (adminOptions !== false) {
|
if (adminOptions !== false) {
|
||||||
app.registerAdminController(adminOptions);
|
app.registerAdminController(adminOptions);
|
||||||
}
|
}
|
||||||
@@ -124,7 +131,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
|||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
|
|
||||||
await config.beforeBuild?.(app, $registries);
|
await appConfig.beforeBuild?.(app, $registries);
|
||||||
await app.build(config.buildConfig);
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,23 +154,32 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
export function serveStaticViaImport(opts?: {
|
||||||
|
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" } }))
|
((
|
||||||
.default as Manifest);
|
await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, {
|
||||||
|
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 content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`;
|
||||||
|
const content = await import(/* @vite-ignore */ url, {
|
||||||
with: { type: "text" },
|
with: { type: "text" },
|
||||||
}).then((m) => m.default);
|
}).then((m) => m.default);
|
||||||
|
|
||||||
@@ -176,7 +192,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error serving static file:", e);
|
console.error(`Error serving static file "${path}":`, String(e));
|
||||||
return c.text("File not found", 404);
|
return c.text("File not found", 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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"];
|
||||||
@@ -9,16 +10,18 @@ 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 = {} as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args ?? (process.env as Env));
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => url.searchParams.delete(k));
|
cleanRequest?.searchParams?.forEach((k) => {
|
||||||
|
url.searchParams.delete(k);
|
||||||
|
});
|
||||||
|
|
||||||
if (isNode()) {
|
if (isNode()) {
|
||||||
return new Request(url.toString(), {
|
return new Request(url.toString(), {
|
||||||
@@ -39,7 +42,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 = {} as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
const app = await getApp(config, args);
|
const app = await getApp(config, args);
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ 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 = {} as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
const root = path.relative(
|
const root = path.relative(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
@@ -33,19 +33,18 @@ export async function createApp<Env = NodeEnv>(
|
|||||||
serveStatic: serveStatic({ root }),
|
serveStatic: serveStatic({ root }),
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
args,
|
||||||
args ?? { env: process.env },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHandler<Env = NodeEnv>(
|
export function createHandler<Env = NodeEnv>(
|
||||||
config: NodeBkndConfig<Env> = {},
|
config: NodeBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = process.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 ?? (process.env as Env));
|
app = await createApp(config, args);
|
||||||
}
|
}
|
||||||
return app.fetch(req);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
@@ -53,7 +52,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 = {} as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
honoServe(
|
honoServe(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,18 +80,79 @@ 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 content = await readFile(`${this.config.path}/${key}`);
|
const filePath = `${this.config.path}/${key}`;
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
const fileSize = stats.size;
|
||||||
const mimeType = guessMimeType(key);
|
const mimeType = guessMimeType(key);
|
||||||
|
|
||||||
return new Response(content, {
|
const responseHeaders = new Headers({
|
||||||
status: 200,
|
"Accept-Ranges": "bytes",
|
||||||
headers: {
|
"Content-Type": mimeType || "application/octet-stream",
|
||||||
"Content-Type": mimeType || "application/octet-stream",
|
|
||||||
"Content-Length": content.length.toString(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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, {
|
||||||
|
status: 200,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
} 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 = {} as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args ?? process.env);
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<Env = ReactRouterEnv>(
|
export function serve<Env = ReactRouterEnv>(
|
||||||
config: ReactRouterBkndConfig<Env> = {},
|
config: ReactRouterBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = process.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);
|
||||||
|
|||||||
+17
-2
@@ -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 } from "bknd/utils";
|
import { $console, secureRandomString, transformObject, pickKeys } 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";
|
||||||
@@ -61,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 ?? {});
|
||||||
@@ -113,6 +113,19 @@ 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!;
|
||||||
@@ -210,10 +223,12 @@ 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),
|
||||||
|
|||||||
+11
-10
@@ -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) => void | Promise<void>;
|
onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise<void>;
|
||||||
credentials?: "include" | "same-origin" | "omit";
|
credentials?: "include" | "same-origin" | "omit";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,23 +17,19 @@ 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);
|
await this.options.onTokenUpdate?.(res.body.token, true);
|
||||||
}
|
}
|
||||||
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);
|
await this.options.onTokenUpdate?.(res.body.token, true);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
await this.options.onTokenUpdate?.(undefined);
|
return this.get(["logout"], undefined, {
|
||||||
|
headers: {
|
||||||
|
// this way bknd detects a json request and doesn't redirect back
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
}).then(() => this.options.onTokenUpdate?.(undefined, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export class AuthController extends Controller {
|
|||||||
if (create) {
|
if (create) {
|
||||||
hono.post(
|
hono.post(
|
||||||
"/create",
|
"/create",
|
||||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
permission(AuthPermissions.createUser, {}),
|
||||||
|
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"],
|
||||||
@@ -223,7 +226,6 @@ export class AuthController extends Controller {
|
|||||||
|
|
||||||
const roles = Object.keys(this.auth.config.roles ?? {});
|
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||||
mcp.tool(
|
mcp.tool(
|
||||||
// @todo: needs permission
|
|
||||||
"auth_user_create",
|
"auth_user_create",
|
||||||
{
|
{
|
||||||
description: "Create a new user",
|
description: "Create a new user",
|
||||||
@@ -238,14 +240,13 @@ export class AuthController extends Controller {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params, c) => {
|
async (params, c) => {
|
||||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
|
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||||
|
|
||||||
return c.json(await this.auth.createUser(params));
|
return c.json(await this.auth.createUser(params));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
mcp.tool(
|
mcp.tool(
|
||||||
// @todo: needs permission
|
|
||||||
"auth_user_token",
|
"auth_user_token",
|
||||||
{
|
{
|
||||||
description: "Get a user token",
|
description: "Get a user token",
|
||||||
@@ -255,7 +256,7 @@ export class AuthController extends Controller {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params, c) => {
|
async (params, c) => {
|
||||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
|
await c.context.ctx().helper.granted(c, AuthPermissions.createToken);
|
||||||
|
|
||||||
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) });
|
||||||
@@ -263,7 +264,6 @@ 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",
|
||||||
@@ -274,7 +274,7 @@ export class AuthController extends Controller {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params, c) => {
|
async (params, c) => {
|
||||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
|
await c.context.ctx().helper.granted(c, AuthPermissions.changePassword);
|
||||||
|
|
||||||
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))) {
|
||||||
@@ -285,7 +285,6 @@ 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",
|
||||||
@@ -295,7 +294,7 @@ export class AuthController extends Controller {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params, c) => {
|
async (params, c) => {
|
||||||
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
|
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
|
||||||
|
|
||||||
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,4 +1,4 @@
|
|||||||
import { Permission } from "core/security/Permission";
|
import { Permission } from "auth/authorize/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");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { objectTransform, s } from "bknd/utils";
|
import { roleSchema } from "auth/authorize/Role";
|
||||||
|
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 = {
|
||||||
@@ -40,11 +41,8 @@ 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({
|
|
||||||
permissions: s.array(s.string()).optional(),
|
export const guardRoleSchema = roleSchema;
|
||||||
is_default: s.boolean().optional(),
|
|
||||||
implicit_allow: s.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const authConfigSchema = $object(
|
export const authConfigSchema = $object(
|
||||||
"config_auth",
|
"config_auth",
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ 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 } from "bknd/utils";
|
import { s, parse, secret, runtimeSupports, truncate, $console, pickKeys } 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
|
||||||
@@ -44,6 +42,7 @@ 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 }),
|
||||||
@@ -229,7 +228,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 = pick(_user, this.config.jwt.fields);
|
const user = pickKeys(_user, this.config.jwt.fields as any);
|
||||||
|
|
||||||
const payload: JWTPayload = {
|
const payload: JWTPayload = {
|
||||||
...user,
|
...user,
|
||||||
@@ -255,7 +254,7 @@ export class Authenticator<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async safeAuthResponse(_user: User): Promise<AuthResponse> {
|
async safeAuthResponse(_user: User): Promise<AuthResponse> {
|
||||||
const user = pick(_user, this.config.jwt.fields) as SafeUser;
|
const user = pickKeys(_user, this.config.jwt.fields as any) as SafeUser;
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
token: await this.jwt(user),
|
token: await this.jwt(user),
|
||||||
@@ -290,6 +289,7 @@ 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -327,6 +327,31 @@ 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);
|
||||||
@@ -354,7 +379,10 @@ 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 c.req.header("Content-Type") === "application/json";
|
return (
|
||||||
|
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 } from "bknd/utils";
|
import { hash, $console, s, parse, jsc, describeRoute } 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";
|
||||||
@@ -84,51 +84,67 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
});
|
});
|
||||||
const payloadSchema = this.getPayloadSchema();
|
const payloadSchema = this.getPayloadSchema();
|
||||||
|
|
||||||
hono.post("/login", jsc("query", redirectQuerySchema), async (c) => {
|
hono.post(
|
||||||
try {
|
"/login",
|
||||||
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
describeRoute({
|
||||||
onError: (errors) => {
|
summary: "Login with email and password",
|
||||||
$console.error("Invalid login payload", [...errors]);
|
tags: ["auth"],
|
||||||
throw new InvalidCredentialsException();
|
}),
|
||||||
},
|
jsc("query", redirectQuerySchema),
|
||||||
});
|
async (c) => {
|
||||||
const { redirect } = c.req.valid("query");
|
try {
|
||||||
|
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||||
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
|
|
||||||
redirect,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return authenticator.respondWithError(c, e as any);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
|
|
||||||
try {
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
const { password, email, ...body } = parse(
|
|
||||||
payloadSchema,
|
|
||||||
await authenticator.getBody(c),
|
|
||||||
{
|
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
$console.error("Invalid register payload", [...errors]);
|
$console.error("Invalid login payload", [...errors]);
|
||||||
new InvalidCredentialsException();
|
throw new InvalidCredentialsException();
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
const profile = {
|
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
|
||||||
...body,
|
redirect,
|
||||||
email,
|
});
|
||||||
strategy_value: await this.hash(password),
|
} catch (e) {
|
||||||
};
|
return authenticator.respondWithError(c, e as any);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
hono.post(
|
||||||
redirect,
|
"/register",
|
||||||
});
|
describeRoute({
|
||||||
} catch (e) {
|
summary: "Register a new user with email and password",
|
||||||
return authenticator.respondWithError(c, e as any);
|
tags: ["auth"],
|
||||||
}
|
}),
|
||||||
});
|
jsc("query", redirectQuerySchema),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
const { password, email, ...body } = parse(
|
||||||
|
payloadSchema,
|
||||||
|
await authenticator.getBody(c),
|
||||||
|
{
|
||||||
|
onError: (errors) => {
|
||||||
|
$console.error("Invalid register payload", [...errors]);
|
||||||
|
new InvalidCredentialsException();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
...body,
|
||||||
|
email,
|
||||||
|
strategy_value: await this.hash(password),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
||||||
|
redirect,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return authenticator.respondWithError(c, e as any);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|||||||
+248
-79
@@ -1,9 +1,12 @@
|
|||||||
import { Exception } from "core/errors";
|
import { Exception } from "core/errors";
|
||||||
import { $console, objectTransform } from "bknd/utils";
|
import { $console, mergeObject, type s } from "bknd/utils";
|
||||||
import { Permission } from "core/security/Permission";
|
import type { Permission, PermissionContext } from "auth/authorize/Permission";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
import { Role } from "./Role";
|
import type { 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;
|
||||||
@@ -12,41 +15,43 @@ 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 Guard {
|
export class GuardPermissionsException extends Exception {
|
||||||
permissions: Permission[];
|
override name = "PermissionsException";
|
||||||
roles?: Role[];
|
override code = HttpStatus.FORBIDDEN;
|
||||||
config?: GuardConfig;
|
|
||||||
|
|
||||||
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
|
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 {
|
||||||
|
constructor(
|
||||||
|
public permissions: Permission<any, any, any, any>[] = [],
|
||||||
|
public roles: Role[] = [],
|
||||||
|
public 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);
|
||||||
}
|
}
|
||||||
@@ -73,7 +78,7 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPermission(permission: Permission) {
|
registerPermission(permission: Permission<any, any, any, any>) {
|
||||||
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`);
|
||||||
}
|
}
|
||||||
@@ -82,9 +87,13 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPermissions(permissions: Record<string, Permission>);
|
registerPermissions(permissions: Record<string, Permission<any, any, any, any>>);
|
||||||
registerPermissions(permissions: Permission[]);
|
registerPermissions(permissions: Permission<any, any, any, any>[]);
|
||||||
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
|
registerPermissions(
|
||||||
|
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) {
|
||||||
@@ -117,56 +126,216 @@ export class Guard {
|
|||||||
return this.config?.enabled === true;
|
return this.config?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
|
private collect(permission: Permission, c: GuardContext | undefined, context: any) {
|
||||||
hasPermission(name: string, user?: GuardUserContext): boolean;
|
|
||||||
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
|
||||||
if (!this.isEnabled()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
|
||||||
$console.debug("guard: checking permission", {
|
|
||||||
name,
|
|
||||||
user: { id: user?.id, role: user?.role },
|
|
||||||
});
|
|
||||||
const exists = this.permissionExists(name);
|
|
||||||
if (!exists) {
|
|
||||||
throw new Error(`Permission ${name} does not exist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = this.getUserRole(user);
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
$console.debug("guard: user has no role, denying");
|
|
||||||
return false;
|
|
||||||
} else if (role.implicit_allow === true) {
|
|
||||||
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rolePermission = role.permissions.find(
|
|
||||||
(rolePermission) => rolePermission.permission.name === name,
|
|
||||||
);
|
|
||||||
|
|
||||||
$console.debug("guard: rolePermission, allowing?", {
|
|
||||||
permission: name,
|
|
||||||
role: role.name,
|
|
||||||
allowing: !!rolePermission,
|
|
||||||
});
|
|
||||||
return !!rolePermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
granted(permission: Permission | string, c?: GuardContext): boolean {
|
|
||||||
const user = c && "get" in c ? c.get("auth")?.user : c;
|
const user = c && "get" in c ? c.get("auth")?.user : c;
|
||||||
return this.hasPermission(permission as any, user);
|
const ctx = {
|
||||||
|
...((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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
|
granted<P extends Permission<any, any, any, any>>(
|
||||||
if (!this.granted(permission, c)) {
|
permission: P,
|
||||||
throw new Exception(
|
c: GuardContext,
|
||||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
context: PermissionContext<P>,
|
||||||
403,
|
): 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()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$console.debug("guard: checking permission", {
|
||||||
|
name: permission.name,
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
|
if (!exists) {
|
||||||
|
throw new GuardPermissionsException(
|
||||||
|
permission,
|
||||||
|
undefined,
|
||||||
|
`Permission ${permission.name} does not exist`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new GuardPermissionsException(permission, undefined, "User has no role");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 true;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) {
|
||||||
|
const base_converted = convert(base);
|
||||||
|
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
|
||||||
|
if ("$or" in base_converted && base_converted.$or) {
|
||||||
|
const $ors = base_converted.$or as ObjectQuery;
|
||||||
|
const priority_keys = Object.keys(priority_converted);
|
||||||
|
for (const key of priority_keys) {
|
||||||
|
if (key in $ors) {
|
||||||
|
merged.$or[key] = mergeObject($ors[key], priority_converted[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,39 @@
|
|||||||
import { Permission } from "core/security/Permission";
|
import { s } from "bknd/utils";
|
||||||
|
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,
|
public permission: Permission<any, any, any, any>,
|
||||||
public config?: any,
|
public policies: Policy[] = [],
|
||||||
|
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 {
|
||||||
@@ -15,31 +44,23 @@ export class Role {
|
|||||||
public implicit_allow: boolean = false,
|
public implicit_allow: boolean = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static createWithPermissionNames(
|
static create(name: string, config: RoleSchema) {
|
||||||
name: string,
|
const permissions =
|
||||||
permissionNames: string[],
|
config.permissions?.map((p: string | RolePermissionSchema) => {
|
||||||
is_default: boolean = false,
|
if (typeof p === "string") {
|
||||||
implicit_allow: boolean = false,
|
return new RolePermission(new Permission(p), []);
|
||||||
) {
|
}
|
||||||
return new Role(
|
const policies = p.policies?.map((policy) => new Policy(policy));
|
||||||
name,
|
return new RolePermission(new Permission(p.permission), policies, p.effect);
|
||||||
permissionNames.map((name) => new RolePermission(new Permission(name))),
|
}) ?? [];
|
||||||
is_default,
|
return new Role(name, permissions, config.is_default, config.implicit_allow);
|
||||||
implicit_allow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(config: {
|
toJSON() {
|
||||||
name: string;
|
return {
|
||||||
permissions?: string[];
|
permissions: this.permissions.map((p) => p.toJSON()),
|
||||||
is_default?: boolean;
|
is_default: this.is_default,
|
||||||
implicit_allow?: boolean;
|
implicit_allow: this.implicit_allow,
|
||||||
}) {
|
};
|
||||||
return new Role(
|
|
||||||
config.name,
|
|
||||||
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
|
|
||||||
config.is_default,
|
|
||||||
config.implicit_allow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -49,7 +48,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.warn(`auth middleware already registered for ${getPath(c)}`);
|
$console.debug(`auth middleware already registered for ${getPath(c)}`);
|
||||||
} else {
|
} else {
|
||||||
authCtx.registered = true;
|
authCtx.registered = true;
|
||||||
|
|
||||||
@@ -67,48 +66,3 @@ 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();
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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: {
|
||||||
@@ -20,6 +21,7 @@ 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: {
|
||||||
@@ -259,17 +261,19 @@ async function action(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// update package name
|
// update package name if there is a package.json
|
||||||
await overridePackageJson(
|
if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) {
|
||||||
(pkg) => ({
|
await overridePackageJson(
|
||||||
...pkg,
|
(pkg) => ({
|
||||||
name: ctx.name,
|
...pkg,
|
||||||
}),
|
name: ctx.name,
|
||||||
{ 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({
|
||||||
|
|||||||
@@ -93,17 +93,19 @@ export async function replacePackageJsonVersions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
|
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
|
||||||
const versions = {
|
try {
|
||||||
bknd: await sysGetVersion(),
|
const versions = {
|
||||||
...(map ?? {}),
|
bknd: await sysGetVersion(),
|
||||||
};
|
...(map ?? {}),
|
||||||
await replacePackageJsonVersions(
|
};
|
||||||
async (pkg) => {
|
await replacePackageJsonVersions(
|
||||||
if (pkg in versions) {
|
async (pkg) => {
|
||||||
return versions[pkg];
|
if (pkg in versions) {
|
||||||
}
|
return versions[pkg];
|
||||||
return;
|
}
|
||||||
},
|
return;
|
||||||
{ dir },
|
},
|
||||||
);
|
{ dir },
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
import { deno } from "cli/commands/create/templates/deno";
|
||||||
import { cloudflare } from "./cloudflare";
|
import { cloudflare } from "./cloudflare";
|
||||||
|
|
||||||
export type TemplateSetupCtx = {
|
export type TemplateSetupCtx = {
|
||||||
@@ -15,6 +16,7 @@ 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";
|
||||||
@@ -34,6 +36,11 @@ 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>;
|
||||||
@@ -90,4 +97,5 @@ 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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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.log("Serving static files from", root);
|
$console.debug("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.log("Serving static files from", root);
|
$console.debug("Serving static files from", root);
|
||||||
return m.serveStatic({
|
return m.serveStatic({
|
||||||
root,
|
root,
|
||||||
onNotFound,
|
onNotFound,
|
||||||
@@ -76,6 +76,9 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ 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 } from "bknd";
|
import { config, type App, type CreateAppConfig, type MaybePromise, registries } 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 {
|
||||||
@@ -111,7 +110,10 @@ 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({ server: { platform: options.server } });
|
app = await makeApp({
|
||||||
|
server: { platform: options.server },
|
||||||
|
connection: { url: ":memory:" },
|
||||||
|
});
|
||||||
|
|
||||||
// finally try to use env variables
|
// finally try to use env variables
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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")
|
||||||
@@ -29,8 +30,22 @@ 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.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
console.info(`\n${c.dim(`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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
log as $log,
|
log as $log,
|
||||||
password as $password,
|
password as $password,
|
||||||
text as $text,
|
text as $text,
|
||||||
|
select as $select,
|
||||||
} from "@clack/prompts";
|
} from "@clack/prompts";
|
||||||
import type { App } from "App";
|
import type { App } from "App";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
@@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig
|
|||||||
server: "node",
|
server: "node",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!app.module.auth.enabled) {
|
||||||
|
$log.error("Auth is not enabled");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "create":
|
case "create":
|
||||||
await create(app, options);
|
await create(app, options);
|
||||||
@@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function create(app: App, options: any) {
|
async function create(app: App, options: any) {
|
||||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
const auth = app.module.auth;
|
||||||
|
let role: string | null = null;
|
||||||
|
const roles = Object.keys(auth.config.roles ?? {});
|
||||||
|
|
||||||
|
const strategy = auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
|
if (roles.length > 0) {
|
||||||
|
role = (await $select({
|
||||||
|
message: "Select role",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
label: "<none>",
|
||||||
|
hint: "No role will be assigned to the user",
|
||||||
|
},
|
||||||
|
...roles.map((role) => ({
|
||||||
|
value: role,
|
||||||
|
label: role,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
})) as any;
|
||||||
|
if ($isCancel(role)) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (!strategy) {
|
if (!strategy) {
|
||||||
$log.error("Password strategy not configured");
|
$log.error("Password strategy not configured");
|
||||||
@@ -76,6 +103,7 @@ async function create(app: App, options: any) {
|
|||||||
const created = await app.createUser({
|
const created = await app.createUser({
|
||||||
email,
|
email,
|
||||||
password: await strategy.hash(password as string),
|
password: await strategy.hash(password as string),
|
||||||
|
role,
|
||||||
});
|
});
|
||||||
$log.success(`Created user: ${c.cyan(created.email)}`);
|
$log.success(`Created user: ${c.cyan(created.email)}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys";
|
|||||||
import { capture, flush, init } from "cli/utils/telemetry";
|
import { capture, flush, init } from "cli/utils/telemetry";
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
export async function main() {
|
async function main() {
|
||||||
await init();
|
await init();
|
||||||
capture("start");
|
capture("start");
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { resendEmail } from "./resend";
|
|||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||||
|
|
||||||
describe.skipIf(ALL_TESTS)("resend", () => {
|
describe.skipIf(ALL_TESTS)("resend", () => {
|
||||||
it.only("should throw on failed", async () => {
|
it("should throw on failed", async () => {
|
||||||
const driver = resendEmail({ apiKey: "invalid" } as any);
|
const driver = resendEmail({ apiKey: "invalid" } as any);
|
||||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -73,8 +73,9 @@ export const env = <
|
|||||||
onValid?: (valid: R) => void;
|
onValid?: (valid: R) => void;
|
||||||
},
|
},
|
||||||
): R extends undefined ? Fallback : R => {
|
): R extends undefined ? Fallback : R => {
|
||||||
|
const source = opts?.source ?? (typeof process !== "undefined" ? process?.env : {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = opts?.source ?? process.env;
|
|
||||||
const c = envs[key];
|
const c = envs[key];
|
||||||
const g = source[c.key];
|
const g = source[c.key];
|
||||||
const v = c.validate(g) as any;
|
const v = c.validate(g) as any;
|
||||||
|
|||||||
@@ -205,7 +205,17 @@ export class EventManager<
|
|||||||
if (listener.mode === "sync") {
|
if (listener.mode === "sync") {
|
||||||
syncs.push(listener);
|
syncs.push(listener);
|
||||||
} else {
|
} else {
|
||||||
asyncs.push(async () => await listener.handler(event, listener.event.slug));
|
asyncs.push(async () => {
|
||||||
|
try {
|
||||||
|
await listener.handler(event, listener.event.slug);
|
||||||
|
} catch (e) {
|
||||||
|
if (this.options?.onError) {
|
||||||
|
this.options.onError(event, e);
|
||||||
|
} else {
|
||||||
|
$console.error("Error executing async listener", listener, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Remove if `once` is true, otherwise keep
|
// Remove if `once` is true, otherwise keep
|
||||||
return !listener.once;
|
return !listener.once;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PrimaryFieldType } from "core/config";
|
import type { PrimaryFieldType } from "core/config";
|
||||||
|
import { getPath, invariant, isPlainObject } from "bknd/utils";
|
||||||
|
|
||||||
export type Primitive = PrimaryFieldType | string | number | boolean;
|
export type Primitive = PrimaryFieldType | string | number | boolean;
|
||||||
export function isPrimitive(value: any): value is Primitive {
|
export function isPrimitive(value: any): value is Primitive {
|
||||||
@@ -25,6 +26,10 @@ export function exp<const Key, const Expect, CTX = any>(
|
|||||||
valid: (v: Expect) => boolean,
|
valid: (v: Expect) => boolean,
|
||||||
validate: (e: Expect, a: unknown, ctx: CTX) => any,
|
validate: (e: Expect, a: unknown, ctx: CTX) => any,
|
||||||
): Expression<Key, Expect, CTX> {
|
): Expression<Key, Expect, CTX> {
|
||||||
|
invariant(typeof key === "string", "key must be a string");
|
||||||
|
invariant(key[0] === "$", "key must start with '$'");
|
||||||
|
invariant(typeof valid === "function", "valid must be a function");
|
||||||
|
invariant(typeof validate === "function", "validate must be a function");
|
||||||
return new Expression(key, valid, validate);
|
return new Expression(key, valid, validate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +55,7 @@ function getExpression<Exps extends Expressions>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LiteralExpressionCondition<Exps extends Expressions> = {
|
type LiteralExpressionCondition<Exps extends Expressions> = {
|
||||||
[key: string]: Primitive | ExpressionCondition<Exps>;
|
[key: string]: undefined | Primitive | ExpressionCondition<Exps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OperandOr = "$or" as const;
|
const OperandOr = "$or" as const;
|
||||||
@@ -67,8 +72,9 @@ function _convert<Exps extends Expressions>(
|
|||||||
expressions: Exps,
|
expressions: Exps,
|
||||||
path: string[] = [],
|
path: string[] = [],
|
||||||
): FilterQuery<Exps> {
|
): FilterQuery<Exps> {
|
||||||
|
invariant(typeof $query === "object", "$query must be an object");
|
||||||
const ExpressionConditionKeys = expressions.map((e) => e.key);
|
const ExpressionConditionKeys = expressions.map((e) => e.key);
|
||||||
const keys = Object.keys($query);
|
const keys = Object.keys($query ?? {});
|
||||||
const operands = [OperandOr] as const;
|
const operands = [OperandOr] as const;
|
||||||
const newQuery: FilterQuery<Exps> = {};
|
const newQuery: FilterQuery<Exps> = {};
|
||||||
|
|
||||||
@@ -83,13 +89,21 @@ function _convert<Exps extends Expressions>(
|
|||||||
function validate(key: string, value: any, path: string[] = []) {
|
function validate(key: string, value: any, path: string[] = []) {
|
||||||
const exp = getExpression(expressions, key as any);
|
const exp = getExpression(expressions, key as any);
|
||||||
if (exp.valid(value) === false) {
|
if (exp.valid(value) === false) {
|
||||||
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
|
throw new Error(
|
||||||
|
`Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries($query)) {
|
for (const [key, value] of Object.entries($query)) {
|
||||||
|
// skip undefined values
|
||||||
|
if (value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// if $or, convert each value
|
// if $or, convert each value
|
||||||
if (key === "$or") {
|
if (key === "$or") {
|
||||||
|
invariant(isPlainObject(value), "$or must be an object");
|
||||||
newQuery.$or = _convert(value, expressions, [...path, key]);
|
newQuery.$or = _convert(value, expressions, [...path, key]);
|
||||||
|
|
||||||
// if primitive, assume $eq
|
// if primitive, assume $eq
|
||||||
@@ -98,7 +112,7 @@ function _convert<Exps extends Expressions>(
|
|||||||
newQuery[key] = { $eq: value };
|
newQuery[key] = { $eq: value };
|
||||||
|
|
||||||
// if object, check for expressions
|
// if object, check for expressions
|
||||||
} else if (typeof value === "object") {
|
} else if (isPlainObject(value)) {
|
||||||
// when object is given, check if all keys are expressions
|
// when object is given, check if all keys are expressions
|
||||||
const invalid = Object.keys(value).filter(
|
const invalid = Object.keys(value).filter(
|
||||||
(f) => !ExpressionConditionKeys.includes(f as any),
|
(f) => !ExpressionConditionKeys.includes(f as any),
|
||||||
@@ -112,9 +126,13 @@ function _convert<Exps extends Expressions>(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`,
|
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expression key: ${ExpressionConditionKeys.join(", ")}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,15 +167,19 @@ function _build<Exps extends Expressions>(
|
|||||||
throw new Error(`Expression does not exist: "${$op}"`);
|
throw new Error(`Expression does not exist: "${$op}"`);
|
||||||
}
|
}
|
||||||
if (!exp.valid(expected)) {
|
if (!exp.valid(expected)) {
|
||||||
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
|
throw new Error(
|
||||||
|
`Invalid value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return exp.validate(expected, actual, options.exp_ctx);
|
return exp.validate(expected, actual, options.exp_ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check $and
|
// check $and
|
||||||
for (const [key, value] of Object.entries($and)) {
|
for (const [key, value] of Object.entries($and)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
|
||||||
for (const [$op, $v] of Object.entries(value)) {
|
for (const [$op, $v] of Object.entries(value)) {
|
||||||
const objValue = options.value_is_kv ? key : options.object[key];
|
const objValue = options.value_is_kv ? key : getPath(options.object, key);
|
||||||
result.$and.push(__validate($op, $v, objValue, [key]));
|
result.$and.push(__validate($op, $v, objValue, [key]));
|
||||||
result.keys.add(key);
|
result.keys.add(key);
|
||||||
}
|
}
|
||||||
@@ -165,7 +187,7 @@ function _build<Exps extends Expressions>(
|
|||||||
|
|
||||||
// check $or
|
// check $or
|
||||||
for (const [key, value] of Object.entries($or ?? {})) {
|
for (const [key, value] of Object.entries($or ?? {})) {
|
||||||
const objValue = options.value_is_kv ? key : options.object[key];
|
const objValue = options.value_is_kv ? key : getPath(options.object, key);
|
||||||
|
|
||||||
for (const [$op, $v] of Object.entries(value)) {
|
for (const [$op, $v] of Object.entries(value)) {
|
||||||
result.$or.push(__validate($op, $v, objValue, [key]));
|
result.$or.push(__validate($op, $v, objValue, [key]));
|
||||||
@@ -189,6 +211,10 @@ function _validate(results: ValidationResults): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
||||||
|
if (!expressions.some((e) => e.key === "$eq")) {
|
||||||
|
throw new Error("'$eq' expression is required");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
|
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
|
||||||
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
|
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
export class Permission<Name extends string = string> {
|
|
||||||
constructor(public name: Name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
name: this.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,3 +6,7 @@ export interface Serializable<Class, Json extends object = object> {
|
|||||||
export type MaybePromise<T> = T | Promise<T>;
|
export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||||
|
|
||||||
|
export type Merge<T> = {
|
||||||
|
[K in keyof T]: T[K];
|
||||||
|
};
|
||||||
|
|||||||
@@ -240,3 +240,46 @@ export async function blobToFile(
|
|||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFileAccepted(file: File | unknown, _accept: string | string[]): boolean {
|
||||||
|
const accept = Array.isArray(_accept) ? _accept.join(",") : _accept;
|
||||||
|
if (!accept || !accept.trim()) return true; // no restrictions
|
||||||
|
if (!isFile(file)) {
|
||||||
|
throw new Error("Given file is not a File instance");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const type = (file.type || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
// split on commas, trim whitespace
|
||||||
|
const tokens = accept
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// try each token until one matches
|
||||||
|
return tokens.some((token) => {
|
||||||
|
if (token.startsWith(".")) {
|
||||||
|
// extension match, e.g. ".png" or ".tar.gz"
|
||||||
|
return name.endsWith(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slashIdx = token.indexOf("/");
|
||||||
|
if (slashIdx !== -1) {
|
||||||
|
const [major, minor] = token.split("/");
|
||||||
|
if (minor === "*") {
|
||||||
|
// wildcard like "image/*"
|
||||||
|
if (!type) return false;
|
||||||
|
const [fMajor] = type.split("/");
|
||||||
|
return fMajor === major;
|
||||||
|
} else {
|
||||||
|
// exact MIME like "image/svg+xml" or "application/pdf"
|
||||||
|
// because of "text/plain;charset=utf-8"
|
||||||
|
return type.startsWith(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknown token shape, ignore
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean {
|
|||||||
export function getPath(
|
export function getPath(
|
||||||
object: object,
|
object: object,
|
||||||
_path: string | (string | number)[],
|
_path: string | (string | number)[],
|
||||||
defaultValue = undefined,
|
defaultValue: any = undefined,
|
||||||
): any {
|
): any {
|
||||||
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
|
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
|
||||||
|
|
||||||
@@ -512,3 +512,43 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
|
|||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recursivelyReplacePlaceholders(
|
||||||
|
obj: any,
|
||||||
|
pattern: RegExp,
|
||||||
|
variables: Record<string, any>,
|
||||||
|
fallback?: any,
|
||||||
|
) {
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
// check if the entire string matches the pattern
|
||||||
|
const match = obj.match(pattern);
|
||||||
|
if (match && match[0] === obj && match[1]) {
|
||||||
|
// full string match - replace with the actual value (preserving type)
|
||||||
|
const key = match[1];
|
||||||
|
const value = getPath(variables, key, null);
|
||||||
|
return value !== null ? value : fallback !== undefined ? fallback : obj;
|
||||||
|
}
|
||||||
|
// partial match - use string replacement
|
||||||
|
if (pattern.test(obj)) {
|
||||||
|
return obj.replace(pattern, (match, key) => {
|
||||||
|
const value = getPath(variables, key, null);
|
||||||
|
// convert to string for partial replacements
|
||||||
|
return value !== null
|
||||||
|
? String(value)
|
||||||
|
: fallback !== undefined
|
||||||
|
? String(fallback)
|
||||||
|
: match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback));
|
||||||
|
}
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
|
||||||
|
return acc;
|
||||||
|
}, {} as object);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,3 +61,19 @@ export function invariant(condition: boolean | any, message: string) {
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
if (instance) {
|
||||||
|
if (e instanceof instance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// if instance given but not what expected, throw
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Exception } from "core/errors";
|
||||||
|
import { HttpStatus } from "bknd/utils";
|
||||||
import * as s from "jsonv-ts";
|
import * as s from "jsonv-ts";
|
||||||
|
|
||||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||||
@@ -12,6 +14,7 @@ export {
|
|||||||
getMcpServer,
|
getMcpServer,
|
||||||
stdioTransport,
|
stdioTransport,
|
||||||
McpClient,
|
McpClient,
|
||||||
|
logLevels as mcpLogLevels,
|
||||||
type McpClientConfig,
|
type McpClientConfig,
|
||||||
type ToolAnnotation,
|
type ToolAnnotation,
|
||||||
type ToolHandlerCtx,
|
type ToolHandlerCtx,
|
||||||
@@ -21,8 +24,35 @@ export { secret, SecretSchema } from "./secret";
|
|||||||
|
|
||||||
export { s };
|
export { s };
|
||||||
|
|
||||||
export const stripMark = <O extends object>(o: O): O => o;
|
const symbol = Symbol("bknd-validation-mark");
|
||||||
export const mark = <O extends object>(o: O): O => o;
|
|
||||||
|
export function stripMark<O = any>(obj: O) {
|
||||||
|
const newObj = structuredClone(obj);
|
||||||
|
mark(newObj, false);
|
||||||
|
return newObj as O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mark(obj: any, validated = true) {
|
||||||
|
try {
|
||||||
|
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
||||||
|
if (validated) {
|
||||||
|
obj[symbol] = true;
|
||||||
|
} else {
|
||||||
|
delete obj[symbol];
|
||||||
|
}
|
||||||
|
for (const key in obj) {
|
||||||
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||||
|
mark(obj[key], validated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarked(obj: any) {
|
||||||
|
if (typeof obj !== "object" || obj === null) return false;
|
||||||
|
return obj[symbol] === true;
|
||||||
|
}
|
||||||
|
|
||||||
export const stringIdentifier = s.string({
|
export const stringIdentifier = s.string({
|
||||||
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||||
@@ -30,7 +60,10 @@ export const stringIdentifier = s.string({
|
|||||||
maxLength: 150,
|
maxLength: 150,
|
||||||
});
|
});
|
||||||
|
|
||||||
export class InvalidSchemaError extends Error {
|
export class InvalidSchemaError extends Exception {
|
||||||
|
override name = "InvalidSchemaError";
|
||||||
|
override code = HttpStatus.UNPROCESSABLE_ENTITY;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public schema: s.Schema,
|
public schema: s.Schema,
|
||||||
public value: unknown,
|
public value: unknown,
|
||||||
@@ -74,6 +107,10 @@ export function parse<S extends s.Schema, Options extends ParseOptions = ParseOp
|
|||||||
v: unknown,
|
v: unknown,
|
||||||
opts?: Options,
|
opts?: Options,
|
||||||
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
|
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
|
||||||
|
if (!opts?.forceParse && !opts?.coerce && isMarked(v)) {
|
||||||
|
return v as any;
|
||||||
|
}
|
||||||
|
|
||||||
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
|
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
|
||||||
let value =
|
let value =
|
||||||
opts?.coerce !== false
|
opts?.coerce !== false
|
||||||
|
|||||||
@@ -120,17 +120,14 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function slugify(str: string): string {
|
export function slugify(str: string): string {
|
||||||
return (
|
return String(str)
|
||||||
String(str)
|
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
||||||
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
||||||
// biome-ignore lint/suspicious/noMisleadingCharacterClass: <explanation>
|
.trim() // trim leading or trailing whitespace
|
||||||
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
.toLowerCase() // convert to lowercase
|
||||||
.trim() // trim leading or trailing whitespace
|
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
||||||
.toLowerCase() // convert to lowercase
|
.replace(/\s+/g, "-") // replace spaces with hyphens
|
||||||
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
.replace(/-+/g, "-"); // remove consecutive hyphens
|
||||||
.replace(/\s+/g, "-") // replace spaces with hyphens
|
|
||||||
.replace(/-+/g, "-") // remove consecutive hyphens
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function truncate(str: string, length = 50, end = "..."): string {
|
export function truncate(str: string, length = 50, end = "..."): string {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { AppDataConfig } from "../data-schema";
|
|||||||
import type { EntityManager, EntityData } from "data/entities";
|
import type { EntityManager, EntityData } from "data/entities";
|
||||||
import * as DataPermissions from "data/permissions";
|
import * as DataPermissions from "data/permissions";
|
||||||
import { repoQuery, type RepoQuery } from "data/server/query";
|
import { repoQuery, type RepoQuery } from "data/server/query";
|
||||||
|
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||||
|
|
||||||
export class DataController extends Controller {
|
export class DataController extends Controller {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -42,7 +43,7 @@ export class DataController extends Controller {
|
|||||||
|
|
||||||
override getController() {
|
override getController() {
|
||||||
const { permission, auth } = this.middlewares;
|
const { permission, auth } = this.middlewares;
|
||||||
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {}));
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||||
|
|
||||||
// info
|
// info
|
||||||
@@ -58,7 +59,7 @@ export class DataController extends Controller {
|
|||||||
// sync endpoint
|
// sync endpoint
|
||||||
hono.get(
|
hono.get(
|
||||||
"/sync",
|
"/sync",
|
||||||
permission(DataPermissions.databaseSync),
|
permission(DataPermissions.databaseSync, {}),
|
||||||
mcpTool("data_sync", {
|
mcpTool("data_sync", {
|
||||||
// @todo: should be removed if readonly
|
// @todo: should be removed if readonly
|
||||||
annotations: {
|
annotations: {
|
||||||
@@ -95,7 +96,9 @@ export class DataController extends Controller {
|
|||||||
// read entity schema
|
// read entity schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schema.json",
|
"/schema.json",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Retrieve data schema",
|
summary: "Retrieve data schema",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
@@ -121,7 +124,9 @@ export class DataController extends Controller {
|
|||||||
// read schema
|
// read schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schemas/:entity/:context?",
|
"/schemas/:entity/:context?",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Retrieve entity schema",
|
summary: "Retrieve entity schema",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
@@ -153,6 +158,22 @@ export class DataController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
hono.get(
|
||||||
|
"/types",
|
||||||
|
permission(SystemPermissions.schemaRead, {
|
||||||
|
context: (c) => ({ module: "data" }),
|
||||||
|
}),
|
||||||
|
describeRoute({
|
||||||
|
summary: "Retrieve data typescript definitions",
|
||||||
|
tags: ["data"],
|
||||||
|
}),
|
||||||
|
mcpTool("data_types"),
|
||||||
|
async (c) => {
|
||||||
|
const et = new EntityTypescript(this.em);
|
||||||
|
return c.text(et.toString());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// entity endpoints
|
// entity endpoints
|
||||||
hono.route("/entity", this.getEntityRoutes());
|
hono.route("/entity", this.getEntityRoutes());
|
||||||
|
|
||||||
@@ -161,7 +182,9 @@ export class DataController extends Controller {
|
|||||||
*/
|
*/
|
||||||
hono.get(
|
hono.get(
|
||||||
"/info/:entity",
|
"/info/:entity",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Retrieve entity info",
|
summary: "Retrieve entity info",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
@@ -213,7 +236,9 @@ export class DataController extends Controller {
|
|||||||
// fn: count
|
// fn: count
|
||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/fn/count",
|
"/:entity/fn/count",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Count entities",
|
summary: "Count entities",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
@@ -236,7 +261,9 @@ export class DataController extends Controller {
|
|||||||
// fn: exists
|
// fn: exists
|
||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/fn/exists",
|
"/:entity/fn/exists",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Check if entity exists",
|
summary: "Check if entity exists",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
@@ -285,16 +312,26 @@ export class DataController extends Controller {
|
|||||||
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
|
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||||
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
const result = await this.em.repository(entity).findMany(options);
|
const result = await this.em.repository(entity).findMany({
|
||||||
|
...options,
|
||||||
|
where: merge(options.where),
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(result, { status: result.data ? 200 : 404 });
|
return c.json(result, { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -308,7 +345,9 @@ export class DataController extends Controller {
|
|||||||
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
mcpTool("data_entity_read_one", {
|
mcpTool("data_entity_read_one", {
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
param: s.object({ entity: entitiesEnum, id: idType }),
|
param: s.object({ entity: entitiesEnum, id: idType }),
|
||||||
@@ -326,11 +365,19 @@ export class DataController extends Controller {
|
|||||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity) || !id) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
const result = await this.em.repository(entity).findId(id, options);
|
const { merge } = this.ctx.guard.filters(
|
||||||
|
DataPermissions.entityRead,
|
||||||
|
c,
|
||||||
|
c.req.valid("param"),
|
||||||
|
);
|
||||||
|
const id_name = this.em.entity(entity).getPrimaryField().name;
|
||||||
|
const result = await this.em
|
||||||
|
.repository(entity)
|
||||||
|
.findOne(merge({ [id_name]: id }), options);
|
||||||
|
|
||||||
return c.json(result, { status: result.data ? 200 : 404 });
|
return c.json(result, { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -344,7 +391,9 @@ export class DataController extends Controller {
|
|||||||
parameters: saveRepoQueryParams(),
|
parameters: saveRepoQueryParams(),
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
jsc(
|
jsc(
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
s.object({
|
||||||
@@ -361,9 +410,20 @@ export class DataController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
const result = await this.em
|
const { entity: newEntity } = this.em
|
||||||
.repository(entity)
|
.repository(entity)
|
||||||
.findManyByReference(id, reference, options);
|
.getEntityByReference(reference);
|
||||||
|
|
||||||
|
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
|
||||||
|
entity: newEntity.name,
|
||||||
|
id,
|
||||||
|
reference,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.em.repository(entity).findManyByReference(id, reference, {
|
||||||
|
...options,
|
||||||
|
where: merge(options.where),
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(result, { status: result.data ? 200 : 404 });
|
return c.json(result, { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -390,7 +450,9 @@ export class DataController extends Controller {
|
|||||||
},
|
},
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead, {
|
||||||
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
|
}),
|
||||||
mcpTool("data_entity_read_many", {
|
mcpTool("data_entity_read_many", {
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
param: s.object({ entity: entitiesEnum }),
|
param: s.object({ entity: entitiesEnum }),
|
||||||
@@ -405,7 +467,13 @@ export class DataController extends Controller {
|
|||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const options = c.req.valid("json") as RepoQuery;
|
const options = c.req.valid("json") as RepoQuery;
|
||||||
const result = await this.em.repository(entity).findMany(options);
|
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
const result = await this.em.repository(entity).findMany({
|
||||||
|
...options,
|
||||||
|
where: merge(options.where),
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(result, { status: result.data ? 200 : 404 });
|
return c.json(result, { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -421,7 +489,9 @@ export class DataController extends Controller {
|
|||||||
summary: "Insert one or many",
|
summary: "Insert one or many",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityCreate),
|
permission(DataPermissions.entityCreate, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
mcpTool("data_entity_insert"),
|
mcpTool("data_entity_insert"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||||
@@ -438,6 +508,12 @@ export class DataController extends Controller {
|
|||||||
// to transform all validation targets into a single object
|
// to transform all validation targets into a single object
|
||||||
const body = convertNumberedObjectToArray(_body);
|
const body = convertNumberedObjectToArray(_body);
|
||||||
|
|
||||||
|
this.ctx.guard
|
||||||
|
.filters(DataPermissions.entityCreate, c, {
|
||||||
|
entity,
|
||||||
|
})
|
||||||
|
.matches(body, { throwOnError: true });
|
||||||
|
|
||||||
if (Array.isArray(body)) {
|
if (Array.isArray(body)) {
|
||||||
const result = await this.em.mutator(entity).insertMany(body);
|
const result = await this.em.mutator(entity).insertMany(body);
|
||||||
return c.json(result, 201);
|
return c.json(result, 201);
|
||||||
@@ -455,7 +531,9 @@ export class DataController extends Controller {
|
|||||||
summary: "Update many",
|
summary: "Update many",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
mcpTool("data_entity_update_many", {
|
mcpTool("data_entity_update_many", {
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
param: s.object({ entity: entitiesEnum }),
|
param: s.object({ entity: entitiesEnum }),
|
||||||
@@ -482,7 +560,10 @@ export class DataController extends Controller {
|
|||||||
update: EntityData;
|
update: EntityData;
|
||||||
where: RepoQuery["where"];
|
where: RepoQuery["where"];
|
||||||
};
|
};
|
||||||
const result = await this.em.mutator(entity).updateWhere(update, where);
|
const { merge } = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
const result = await this.em.mutator(entity).updateWhere(update, merge(where));
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
},
|
},
|
||||||
@@ -495,7 +576,9 @@ export class DataController extends Controller {
|
|||||||
summary: "Update one",
|
summary: "Update one",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
mcpTool("data_entity_update_one"),
|
mcpTool("data_entity_update_one"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||||
jsc("json", s.object({})),
|
jsc("json", s.object({})),
|
||||||
@@ -505,6 +588,17 @@ export class DataController extends Controller {
|
|||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const body = (await c.req.json()) as EntityData;
|
const body = (await c.req.json()) as EntityData;
|
||||||
|
const fns = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
|
||||||
|
entity,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if it has filters attached, fetch entry and make the check
|
||||||
|
if (fns.filters.length > 0) {
|
||||||
|
const { data } = await this.em.repository(entity).findId(id);
|
||||||
|
fns.matches(data, { throwOnError: true });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.em.mutator(entity).updateOne(id, body);
|
const result = await this.em.mutator(entity).updateOne(id, body);
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
@@ -518,7 +612,9 @@ export class DataController extends Controller {
|
|||||||
summary: "Delete one",
|
summary: "Delete one",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
mcpTool("data_entity_delete_one"),
|
mcpTool("data_entity_delete_one"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -526,6 +622,18 @@ export class DataController extends Controller {
|
|||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fns = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
|
||||||
|
entity,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if it has filters attached, fetch entry and make the check
|
||||||
|
if (fns.filters.length > 0) {
|
||||||
|
const { data } = await this.em.repository(entity).findId(id);
|
||||||
|
fns.matches(data, { throwOnError: true });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.em.mutator(entity).deleteOne(id);
|
const result = await this.em.mutator(entity).deleteOne(id);
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
@@ -539,7 +647,9 @@ export class DataController extends Controller {
|
|||||||
summary: "Delete many",
|
summary: "Delete many",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete, {
|
||||||
|
context: (c) => ({ ...c.req.param() }) as any,
|
||||||
|
}),
|
||||||
mcpTool("data_entity_delete_many", {
|
mcpTool("data_entity_delete_many", {
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
param: s.object({ entity: entitiesEnum }),
|
param: s.object({ entity: entitiesEnum }),
|
||||||
@@ -554,7 +664,10 @@ export class DataController extends Controller {
|
|||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const where = (await c.req.json()) as RepoQuery["where"];
|
const where = (await c.req.json()) as RepoQuery["where"];
|
||||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
const { merge } = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
const result = await this.em.mutator(entity).deleteWhere(merge(where));
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type Simplify,
|
type Simplify,
|
||||||
sql,
|
sql,
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
|
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||||
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
||||||
import type { DB } from "bknd";
|
import type { DB } from "bknd";
|
||||||
import type { Constructor } from "core/registry/Registry";
|
import type { Constructor } from "core/registry/Registry";
|
||||||
@@ -70,15 +71,9 @@ export type IndexSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DbFunctions = {
|
export type DbFunctions = {
|
||||||
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>;
|
jsonObjectFrom: typeof jsonObjectFrom;
|
||||||
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>;
|
jsonArrayFrom: typeof jsonArrayFrom;
|
||||||
jsonBuildObject<O extends Record<string, Expression<unknown>>>(
|
jsonBuildObject: typeof jsonBuildObject;
|
||||||
obj: O,
|
|
||||||
): RawBuilder<
|
|
||||||
Simplify<{
|
|
||||||
[K in keyof O]: O[K] extends Expression<infer V> ? V : never;
|
|
||||||
}>
|
|
||||||
>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConnQuery = CompiledQuery | Compilable;
|
export type ConnQuery = CompiledQuery | Compilable;
|
||||||
@@ -230,3 +225,15 @@ export function customIntrospector<T extends Constructor<Dialect>>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class DummyConnection extends Connection {
|
||||||
|
override name = "dummy";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(undefined as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getFieldSchema(): SchemaResponse {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export class SqliteIntrospector extends BaseIntrospector {
|
|||||||
)) FROM pragma_index_list(m.name) i
|
)) FROM pragma_index_list(m.name) i
|
||||||
LEFT JOIN sqlite_master im ON im.name = i.name
|
LEFT JOIN sqlite_master im ON im.name = i.name
|
||||||
AND im.type = 'index'
|
AND im.type = 'index'
|
||||||
|
WHERE i.name not like 'sqlite_%'
|
||||||
) AS indices
|
) AS indices
|
||||||
FROM sqlite_master m
|
FROM sqlite_master m
|
||||||
WHERE m.type IN ('table', 'view')
|
WHERE m.type IN ('table', 'view')
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type LibsqlClientFns = {
|
|||||||
function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client {
|
function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client {
|
||||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||||
const { url, authToken } = clientOrCredentials;
|
const { url, authToken } = clientOrCredentials;
|
||||||
return createClient({ url, authToken });
|
return createClient({ url, authToken }) as unknown as Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientOrCredentials as Client;
|
return clientOrCredentials as Client;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const entityConfigSchema = s
|
|||||||
sort_field: s.string({ default: config.data.default_primary_field }),
|
sort_field: s.string({ default: config.data.default_primary_field }),
|
||||||
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
|
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
|
||||||
primary_format: s.string({ enum: primaryFieldTypes }),
|
primary_format: s.string({ enum: primaryFieldTypes }),
|
||||||
fields_order: s.array(s.string()).optional(),
|
|
||||||
},
|
},
|
||||||
{ default: {} },
|
{ default: {} },
|
||||||
)
|
)
|
||||||
@@ -138,9 +137,7 @@ export class Entity<
|
|||||||
}
|
}
|
||||||
|
|
||||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||||
field.isFillable(context),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRequiredFields(): Field[] {
|
getRequiredFields(): Field[] {
|
||||||
@@ -192,24 +189,9 @@ export class Entity<
|
|||||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFields(opts?: { virtual?: boolean; sorted?: boolean }): Field[] {
|
getFields(include_virtual: boolean = false): Field[] {
|
||||||
const fields = [...this.fields];
|
if (include_virtual) return this.fields;
|
||||||
const sort = opts?.sorted !== false;
|
return this.fields.filter((f) => !f.isVirtual());
|
||||||
const fields_order = this.config.fields_order;
|
|
||||||
if (fields_order && fields_order.length > 0 && sort) {
|
|
||||||
if (fields_order.length !== this.fields.length) {
|
|
||||||
$console.warn("Fields order must contain all fields");
|
|
||||||
} else {
|
|
||||||
// sort fields by order
|
|
||||||
fields.sort((a, b) => {
|
|
||||||
const a_index = fields_order.indexOf(a.name);
|
|
||||||
const b_index = fields_order.indexOf(b.name);
|
|
||||||
return a_index - b_index;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (opts?.virtual) return fields;
|
|
||||||
return fields.filter((f) => !f.isVirtual());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addField(field: Field) {
|
addField(field: Field) {
|
||||||
@@ -293,7 +275,7 @@ export class Entity<
|
|||||||
fields = this.getFillableFields(options.context);
|
fields = this.getFillableFields(options.context);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
fields = this.getFields({ virtual: true });
|
fields = this.getFields(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||||
@@ -327,12 +309,7 @@ export class Entity<
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
type: this.type,
|
type: this.type,
|
||||||
fields: Object.fromEntries(
|
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
|
||||||
this.getFields({ virtual: true, sorted: true }).map((field) => [
|
|
||||||
field.name,
|
|
||||||
field.toJSON(),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
config: this.config,
|
config: this.config,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
private _entities: Entity[] = [];
|
private _entities: Entity[] = [];
|
||||||
private _relations: EntityRelation[] = [];
|
private _relations: EntityRelation[] = [];
|
||||||
private _indices: EntityIndex[] = [];
|
private _indices: EntityIndex[] = [];
|
||||||
private _schema?: SchemaManager;
|
|
||||||
readonly emgr: EventManager<typeof EntityManager.Events>;
|
readonly emgr: EventManager<typeof EntityManager.Events>;
|
||||||
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
||||||
|
|
||||||
@@ -249,11 +248,7 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
schema() {
|
schema() {
|
||||||
if (!this._schema) {
|
return new SchemaManager(this);
|
||||||
this._schema = new SchemaManager(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._schema;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: centralize and add tests
|
// @todo: centralize and add tests
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { EntityTypescript } from "./EntityTypescript";
|
||||||
|
import * as proto from "../prototype";
|
||||||
|
import { DummyConnection } from "../connection/Connection";
|
||||||
|
|
||||||
|
describe("EntityTypescript", () => {
|
||||||
|
it("should generate correct typescript for system entities", () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
test: proto.entity("test", {
|
||||||
|
name: proto.text(),
|
||||||
|
}),
|
||||||
|
users: proto.systemEntity("users", {
|
||||||
|
name: proto.text(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
({ relation }, { test, users }) => {
|
||||||
|
relation(test).manyToOne(users);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
|
||||||
|
expect(et.toString()).toContain('users?: DB["users"];');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate correct typescript for system entities with uuid primary field", () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
test: proto.entity(
|
||||||
|
"test",
|
||||||
|
{
|
||||||
|
name: proto.text(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
primary_format: "uuid",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
users: proto.systemEntity(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
name: proto.text(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
primary_format: "uuid",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
({ relation }, { test, users }) => {
|
||||||
|
relation(test).manyToOne(users);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
|
||||||
|
expect(et.toString()).toContain("users_id?: string;");
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user