Merge pull request #278 from bknd-io/release/0.19

Release 0.19
This commit is contained in:
dswbx
2025-10-31 09:27:01 +01:00
committed by GitHub
130 changed files with 4809 additions and 733 deletions

View File

@@ -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 () => {

View File

@@ -201,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", {
@@ -210,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", {

View File

@@ -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");
});
});

View File

@@ -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();
});
}); });
}); });

View File

@@ -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);
});
});
});

View File

@@ -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));
});
});

View File

@@ -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());
});
});

View File

@@ -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);
});
}); });

View File

@@ -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" } });
});
});

View File

@@ -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", () => {

View File

@@ -30,9 +30,9 @@ describe("some tests", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
expect(query.sql).toBe( expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? 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();
}); });

View File

@@ -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 */
});
}); });

View File

@@ -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");
}); });
}); });

View File

@@ -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";

View File

@@ -85,7 +85,12 @@ 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,

View File

@@ -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.");

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.18.1", "version": "0.19.0-rc.3",
"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": {
@@ -65,7 +65,7 @@
"hono": "4.8.3", "hono": "4.8.3",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.5", "jsonv-ts": "0.9.1",
"kysely": "0.27.6", "kysely": "0.27.6",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
@@ -180,6 +180,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": {

View File

@@ -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 new Proxy(
{},
{
get(_, prop) {
return (...args: any[]) => {
const response = storage ? storage[prop](...args) : undefined;
if (response instanceof Promise) {
return response;
}
return { return {
getItem: async (key: string) => { // biome-ignore lint/suspicious/noThenProperty: it's a promise :)
return await this.options.storage!.getItem(key); then: (fn) => fn(response),
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
}; };
};
},
},
) as any;
} }
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);
}, },
}, },

View File

@@ -245,9 +245,8 @@ export class App<
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
if (!this.isBuilt()) { if (!this.isBuilt()) {
throw new Error("App is not built yet, run build() first"); console.error("App is not built yet, run build() first");
} }
return this.server.fetch as any; return this.server.fetch as any;
} }
@@ -296,6 +295,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) {
@@ -311,8 +311,9 @@ export class App<
throw new Error("MCP is not enabled"); throw new Error("MCP is not enabled");
} }
const url = new URL(config.path, "http://localhost").toString();
return new McpClient({ return new McpClient({
url: "http://localhost" + config.path, url,
fetch: this.server.request, fetch: this.server.request,
}); });
} }
@@ -385,6 +386,7 @@ export class App<
} }
} }
} }
await this.options?.manager?.onModulesBuilt?.(ctx);
} }
} }

View File

@@ -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);
}; };

View File

@@ -12,7 +12,7 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
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 +26,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,9 +54,10 @@ 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,
@@ -71,6 +72,7 @@ export function serve<Env = BunEnv>(
adminOptions, adminOptions,
distPath, distPath,
serveStatic, serveStatic,
beforeBuild,
}, },
args, args,
), ),

View File

@@ -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();
}

View File

@@ -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:";

View File

@@ -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

View File

@@ -49,6 +49,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();
} }
@@ -175,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter {
} }
protected getKey(key: string) { protected getKey(key: string) {
if (this.keyPrefix.length > 0) {
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
}
return key; return key;
} }

View File

@@ -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 & {
app?:
| Merge<Omit<BkndConfig, "app"> & Additional>
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
onBuilt?: (app: App) => MaybePromise<void>; onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>; beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; } & 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);
} }
} }

View File

@@ -9,9 +9,9 @@ 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"]) {
@@ -39,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>( export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {}, { cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} 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);

View File

@@ -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");
}

View File

@@ -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(
{ {

View File

@@ -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);

View File

@@ -2,7 +2,7 @@ import type { DB, PrimaryFieldType } from "bknd";
import * as AuthPermissions from "auth/auth-permissions"; import * as AuthPermissions from "auth/auth-permissions";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { $console, secureRandomString, transformObject } 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),

View File

@@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & { export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token?: string) => 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));
} }
} }

View File

@@ -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);

View File

@@ -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");

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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; const user = c && "get" in c ? c.get("auth")?.user : c;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { const ctx = {
if (!this.isEnabled()) { ...((context ?? {}) as any),
return true; ...this.config?.context,
} user,
};
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; const exists = this.permissionExists(permission.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); const role = this.getUserRole(user);
const rolePermission = role?.permissions.find(
(rolePermission) => rolePermission.permission.name === permission.name,
);
return {
ctx,
user,
exists,
role,
rolePermission,
};
}
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): void;
granted<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext): void;
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): void {
if (!this.isEnabled()) {
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) { if (!role) {
$console.debug("guard: user has no role, denying"); throw new GuardPermissionsException(permission, undefined, "User has no role");
return false; }
} else if (role.implicit_allow === true) {
if (!rolePermission) {
if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`); $console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true; return;
} }
const rolePermission = role.permissions.find( throw new GuardPermissionsException(
(rolePermission) => rolePermission.permission.name === name, permission,
undefined,
`Role "${role.name}" does not have required permission`,
); );
}
$console.debug("guard: rolePermission, allowing?", { if (rolePermission?.policies.length > 0) {
permission: name, $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, role: role.name,
allowing: !!rolePermission,
}); });
return !!rolePermission;
} }
granted(permission: Permission | string, c?: GuardContext): boolean { filters<P extends Permission<any, any, any, any>>(
const user = c && "get" in c ? c.get("auth")?.user : c; permission: P,
return this.hasPermission(permission as any, user); 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");
} }
throwUnlessGranted(permission: Permission | string, c: GuardContext) { const {
if (!this.granted(permission, c)) { ctx: _ctx,
throw new Exception( exists,
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, role,
403, 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;
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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,
);
} }
} }

View File

@@ -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();
});

View File

@@ -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;
}

View File

@@ -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,7 +261,8 @@ async function action(options: {
} }
} }
// update package name // update package name if there is a package.json
if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) {
await overridePackageJson( await overridePackageJson(
(pkg) => ({ (pkg) => ({
...pkg, ...pkg,
@@ -268,8 +271,9 @@ async function action(options: {
{ dir: ctx.dir }, { dir: ctx.dir },
); );
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
}
{ if (template.installDeps !== false) {
const install = const install =
options.yes ?? options.yes ??
(await $p.confirm({ (await $p.confirm({

View File

@@ -93,6 +93,7 @@ export async function replacePackageJsonVersions(
} }
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) { export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
try {
const versions = { const versions = {
bknd: await sysGetVersion(), bknd: await sysGetVersion(),
...(map ?? {}), ...(map ?? {}),
@@ -106,4 +107,5 @@ export async function updateBkndPackages(dir?: string, map?: Record<string, stri
}, },
{ dir }, { dir },
); );
} catch (e) {}
} }

View File

@@ -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;

View File

@@ -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,
]; ];

View File

@@ -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);

View File

@@ -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;

View File

@@ -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) =>

View File

@@ -1,11 +0,0 @@
export class Permission<Name extends string = string> {
constructor(public name: Name) {
this.name = name;
}
toJSON() {
return {
name: this.name,
};
}
}

View File

@@ -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];
};

View File

@@ -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;
});
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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";
@@ -58,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,

View File

@@ -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);
}, },

View File

@@ -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

View File

@@ -1,4 +1,4 @@
import type { DB as DefaultDB, PrimaryFieldType } from "bknd"; import type { DB as DefaultDB, EntityRelation, PrimaryFieldType } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
@@ -280,16 +280,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
id: PrimaryFieldType, id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResult<TBD[TB] | undefined>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery( if (typeof id === "undefined" || id === null) {
{ throw new InvalidSearchParamsException("id is required");
..._options, }
where: { [this.entity.getPrimaryField().name]: id },
limit: 1,
},
["offset", "sort"],
);
return this.single(qb, options) as any; return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options);
} }
async findOne( async findOne(
@@ -315,23 +310,27 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return res as any; return res as any;
} }
getEntityByReference(reference: string): { entity: Entity; relation: EntityRelation } {
const listable_relations = this.em.relations.listableRelationsOf(this.entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
if (!relation) {
throw new Error(
`Relation "${reference}" not found or not listable on entity "${this.entity.name}"`,
);
}
return {
entity: relation.other(this.entity).entity,
relation,
};
}
// @todo: add unit tests, specially for many to many // @todo: add unit tests, specially for many to many
async findManyByReference( async findManyByReference(
id: PrimaryFieldType, id: PrimaryFieldType,
reference: string, reference: string,
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "limit" | "offset">>,
): Promise<RepositoryResult<EntityData>> { ): Promise<RepositoryResult<EntityData>> {
const entity = this.entity; const { entity: newEntity, relation } = this.getEntityByReference(reference);
const listable_relations = this.em.relations.listableRelationsOf(entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
if (!relation) {
throw new Error(
`Relation "${reference}" not found or not listable on entity "${entity.name}"`,
);
}
const newEntity = relation.other(entity).entity;
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference); const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) { if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
throw new Error( throw new Error(

View File

@@ -64,20 +64,27 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
return false; return false;
} }
override isValid(value: any): boolean {
return this.isSerializable(value);
}
override getValue(value: any, context: TRenderContext): any { override getValue(value: any, context: TRenderContext): any {
switch (context) { switch (context) {
case "form":
if (value === null) return "";
return JSON.stringify(value, null, 2);
case "table": case "table":
if (value === null) return null; if (value === null) return null;
return JSON.stringify(value); return JSON.stringify(value);
case "submit": case "submit":
if (typeof value === "string" && value.length === 0) { if (!value || (typeof value === "string" && value.length === 0)) {
return null; return null;
} else if (typeof value === "object") {
return value;
} }
try {
return JSON.parse(value); return JSON.parse(value);
} catch (e) {
return value;
}
} }
return value; return value;

View File

@@ -1,9 +1,51 @@
import { Permission } from "core/security/Permission"; import { Permission } from "auth/authorize/Permission";
import { s } from "bknd/utils";
export const entityRead = new Permission("data.entity.read"); export const entityRead = new Permission(
export const entityCreate = new Permission("data.entity.create"); "data.entity.read",
export const entityUpdate = new Permission("data.entity.update"); {
export const entityDelete = new Permission("data.entity.delete"); filterable: true,
},
s.object({
entity: s.string(),
id: s.anyOf([s.number(), s.string()]).optional(),
}),
);
/**
* Filter filters content given
*/
export const entityCreate = new Permission(
"data.entity.create",
{
filterable: true,
},
s.object({
entity: s.string(),
}),
);
/**
* Filter filters where clause
*/
export const entityUpdate = new Permission(
"data.entity.update",
{
filterable: true,
},
s.object({
entity: s.string(),
id: s.anyOf([s.number(), s.string()]).optional(),
}),
);
export const entityDelete = new Permission(
"data.entity.delete",
{
filterable: true,
},
s.object({
entity: s.string(),
id: s.anyOf([s.number(), s.string()]).optional(),
}),
);
export const databaseSync = new Permission("data.database.sync"); export const databaseSync = new Permission("data.database.sync");
export const rawQuery = new Permission("data.raw.query"); export const rawQuery = new Permission("data.raw.query");
export const rawMutate = new Permission("data.raw.mutate"); export const rawMutate = new Permission("data.raw.mutate");

View File

@@ -248,20 +248,16 @@ export class SchemaManager {
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) { async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
const diff = await this.getDiff(); const diff = await this.getDiff();
let updates: number = 0;
const statements: { sql: string; parameters: readonly unknown[] }[] = []; const statements: { sql: string; parameters: readonly unknown[] }[] = [];
const schema = this.em.connection.kysely.schema; const schema = this.em.connection.kysely.schema;
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
for (const table of diff) { for (const table of diff) {
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
let local_updates: number = 0;
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add); const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
const dropFields = table.columns.drop; const dropFields = table.columns.drop;
const dropIndices = table.indices.drop; const dropIndices = table.indices.drop;
if (table.isDrop) { if (table.isDrop) {
updates++;
local_updates++;
if (config.drop) { if (config.drop) {
qbs.push(schema.dropTable(table.name)); qbs.push(schema.dropTable(table.name));
} }
@@ -269,8 +265,6 @@ export class SchemaManager {
let createQb = schema.createTable(table.name); let createQb = schema.createTable(table.name);
// add fields // add fields
for (const fieldSchema of addFieldSchemas) { for (const fieldSchema of addFieldSchemas) {
updates++;
local_updates++;
// @ts-ignore // @ts-ignore
createQb = createQb.addColumn(...fieldSchema); createQb = createQb.addColumn(...fieldSchema);
} }
@@ -281,8 +275,6 @@ export class SchemaManager {
if (addFieldSchemas.length > 0) { if (addFieldSchemas.length > 0) {
// add fields // add fields
for (const fieldSchema of addFieldSchemas) { for (const fieldSchema of addFieldSchemas) {
updates++;
local_updates++;
// @ts-ignore // @ts-ignore
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema)); qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
} }
@@ -292,8 +284,6 @@ export class SchemaManager {
if (config.drop && dropFields.length > 0) { if (config.drop && dropFields.length > 0) {
// drop fields // drop fields
for (const column of dropFields) { for (const column of dropFields) {
updates++;
local_updates++;
qbs.push(schema.alterTable(table.name).dropColumn(column)); qbs.push(schema.alterTable(table.name).dropColumn(column));
} }
} }
@@ -311,35 +301,33 @@ export class SchemaManager {
qb = qb.unique(); qb = qb.unique();
} }
qbs.push(qb); qbs.push(qb);
local_updates++;
updates++;
} }
// drop indices // drop indices
if (config.drop) { if (config.drop) {
for (const index of dropIndices) { for (const index of dropIndices) {
qbs.push(schema.dropIndex(index)); qbs.push(schema.dropIndex(index));
local_updates++; }
updates++;
} }
} }
if (local_updates === 0) continue; if (qbs.length > 0) {
statements.push(
// iterate through built qbs ...qbs.map((qb) => {
// @todo: run in batches
for (const qb of qbs) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
statements.push({ sql, parameters }); return { sql, parameters };
}),
);
$console.debug(
"[SchemaManager]",
`${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
);
if (config.force) {
try { try {
$console.debug("[SchemaManager]", sql); await this.em.connection.executeQueries(...qbs);
await qb.execute();
} catch (e) { } catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`); throw new Error(`Failed to execute batch: ${String(e)}`);
}
}
} }
} }

View File

@@ -1,6 +1,8 @@
import { test, describe, expect } from "bun:test"; import { test, describe, expect } from "bun:test";
import * as q from "./query"; import * as q from "./query";
import { parse as $parse, type ParseOptions } from "bknd/utils"; import { parse as $parse, type ParseOptions } from "bknd/utils";
import type { PrimaryFieldType } from "modules";
import type { Generated } from "kysely";
const parse = (v: unknown, o: ParseOptions = {}) => const parse = (v: unknown, o: ParseOptions = {}) =>
$parse(q.repoQuery, v, { $parse(q.repoQuery, v, {
@@ -186,4 +188,35 @@ describe("server/query", () => {
decode({ with: { images: {}, comments: {} } }, output); decode({ with: { images: {}, comments: {} } }, output);
} }
}); });
test("types", () => {
const id = 1 as PrimaryFieldType;
const id2 = "1" as unknown as Generated<string>;
const c: q.RepoQueryIn = {
where: {
// @ts-expect-error only primitives are allowed for $eq
something: [],
// this gets ignored
another: undefined,
// @ts-expect-error null is not a valid value
null_is_okay: null,
some_id: id,
another_id: id2,
},
};
const d: q.RepoQuery = {
where: {
// @ts-expect-error only primitives are allowed for $eq
something: [],
// this gets ignored
another: undefined,
// @ts-expect-error null is not a valid value
null_is_okay: null,
some_id: id,
another_id: id2,
},
};
});
}); });

View File

@@ -84,8 +84,6 @@ const where = s.anyOf([s.string(), s.object({})], {
return WhereBuilder.convert(q); return WhereBuilder.convert(q);
}, },
}); });
//type WhereSchemaIn = s.Static<typeof where>;
//type WhereSchema = s.StaticCoerced<typeof where>;
// ------ // ------
// with // with
@@ -128,7 +126,7 @@ const withSchema = <Type = unknown>(self: s.Schema): s.Schema<{}, Type, Type> =>
} }
} }
return value as unknown as any; return value as any;
}, },
}) as any; }) as any;
@@ -167,15 +165,3 @@ export type RepoQueryIn = {
export type RepoQuery = s.StaticCoerced<typeof repoQuery> & { export type RepoQuery = s.StaticCoerced<typeof repoQuery> & {
sort: SortSchema; sort: SortSchema;
}; };
//export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
// @todo: CURRENT WORKAROUND
/* export type RepoQuery = {
limit?: number;
offset?: number;
sort?: { by: string; dir: "asc" | "desc" };
select?: string[];
with?: Record<string, RepoQuery>;
join?: string[];
where?: WhereQuery;
}; */

View File

@@ -41,15 +41,16 @@ export { getSystemMcp } from "modules/mcp/system-mcp";
/** /**
* Core * Core
*/ */
export type { MaybePromise } from "core/types"; export type { MaybePromise, Merge } from "core/types";
export { Exception, BkndError } from "core/errors"; export { Exception, BkndError } from "core/errors";
export { isDebug, env } from "core/env"; export { isDebug, env } from "core/env";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";
export { Permission } from "core/security/Permission"; export { Permission } from "auth/authorize/Permission";
export { getFlashMessage } from "core/server/flash"; export { getFlashMessage } from "core/server/flash";
export * from "core/drivers"; export * from "core/drivers";
export { Event, InvalidEventReturn } from "core/events/Event"; export { Event, InvalidEventReturn } from "core/events/Event";
export type { export type {
EventListener,
ListenerMode, ListenerMode,
ListenerHandler, ListenerHandler,
} from "core/events/EventListener"; } from "core/events/EventListener";

View File

@@ -22,6 +22,9 @@ declare module "bknd" {
// @todo: current workaround to make it all required // @todo: current workaround to make it all required
export class AppMedia extends Module<Required<TAppMediaConfig>> { export class AppMedia extends Module<Required<TAppMediaConfig>> {
private _storage?: Storage; private _storage?: Storage;
options = {
body_max_size: null as number | null,
};
override async build() { override async build() {
if (!this.config.enabled) { if (!this.config.enabled) {

View File

@@ -36,7 +36,7 @@ export class MediaController extends Controller {
summary: "Get the list of files", summary: "Get the list of files",
tags: ["media"], tags: ["media"],
}), }),
permission(MediaPermissions.listFiles), permission(MediaPermissions.listFiles, {}),
async (c) => { async (c) => {
const files = await this.getStorageAdapter().listObjects(); const files = await this.getStorageAdapter().listObjects();
return c.json(files); return c.json(files);
@@ -51,7 +51,7 @@ export class MediaController extends Controller {
summary: "Get a file by name", summary: "Get a file by name",
tags: ["media"], tags: ["media"],
}), }),
permission(MediaPermissions.readFile), permission(MediaPermissions.readFile, {}),
async (c) => { async (c) => {
const { filename } = c.req.param(); const { filename } = c.req.param();
if (!filename) { if (!filename) {
@@ -81,7 +81,7 @@ export class MediaController extends Controller {
summary: "Delete a file by name", summary: "Delete a file by name",
tags: ["media"], tags: ["media"],
}), }),
permission(MediaPermissions.deleteFile), permission(MediaPermissions.deleteFile, {}),
async (c) => { async (c) => {
const { filename } = c.req.param(); const { filename } = c.req.param();
if (!filename) { if (!filename) {
@@ -93,7 +93,10 @@ export class MediaController extends Controller {
}, },
); );
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; const maxSize =
this.media.options.body_max_size ??
this.getStorage().getConfig().body_max_size ??
Number.POSITIVE_INFINITY;
if (isDebug()) { if (isDebug()) {
hono.post( hono.post(
@@ -146,7 +149,7 @@ export class MediaController extends Controller {
requestBody, requestBody,
}), }),
jsc("param", s.object({ filename: s.string().optional() })), jsc("param", s.object({ filename: s.string().optional() })),
permission(MediaPermissions.uploadFile), permission(MediaPermissions.uploadFile, {}),
async (c) => { async (c) => {
const reqname = c.req.param("filename"); const reqname = c.req.param("filename");
@@ -186,7 +189,10 @@ export class MediaController extends Controller {
}), }),
), ),
jsc("query", s.object({ overwrite: s.boolean().optional() })), jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), permission(DataPermissions.entityCreate, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
permission(MediaPermissions.uploadFile, {}),
async (c) => { async (c) => {
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param"); const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");

View File

@@ -1,4 +1,4 @@
import { Permission } from "core/security/Permission"; import { Permission } from "auth/authorize/Permission";
export const readFile = new Permission("media.file.read"); export const readFile = new Permission("media.file.read");
export const listFiles = new Permission("media.file.list"); export const listFiles = new Permission("media.file.list");

View File

@@ -48,7 +48,7 @@ export function buildMediaSchema() {
{ {
default: {}, default: {},
}, },
); ).strict();
} }
export const mediaConfigSchema = buildMediaSchema(); export const mediaConfigSchema = buildMediaSchema();

49
app/src/modes/code.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { BkndConfig } from "bknd/adapter";
import { makeModeConfig, type BkndModeConfig } from "./shared";
import { $console } from "bknd/utils";
export type BkndCodeModeConfig<Args = any> = BkndModeConfig<Args>;
export type CodeMode<AdapterConfig extends BkndConfig> = AdapterConfig extends BkndConfig<
infer Args
>
? BkndModeConfig<Args, AdapterConfig>
: never;
export function code<Args>(config: BkndCodeModeConfig<Args>): BkndConfig<Args> {
return {
...config,
app: async (args) => {
const {
config: appConfig,
plugins,
isProd,
syncSchemaOptions,
} = await makeModeConfig(config, args);
if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
$console.warn("You should not set a different mode than `db` when using code mode");
}
return {
...appConfig,
options: {
...appConfig?.options,
mode: "code",
plugins,
manager: {
// skip validation in prod for a speed boost
skipValidation: isProd,
onModulesBuilt: async (ctx) => {
if (!isProd && syncSchemaOptions.force) {
$console.log("[code] syncing schema");
await ctx.em.schema().sync(syncSchemaOptions);
}
},
...appConfig?.options?.manager,
},
},
};
},
};
}

89
app/src/modes/hybrid.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { BkndConfig } from "bknd/adapter";
import { makeModeConfig, type BkndModeConfig } from "./shared";
import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd";
import type { DbModuleManager } from "modules/db/DbModuleManager";
import { invariant, $console } from "bknd/utils";
export type BkndHybridModeOptions = {
/**
* Reader function to read the configuration from the file system.
* This is required for hybrid mode to work.
*/
reader?: (path: string) => MaybePromise<string>;
/**
* Provided secrets to be merged into the configuration
*/
secrets?: Record<string, any>;
};
export type HybridBkndConfig<Args = any> = BkndModeConfig<Args, BkndHybridModeOptions>;
export type HybridMode<AdapterConfig extends BkndConfig> = AdapterConfig extends BkndConfig<
infer Args
>
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
: never;
export function hybrid<Args>({
configFilePath = "bknd-config.json",
...rest
}: HybridBkndConfig<Args>): BkndConfig<Args> {
return {
...rest,
config: undefined,
app: async (args) => {
const {
config: appConfig,
isProd,
plugins,
syncSchemaOptions,
} = await makeModeConfig(
{
...rest,
configFilePath,
},
args,
);
if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") {
$console.warn("You should not set a different mode than `db` when using hybrid mode");
}
invariant(
typeof appConfig.reader === "function",
"You must set the `reader` option when using hybrid mode",
);
let fileConfig: ModuleConfigs;
try {
fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs;
} catch (e) {
const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs;
await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2));
fileConfig = defaultConfig;
}
return {
...(appConfig as any),
beforeBuild: async (app) => {
if (app && !isProd) {
const mm = app.modules as DbModuleManager;
mm.buildSyncConfig = syncSchemaOptions;
}
await appConfig.beforeBuild?.(app);
},
config: fileConfig,
options: {
...appConfig?.options,
mode: isProd ? "code" : "db",
plugins,
manager: {
// skip validation in prod for a speed boost
skipValidation: isProd,
// secrets are required for hybrid mode
secrets: appConfig.secrets,
...appConfig?.options?.manager,
},
},
};
},
};
}

3
app/src/modes/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./code";
export * from "./hybrid";
export * from "./shared";

183
app/src/modes/shared.ts Normal file
View File

@@ -0,0 +1,183 @@
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
import { syncTypes, syncConfig } from "bknd/plugins";
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
import { invariant, $console } from "bknd/utils";
export type BkndModeOptions = {
/**
* Whether the application is running in production.
*/
isProduction?: boolean;
/**
* Writer function to write the configuration to the file system
*/
writer?: (path: string, content: string) => MaybePromise<void>;
/**
* Configuration file path
*/
configFilePath?: string;
/**
* Types file path
* @default "bknd-types.d.ts"
*/
typesFilePath?: string;
/**
* Syncing secrets options
*/
syncSecrets?: {
/**
* Whether to enable syncing secrets
*/
enabled?: boolean;
/**
* Output file path
*/
outFile?: string;
/**
* Format of the output file
* @default "env"
*/
format?: "json" | "env";
/**
* Whether to include secrets in the output file
* @default false
*/
includeSecrets?: boolean;
};
/**
* Determines whether to automatically sync the schema if not in production.
* @default true
*/
syncSchema?: boolean | { force?: boolean; drop?: boolean };
};
export type BkndModeConfig<Args = any, Additional = {}> = BkndConfig<
Args,
Merge<BkndModeOptions & Additional>
>;
export async function makeModeConfig<
Args = any,
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
>({ app, ..._config }: Config, args: Args) {
const appConfig = typeof app === "function" ? await app(args) : app;
const config = {
..._config,
...appConfig,
} as Omit<Config, "app">;
if (typeof config.isProduction !== "boolean") {
$console.warn(
"You should set `isProduction` option when using managed modes to prevent accidental issues",
);
}
invariant(
typeof config.writer === "function",
"You must set the `writer` option when using managed modes",
);
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
const isProd = config.isProduction;
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
const syncSchemaOptions =
typeof config.syncSchema === "object"
? config.syncSchema
: {
force: config.syncSchema !== false,
drop: true,
};
if (!isProd) {
if (typesFilePath) {
if (plugins.some((p) => p.name === "bknd-sync-types")) {
throw new Error("You have to unregister the `syncTypes` plugin");
}
plugins.push(
syncTypes({
enabled: true,
includeFirstBoot: true,
write: async (et) => {
try {
await config.writer?.(typesFilePath, et.toString());
} catch (e) {
console.error(`Error writing types to"${typesFilePath}"`, e);
}
},
}) as any,
);
}
if (configFilePath) {
if (plugins.some((p) => p.name === "bknd-sync-config")) {
throw new Error("You have to unregister the `syncConfig` plugin");
}
plugins.push(
syncConfig({
enabled: true,
includeFirstBoot: true,
write: async (config) => {
try {
await writer?.(configFilePath, JSON.stringify(config, null, 2));
} catch (e) {
console.error(`Error writing config to "${configFilePath}"`, e);
}
},
}) as any,
);
}
if (syncSecretsOptions && syncSecretsOptions.enabled !== false) {
if (plugins.some((p) => p.name === "bknd-sync-secrets")) {
throw new Error("You have to unregister the `syncSecrets` plugin");
}
let outFile = syncSecretsOptions.outFile;
const format = syncSecretsOptions.format ?? "env";
if (!outFile) {
outFile = ["env", !syncSecretsOptions.includeSecrets && "example", format]
.filter(Boolean)
.join(".");
}
plugins.push(
syncSecrets({
enabled: true,
includeFirstBoot: true,
write: async (secrets) => {
const values = Object.fromEntries(
Object.entries(secrets).map(([key, value]) => [
key,
syncSecretsOptions.includeSecrets ? value : "",
]),
);
try {
if (format === "env") {
await writer?.(
outFile,
Object.entries(values)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
);
} else {
await writer?.(outFile, JSON.stringify(values, null, 2));
}
} catch (e) {
console.error(`Error writing secrets to "${outFile}"`, e);
}
},
}) as any,
);
}
}
return {
config,
isProd,
plugins,
syncSchemaOptions,
};
}

View File

@@ -8,6 +8,7 @@ export type BaseModuleApiOptions = {
host: string; host: string;
basepath?: string; basepath?: string;
token?: string; token?: string;
credentials?: RequestCredentials;
headers?: Headers; headers?: Headers;
token_transport?: "header" | "cookie" | "none"; token_transport?: "header" | "cookie" | "none";
verbose?: boolean; verbose?: boolean;
@@ -106,6 +107,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
const request = new Request(url, { const request = new Request(url, {
..._init, ..._init,
credentials: this.options.credentials,
method, method,
body, body,
headers, headers,

View File

@@ -5,7 +5,7 @@ import { entityTypes } from "data/entities/Entity";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module";
import type { EntityRelation } from "data/relations"; import type { EntityRelation } from "data/relations";
import type { Permission } from "core/security/Permission"; import type { Permission, PermissionContext } from "auth/authorize/Permission";
import { Exception } from "core/errors"; import { Exception } from "core/errors";
import { invariant, isPlainObject } from "bknd/utils"; import { invariant, isPlainObject } from "bknd/utils";
@@ -114,10 +114,20 @@ export class ModuleHelper {
entity.__replaceField(name, newField); entity.__replaceField(name, newField);
} }
async throwUnlessGranted( async granted<P extends Permission<any, any, any, any>>(
permission: Permission | string,
c: { context: ModuleBuildContextMcpContext; raw?: unknown }, c: { context: ModuleBuildContextMcpContext; raw?: unknown },
) { permission: P,
context: PermissionContext<P>,
): Promise<void>;
async granted<P extends Permission<any, any, undefined, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
): Promise<void>;
async granted<P extends Permission<any, any, any, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
context?: PermissionContext<P>,
): Promise<void> {
invariant(c.context.app, "app is not available in mcp context"); invariant(c.context.app, "app is not available in mcp context");
const auth = c.context.app.module.auth; const auth = c.context.app.module.auth;
if (!auth.enabled) return; if (!auth.enabled) return;
@@ -127,12 +137,6 @@ export class ModuleHelper {
} }
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
this.ctx.guard.granted(permission, user as any, context as any);
if (!this.ctx.guard.granted(permission, user)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403,
);
}
} }
} }

View File

@@ -1,6 +1,7 @@
import type { ConfigUpdateResponse } from "modules/server/SystemController"; import type { ConfigUpdateResponse } from "modules/server/SystemController";
import { ModuleApi } from "./ModuleApi"; import { ModuleApi } from "./ModuleApi";
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager"; import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
import type { TPermission } from "auth/authorize/Permission";
export type ApiSchemaResponse = { export type ApiSchemaResponse = {
version: number; version: number;
@@ -54,4 +55,8 @@ export class SystemApi extends ModuleApi<any> {
removeConfig<Module extends ModuleKey>(module: Module, path: string) { removeConfig<Module extends ModuleKey>(module: Module, path: string) {
return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]); return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
} }
permissions() {
return this.get<{ permissions: TPermission[]; context: object }>("permissions");
}
} }

View File

@@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager {
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined; private _stable_configs: ModuleConfigs | undefined;
// config used when syncing database
public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true };
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) { constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
let initial = {} as InitialModuleConfigs; let initial = {} as InitialModuleConfigs;
let booted_with = "partial" as any; let booted_with = "partial" as any;
@@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager {
const version_before = this.version(); const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.configs.json, { const [_version, _configs] = await migrate(version_before, result.configs.json, {
db: this.db db: this.db,
}); });
this._version = _version; this._version = _version;
@@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager {
this.logger.log("db sync requested"); this.logger.log("db sync requested");
// sync db // sync db
await ctx.em.schema().sync({ force: true }); await ctx.em.schema().sync(this.buildSyncConfig);
state.synced = true; state.synced = true;
// save // save

View File

@@ -1 +1,2 @@
export { auth, permission } from "auth/middlewares"; export { auth } from "auth/middlewares/auth.middleware";
export { permission } from "auth/middlewares/permission.middleware";

View File

@@ -1,10 +1,35 @@
import { Permission } from "core/security/Permission"; import { Permission } from "auth/authorize/Permission";
import { s } from "bknd/utils";
export const accessAdmin = new Permission("system.access.admin"); export const accessAdmin = new Permission("system.access.admin");
export const accessApi = new Permission("system.access.api"); export const accessApi = new Permission("system.access.api");
export const configRead = new Permission("system.config.read"); export const configRead = new Permission(
export const configReadSecrets = new Permission("system.config.read.secrets"); "system.config.read",
export const configWrite = new Permission("system.config.write"); {},
export const schemaRead = new Permission("system.schema.read"); s.object({
module: s.string().optional(),
}),
);
export const configReadSecrets = new Permission(
"system.config.read.secrets",
{},
s.object({
module: s.string().optional(),
}),
);
export const configWrite = new Permission(
"system.config.write",
{},
s.object({
module: s.string().optional(),
}),
);
export const schemaRead = new Permission(
"system.schema.read",
{},
s.object({
module: s.string().optional(),
}),
);
export const build = new Permission("system.build"); export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp"); export const mcp = new Permission("system.mcp");

View File

@@ -114,8 +114,9 @@ export class AdminController extends Controller {
}), }),
permission(SystemPermissions.schemaRead, { permission(SystemPermissions.schemaRead, {
onDenied: async (c) => { onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning"); addFlashMessage(c, "You are not allowed to read the schema", "warning");
}, },
context: (c) => ({}),
}), }),
async (c) => { async (c) => {
const obj: AdminBkndWindowContext = { const obj: AdminBkndWindowContext = {
@@ -139,9 +140,7 @@ export class AdminController extends Controller {
} }
if (auth_enabled) { if (auth_enabled) {
const redirectRouteParams = [ const options = {
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore
onGranted: async (c) => { onGranted: async (c) => {
// @todo: add strict test to permissions middleware? // @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) { if (c.get("auth")?.user) {
@@ -149,7 +148,11 @@ export class AdminController extends Controller {
return c.redirect(authRoutes.success); return c.redirect(authRoutes.success);
} }
}, },
}), context: (c) => ({}),
};
const redirectRouteParams = [
permission(SystemPermissions.accessAdmin, options as any),
permission(SystemPermissions.schemaRead, options),
async (c) => { async (c) => {
return c.html(c.get("html")!); return c.html(c.get("html")!);
}, },

View File

@@ -52,11 +52,16 @@ export class AppServer extends Module<AppServerConfig> {
} }
override async build() { override async build() {
const origin = this.config.cors.origin ?? ""; const origin = this.config.cors.origin ?? "*";
const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin];
const all_origins = origins.includes("*");
this.client.use( this.client.use(
"*", "*",
cors({ cors({
origin: origin.includes(",") ? origin.split(",").map((o) => o.trim()) : origin, origin: (origin: string) => {
if (all_origins) return origin;
return origins.includes(origin) ? origin : undefined;
},
allowMethods: this.config.cors.allow_methods, allowMethods: this.config.cors.allow_methods,
allowHeaders: this.config.cors.allow_headers, allowHeaders: this.config.cors.allow_headers,
credentials: this.config.cors.allow_credentials, credentials: this.config.cors.allow_credentials,
@@ -87,6 +92,10 @@ export class AppServer extends Module<AppServerConfig> {
} }
if (err instanceof AuthException) { if (err instanceof AuthException) {
if (isDebug()) {
return c.json(err.toJSON(), err.code);
}
return c.json(err.toJSON(), err.getSafeErrorAndCode().code); return c.json(err.toJSON(), err.getSafeErrorAndCode().code);
} }

View File

@@ -17,6 +17,7 @@ import {
mcp as mcpMiddleware, mcp as mcpMiddleware,
isNode, isNode,
type McpServer, type McpServer,
threw,
} from "bknd/utils"; } from "bknd/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
@@ -32,6 +33,7 @@ import { getVersion } from "core/env";
import type { Module } from "modules/Module"; import type { Module } from "modules/Module";
import { getSystemMcp } from "modules/mcp/system-mcp"; import { getSystemMcp } from "modules/mcp/system-mcp";
import type { DbModuleManager } from "modules/db/DbModuleManager"; import type { DbModuleManager } from "modules/db/DbModuleManager";
import type { TPermission } from "auth/authorize/Permission";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = { export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true; success: true;
@@ -46,7 +48,8 @@ export type SchemaResponse = {
schema: ModuleSchemas; schema: ModuleSchemas;
readonly: boolean; readonly: boolean;
config: ModuleConfigs; config: ModuleConfigs;
permissions: string[]; //permissions: string[];
permissions: TPermission[];
}; };
export class SystemController extends Controller { export class SystemController extends Controller {
@@ -67,10 +70,14 @@ export class SystemController extends Controller {
if (!config.mcp.enabled) { if (!config.mcp.enabled) {
return; return;
} }
const { permission, auth } = this.middlewares;
this.registerMcp(); this.registerMcp();
app.server.use( app.server.all(
config.mcp.path,
auth(),
permission(SystemPermissions.mcp, {}),
mcpMiddleware({ mcpMiddleware({
setup: async () => { setup: async () => {
if (!this._mcpServer) { if (!this._mcpServer) {
@@ -108,7 +115,6 @@ export class SystemController extends Controller {
explainEndpoint: true, explainEndpoint: true,
}, },
endpoint: { endpoint: {
path: config.mcp.path as any,
// @ts-ignore // @ts-ignore
_init: isNode() ? { duplex: "half" } : {}, _init: isNode() ? { duplex: "half" } : {},
}, },
@@ -119,7 +125,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void { private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares; const { permission } = this.middlewares;
// don't add auth again, it's already added in getController // don't add auth again, it's already added in getController
const hono = this.create().use(permission(SystemPermissions.configRead)); const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
if (!this.app.isReadOnly()) { if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager; const manager = this.app.modules as DbModuleManager;
@@ -130,7 +136,11 @@ export class SystemController extends Controller {
summary: "Get the raw config", summary: "Get the raw config",
tags: ["system"], tags: ["system"],
}), }),
permission([SystemPermissions.configReadSecrets]), permission(SystemPermissions.configReadSecrets, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @ts-expect-error "fetch" is private // @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch().then((r) => r?.configs)); return c.json(await this.app.modules.fetch().then((r) => r?.configs));
@@ -165,7 +175,11 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/set/:module", "/set/:module",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }), jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => { async (c) => {
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -194,7 +208,14 @@ export class SystemController extends Controller {
}, },
); );
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => { hono.post(
"/add/:module/:path",
permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
const value = await c.req.json(); const value = await c.req.json();
@@ -215,11 +236,16 @@ export class SystemController extends Controller {
config: this.app.module[module].config, config: this.app.module[module].config,
}; };
}); });
}); },
);
hono.patch( hono.patch(
"/patch/:module/:path", "/patch/:module/:path",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -239,7 +265,11 @@ export class SystemController extends Controller {
hono.put( hono.put(
"/overwrite/:module/:path", "/overwrite/:module/:path",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -259,7 +289,11 @@ export class SystemController extends Controller {
hono.delete( hono.delete(
"/remove/:module/:path", "/remove/:module/:path",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -295,7 +329,11 @@ export class SystemController extends Controller {
const { secrets } = c.req.valid("query"); const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param"); const { module } = c.req.valid("param");
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); if (secrets) {
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
module,
});
}
const config = this.app.toJSON(secrets); const config = this.app.toJSON(secrets);
@@ -326,7 +364,11 @@ export class SystemController extends Controller {
summary: "Get the schema for a module", summary: "Get the schema for a module",
tags: ["system"], tags: ["system"],
}), }),
permission(SystemPermissions.schemaRead), permission(SystemPermissions.schemaRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
jsc( jsc(
"query", "query",
s s
@@ -340,10 +382,22 @@ export class SystemController extends Controller {
async (c) => { async (c) => {
const module = c.req.param("module") as ModuleKey | undefined; const module = c.req.param("module") as ModuleKey | undefined;
const { config, secrets, fresh } = c.req.valid("query"); const { config, secrets, fresh } = c.req.valid("query");
const readonly = this.app.isReadOnly(); const readonly =
// either if app is read only in general
this.app.isReadOnly() ||
// or if user is not allowed to modify the config
threw(() => this.ctx.guard.granted(SystemPermissions.configWrite, c, { module }));
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c); if (config) {
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); this.ctx.guard.granted(SystemPermissions.configRead, c, {
module,
});
}
if (secrets) {
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
module,
});
}
const { version, ...schema } = this.app.getSchema(); const { version, ...schema } = this.app.getSchema();
@@ -368,11 +422,23 @@ export class SystemController extends Controller {
readonly, readonly,
schema, schema,
config: config ? this.app.toJSON(secrets) : undefined, config: config ? this.app.toJSON(secrets) : undefined,
permissions: this.app.modules.ctx().guard.getPermissionNames(), permissions: this.app.modules.ctx().guard.getPermissions(),
}); });
}, },
); );
hono.get(
"/permissions",
describeRoute({
summary: "Get the permissions",
tags: ["system"],
}),
(c) => {
const permissions = this.app.modules.ctx().guard.getPermissions();
return c.json({ permissions, context: this.app.module.auth.getGuardContextSchema() });
},
);
hono.post( hono.post(
"/build", "/build",
describeRoute({ describeRoute({
@@ -383,7 +449,7 @@ export class SystemController extends Controller {
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })), jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => { async (c) => {
const options = c.req.valid("query") as Record<string, boolean>; const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); this.ctx.guard.granted(SystemPermissions.build, c);
await this.app.build(options); await this.app.build(options);
return c.json({ return c.json({
@@ -455,7 +521,7 @@ export class SystemController extends Controller {
const { version, ...appConfig } = this.app.toJSON(); const { version, ...appConfig } = this.app.toJSON();
mcp.resource("system_config", "bknd://system/config", async (c) => { mcp.resource("system_config", "bknd://system/config", async (c) => {
await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c); await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {});
return c.json(this.app.toJSON(), { return c.json(this.app.toJSON(), {
title: "System Config", title: "System Config",
@@ -465,7 +531,9 @@ export class SystemController extends Controller {
"system_config_module", "system_config_module",
"bknd://system/config/{module}", "bknd://system/config/{module}",
async (c, { module }) => { async (c, { module }) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c); await this.ctx.helper.granted(c, SystemPermissions.configRead, {
module,
});
const m = this.app.modules.get(module as any) as Module; const m = this.app.modules.get(module as any) as Module;
return c.json(m.toJSON(), { return c.json(m.toJSON(), {
@@ -477,7 +545,7 @@ export class SystemController extends Controller {
}, },
) )
.resource("system_schema", "bknd://system/schema", async (c) => { .resource("system_schema", "bknd://system/schema", async (c) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {});
return c.json(this.app.getSchema(), { return c.json(this.app.getSchema(), {
title: "System Schema", title: "System Schema",
@@ -487,7 +555,9 @@ export class SystemController extends Controller {
"system_schema_module", "system_schema_module",
"bknd://system/schema/{module}", "bknd://system/schema/{module}",
async (c, { module }) => { async (c, { module }) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {
module,
});
const m = this.app.modules.get(module as any); const m = this.app.modules.get(module as any);
return c.json(m.getSchema().toJSON(), { return c.json(m.getSchema().toJSON(), {

View File

@@ -0,0 +1,74 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { timestamps } from "./timestamps.plugin";
import { em, entity, text } from "bknd";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("timestamps plugin", () => {
test("should ignore if no or invalid entities are provided", async () => {
const app = createApp({
options: {
plugins: [timestamps({ entities: [] })],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual([]);
{
const app = createApp({
options: {
plugins: [timestamps({ entities: ["posts"] })],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual([]);
}
});
test("should add timestamps to the specified entities", async () => {
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts", "invalid"] })],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]);
expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([
"id",
"title",
"created_at",
"updated_at",
]);
// insert
const mutator = app.em.mutator(app.em.entity("posts"));
const { data } = await mutator.insertOne({ title: "Hello" });
expect(data.created_at).toBeDefined();
expect(data.updated_at).toBeDefined();
expect(data.created_at).toBeInstanceOf(Date);
expect(data.updated_at).toBeInstanceOf(Date);
const diff = data.created_at.getTime() - data.updated_at.getTime();
expect(diff).toBeLessThan(10);
expect(diff).toBeGreaterThan(-1);
// update (set updated_at to null, otherwise it's too fast to test)
await app.em.connection.kysely
.updateTable("posts")
.set({ updated_at: null })
.where("id", "=", data.id)
.execute();
const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" });
expect(updatedData.updated_at).toBeDefined();
expect(updatedData.updated_at).toBeInstanceOf(Date);
});
});

View File

@@ -0,0 +1,86 @@
import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd";
import { $console } from "bknd/utils";
export type TimestampsPluginOptions = {
entities: string[];
setUpdatedOnCreate?: boolean;
};
/**
* This plugin adds `created_at` and `updated_at` fields to the specified entities.
* Add it to your plugins in `bknd.config.ts` like this:
*
* ```ts
* export default {
* plugins: [timestamps({ entities: ["posts"] })],
* }
* ```
*/
export function timestamps({
entities = [],
setUpdatedOnCreate = true,
}: TimestampsPluginOptions): AppPlugin {
return (app: App) => ({
name: "timestamps",
schema: () => {
if (entities.length === 0) {
$console.warn("No entities specified for timestamps plugin");
return;
}
const appEntities = app.em.entities.map((e) => e.name);
return em(
Object.fromEntries(
entities
.filter((e) => appEntities.includes(e))
.map((e) => [
e,
entity(e, {
created_at: datetime(),
updated_at: datetime(),
}),
]),
),
);
},
onBuilt: async () => {
app.emgr.onEvent(
DatabaseEvents.MutatorInsertBefore,
(event) => {
const { entity, data } = event.params;
if (entities.includes(entity.name)) {
return {
...data,
created_at: new Date(),
updated_at: setUpdatedOnCreate ? new Date() : null,
};
}
return data;
},
{
mode: "sync",
id: "bknd-timestamps",
},
);
app.emgr.onEvent(
DatabaseEvents.MutatorUpdateBefore,
async (event) => {
const { entity, data } = event.params;
if (entities.includes(entity.name)) {
return {
...data,
updated_at: new Date(),
};
}
return data;
},
{
mode: "sync",
id: "bknd-timestamps",
},
);
},
});
}

View File

@@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";

View File

@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import type { BkndAdminProps } from "ui/Admin"; import type { BkndAdminProps } from "ui/Admin";
import type { TPermission } from "auth/authorize/Permission";
export type BkndContext = { export type BkndContext = {
version: number; version: number;
readonly: boolean; readonly: boolean;
schema: ModuleSchemas; schema: ModuleSchemas;
config: ModuleConfigs; config: ModuleConfigs;
permissions: string[]; permissions: TPermission[];
hasSecrets: boolean; hasSecrets: boolean;
requireSecrets: () => Promise<void>; requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;

View File

@@ -53,9 +53,7 @@ export const ClientProvider = ({
[JSON.stringify(apiProps)], [JSON.stringify(apiProps)],
); );
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>( const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
apiProps.user ? api.getAuthState() : undefined,
);
return ( return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>

View File

@@ -1,6 +1,6 @@
import type { Api } from "Api"; import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { useApi } from "ui/client"; import { useApi } from "ui/client";
import { useState } from "react"; import { useState } from "react";
@@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => {
return mutate((k) => typeof k === "string" && k.startsWith(key)); return mutate((k) => typeof k === "string" && k.startsWith(key));
}; };
}; };
const mountOnceCache = new Map<string, any>();
/**
* Simple middleware to only load on first mount.
*/
export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
if (typeof key === "string") {
if (mountOnceCache.has(key)) {
return useSWRNext(key, fetcher, {
...config,
revalidateOnMount: false,
});
}
const swr = useSWRNext(key, fetcher, config);
if (swr.data) {
mountOnceCache.set(key, true);
}
return swr;
}
return useSWRNext(key, fetcher, config);
};

View File

@@ -16,8 +16,8 @@ type UseAuth = {
verified: boolean; verified: boolean;
login: (data: LoginData) => Promise<AuthResponse>; login: (data: LoginData) => Promise<AuthResponse>;
register: (data: LoginData) => Promise<AuthResponse>; register: (data: LoginData) => Promise<AuthResponse>;
logout: () => void; logout: () => Promise<void>;
verify: () => void; verify: () => Promise<void>;
setToken: (token: string) => void; setToken: (token: string) => void;
}; };
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
} }
async function logout() { async function logout() {
api.updateToken(undefined); await api.auth.logout();
invalidate(); await invalidate();
} }
async function verify() { async function verify() {
await api.verifyAuth(); await api.verifyAuth();
await invalidate();
} }
return { return {

View File

@@ -49,7 +49,7 @@ export function useBkndAuth() {
has_admin: Object.entries(config.auth.roles ?? {}).some( has_admin: Object.entries(config.auth.roles ?? {}).some(
([name, role]) => ([name, role]) =>
role.implicit_allow || role.implicit_allow ||
minimum_permissions.every((p) => role.permissions?.includes(p)), minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)),
), ),
}, },
routes: { routes: {

View File

@@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
const sizes = { const sizes = {
smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs",
small: "px-2 py-1.5 rounded-md gap-1 text-sm", small: "px-2 py-1.5 rounded-md gap-1 text-sm",
default: "px-3 py-2.5 rounded-md gap-1.5", default: "px-3 py-2.5 rounded-md gap-1.5",
large: "px-4 py-3 rounded-md gap-2.5 text-lg", large: "px-4 py-3 rounded-md gap-2.5 text-lg",
}; };
const iconSizes = { const iconSizes = {
small: 12, smaller: 12,
small: 14,
default: 16, default: 16,
large: 20, large: 20,
}; };

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { useTheme } from "ui/client/use-theme";
import { cn, importDynamicBrowserModule } from "ui/lib/utils";
export type CodePreviewProps = {
code: string;
className?: string;
lang?: string;
theme?: string;
enabled?: boolean;
};
export const CodePreview = ({
code,
className,
lang = "typescript",
theme: _theme,
enabled = true,
}: CodePreviewProps) => {
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
const $theme = useTheme();
const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light";
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setHighlightedHtml(null);
async function highlightCode() {
try {
// Dynamically import Shiki from CDN
const { codeToHtml } = await importDynamicBrowserModule(
"shiki",
"https://esm.sh/shiki@3.13.0",
);
if (cancelled) return;
const html = await codeToHtml(code, {
lang,
theme,
structure: "inline",
});
if (cancelled) return;
setHighlightedHtml(html);
} catch (error) {
console.error("Failed to load Shiki:", error);
// Fallback to plain text if Shiki fails to load
if (!cancelled) {
setHighlightedHtml(code);
}
}
}
highlightCode();
return () => {
cancelled = true;
};
}, [code, enabled]);
if (!highlightedHtml) {
return <pre className={cn("select-text cursor-text", className)}>{code}</pre>;
}
return (
<pre
className={cn("select-text cursor-text", className)}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
);
};

View File

@@ -1,19 +1,68 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { CodeEditorProps } from "./CodeEditor"; import type { CodeEditorProps } from "./CodeEditor";
import { useDebouncedCallback } from "@mantine/hooks";
const CodeEditor = lazy(() => import("./CodeEditor")); const CodeEditor = lazy(() => import("./CodeEditor"));
export function JsonEditor({ editable, className, ...props }: CodeEditorProps) { export type JsonEditorProps = Omit<CodeEditorProps, "value" | "onChange"> & {
value?: object;
onChange?: (value: object) => void;
emptyAs?: any;
onInvalid?: (error: Error) => void;
};
export function JsonEditor({
editable,
className,
value,
onChange,
onBlur,
emptyAs = undefined,
onInvalid,
...props
}: JsonEditorProps) {
const [editorValue, setEditorValue] = useState<string | null | undefined>(
value ? JSON.stringify(value, null, 2) : emptyAs,
);
const [error, setError] = useState<boolean>(false);
const handleChange = useDebouncedCallback((given: string) => {
try {
setError(false);
onChange?.(given ? JSON.parse(given) : emptyAs);
} catch (e) {
onInvalid?.(e as Error);
setError(true);
}
}, 250);
const handleBlur = (e) => {
try {
const formatted = JSON.stringify(value, null, 2);
setEditorValue(formatted);
} catch (e) {}
onBlur?.(e);
};
useEffect(() => {
if (!editorValue) {
setEditorValue(value ? JSON.stringify(value, null, 2) : emptyAs);
}
}, [value]);
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<CodeEditor <CodeEditor
className={twMerge( className={twMerge(
"flex w-full border border-muted", "flex w-full border border-muted",
!editable && "opacity-70", !editable && "opacity-70",
error && "border-red-500",
className, className,
)} )}
editable={editable} editable={editable}
_extensions={{ json: true }} _extensions={{ json: true }}
value={editorValue ?? undefined}
onChange={handleChange}
onBlur={handleBlur}
{...props} {...props}
/> />
</Suspense> </Suspense>

View File

@@ -28,8 +28,9 @@ export const Group = <E extends ElementType = "div">({
return ( return (
<Tag <Tag
{...props} {...props}
data-role="group"
className={twMerge( className={twMerge(
"flex flex-col gap-1.5", "flex flex-col gap-1.5 w-full",
as === "fieldset" && "border border-primary/10 p-3 rounded-md", as === "fieldset" && "border border-primary/10 p-3 rounded-md",
as === "fieldset" && error && "border-red-500", as === "fieldset" && error && "border-red-500",
error && "text-red-500", error && "text-red-500",

View File

@@ -5,19 +5,29 @@ import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { FieldComponent } from "./Field"; import { Field, FieldComponent, type FieldProps } from "./Field";
import { FieldWrapper } from "./FieldWrapper"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form"; import { FormContextOverride, useDerivedFieldContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils"; import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
export const ArrayField = ({ path = "" }: { path?: string }) => { export type ArrayFieldProps = {
path?: string;
labelAdd?: string;
wrapperProps?: Omit<FieldwrapperProps, "name" | "children">;
};
export const ArrayField = ({
path = "",
labelAdd = "Add",
wrapperProps = { wrapper: "fieldset" },
}: ArrayFieldProps) => {
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path); const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`; if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum // if unique items with enum
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) { if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
return ( return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset"> <FieldWrapper {...wrapperProps} name={path} schema={schema}>
<FieldComponent <FieldComponent
required required
name={path} name={path}
@@ -35,7 +45,7 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
} }
return ( return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset"> <FieldWrapper {...wrapperProps} name={path} schema={schema}>
<ArrayIterator name={path}> <ArrayIterator name={path}>
{({ value }) => {({ value }) =>
value?.map((v, index: number) => ( value?.map((v, index: number) => (
@@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
} }
</ArrayIterator> </ArrayIterator>
<div className="flex flex-row"> <div className="flex flex-row">
<ArrayAdd path={path} schema={schema} /> <ArrayAdd path={path} schema={schema} label={labelAdd} />
</div> </div>
</FieldWrapper> </FieldWrapper>
); );
}; };
const ArrayItem = memo(({ path, index, schema }: any) => { const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => { const {
value,
path: absolutePath,
...ctx
} = useDerivedFieldContext(path, (ctx) => {
return ctx.value?.[index]; return ctx.value?.[index];
}); });
const itemPath = suffixPath(path, index); const itemPath = suffixPath(absolutePath, index);
let subschema = schema.items; let subschema = schema.items;
const itemsMultiSchema = getMultiSchema(schema.items); const itemsMultiSchema = getMultiSchema(schema.items);
if (itemsMultiSchema) { if (itemsMultiSchema) {
@@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
subschema = _subschema; subschema = _subschema;
} }
const handleUpdate = useEvent((pointer: string, value: any) => {
ctx.setValue(pointer, value);
});
const handleDelete = useEvent((pointer: string) => { const handleDelete = useEvent((pointer: string) => {
ctx.deleteValue(pointer); ctx.deleteValue(pointer);
}); });
@@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
); );
return ( return (
<div key={itemPath} className="flex flex-row gap-2"> <FormContextOverride prefix={itemPath} schema={subschema!}>
<FieldComponent <div className="flex flex-row gap-2 w-full">
name={itemPath} {/* another wrap is required for primitive schemas */}
schema={subschema!} <AnotherField label={false} />
value={value}
onChange={(e) => {
handleUpdate(itemPath, coerce(e.target.value, subschema!));
}}
className="w-full"
/>
{DeleteButton} {DeleteButton}
</div> </div>
</FormContextOverride>
); );
}, isEqual); }, isEqual);
const AnotherField = (props: Partial<FieldProps>) => {
const { value } = useFormValue("");
const inputProps = {
// @todo: check, potentially just provide value
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
};
return <Field name={""} label={false} {...props} inputProps={inputProps} />;
};
const ArrayIterator = memo( const ArrayIterator = memo(
({ name, children }: any) => { ({ name, children }: any) => {
return children(useFormValue(name)); return children(useFormValue(name));
@@ -98,19 +113,25 @@ const ArrayIterator = memo(
(prev, next) => prev.value?.length === next.value?.length, (prev, next) => prev.value?.length === next.value?.length,
); );
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => { const ArrayAdd = ({
schema,
path: _path,
label = "Add",
}: { schema: JsonSchema; path: string; label?: string }) => {
const { const {
setValue, setValue,
value: { currentIndex }, value: { currentIndex },
path,
...ctx ...ctx
} = useDerivedFieldContext(path, (ctx) => { } = useDerivedFieldContext(_path, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 }; return { currentIndex: ctx.value?.length ?? 0 };
}); });
const itemsMultiSchema = getMultiSchema(schema.items); const itemsMultiSchema = getMultiSchema(schema.items);
const options = { addOptionalProps: true };
function handleAdd(template?: any) { function handleAdd(template?: any) {
const newPath = suffixPath(path, currentIndex); const newPath = suffixPath(path, currentIndex);
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items)); setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options));
} }
if (itemsMultiSchema) { if (itemsMultiSchema) {
@@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
}} }}
items={itemsMultiSchema.map((s, i) => ({ items={itemsMultiSchema.map((s, i) => ({
label: s!.title ?? `Option ${i + 1}`, label: s!.title ?? `Option ${i + 1}`,
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)), onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)),
}))} }))}
onClickItem={console.log} onClickItem={console.log}
> >
<Button IconLeft={IconLibraryPlus}>Add</Button> <Button IconLeft={IconLibraryPlus}>{label}</Button>
</Dropdown> </Dropdown>
); );
} }
return <Button onClick={() => handleAdd()}>Add</Button>; return <Button onClick={() => handleAdd()}>{label}</Button>;
}; };

View File

@@ -72,7 +72,7 @@ const FieldImpl = ({
); );
if (isType(schema.type, "object")) { if (isType(schema.type, "object")) {
return <ObjectField path={name} />; return <ObjectField path={name} wrapperProps={props} />;
} }
if (isType(schema.type, "array")) { if (isType(schema.type, "array")) {
@@ -217,14 +217,14 @@ export type CustomFieldProps<Data = any> = {
) => React.ReactNode; ) => React.ReactNode;
}; };
export const CustomField = <Data = any>({ export function CustomField<Data = any>({
path: _path, path: _path,
valueStrict = true, valueStrict = true,
deriveFn, deriveFn,
children, children,
}: CustomFieldProps<Data>) => { }: CustomFieldProps<Data>) {
const ctx = useDerivedFieldContext(_path, deriveFn); const ctx = useDerivedFieldContext(_path, deriveFn);
const $value = useFormValue(ctx.path, { strict: valueStrict }); const $value = useFormValue(_path, { strict: valueStrict });
const setValue = (value: any) => ctx.setValue(ctx.path, value); const setValue = (value: any) => ctx.setValue(ctx.path, value);
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue }); return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
}; }

View File

@@ -1,4 +1,4 @@
import { IconBug } from "@tabler/icons-react"; import { IconBug, IconInfoCircle } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library"; import type { JsonSchema } from "json-schema-library";
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react"; import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
@@ -11,6 +11,8 @@ import {
} from "ui/components/form/json-schema-form/Form"; } from "ui/components/form/json-schema-form/Form";
import { Popover } from "ui/components/overlay/Popover"; import { Popover } from "ui/components/overlay/Popover";
import { getLabel } from "./utils"; import { getLabel } from "./utils";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@mantine/core";
export type FieldwrapperProps = { export type FieldwrapperProps = {
name: string; name: string;
@@ -23,8 +25,9 @@ export type FieldwrapperProps = {
children: ReactElement | ReactNode; children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom"; errorPlacement?: "top" | "bottom";
description?: string; description?: string;
descriptionPlacement?: "top" | "bottom"; descriptionPlacement?: "top" | "bottom" | "label";
fieldId?: string; fieldId?: string;
className?: string;
}; };
export function FieldWrapper({ export function FieldWrapper({
@@ -38,6 +41,7 @@ export function FieldWrapper({
descriptionPlacement = "bottom", descriptionPlacement = "bottom",
children, children,
fieldId, fieldId,
className,
...props ...props
}: FieldwrapperProps) { }: FieldwrapperProps) {
const errors = useFormError(name, { strict: true }); const errors = useFormError(name, { strict: true });
@@ -50,17 +54,23 @@ export function FieldWrapper({
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage> <Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
); );
const Description = description && ( const Description = description ? (
["top", "bottom"].includes(descriptionPlacement) ? (
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}> <Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
{description} {description}
</Formy.Help> </Formy.Help>
); ) : (
<Tooltip label={description}>
<IconInfoCircle className="size-4 opacity-50" />
</Tooltip>
)
) : null;
return ( return (
<Formy.Group <Formy.Group
error={errors.length > 0} error={errors.length > 0}
as={wrapper === "fieldset" ? "fieldset" : "div"} as={wrapper === "fieldset" ? "fieldset" : "div"}
className={hidden ? "hidden" : "relative"} className={twMerge(hidden ? "hidden" : "relative", className)}
> >
{errorPlacement === "top" && Errors} {errorPlacement === "top" && Errors}
<FieldDebug name={name} schema={schema} required={required} /> <FieldDebug name={name} schema={schema} required={required} />
@@ -69,14 +79,15 @@ export function FieldWrapper({
<Formy.Label <Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"} as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={fieldId} htmlFor={fieldId}
className="self-start" className="self-start flex flex-row gap-1 items-center"
> >
{label} {required && <span className="font-medium opacity-30">*</span>} {label} {required && <span className="font-medium opacity-30">*</span>}
{descriptionPlacement === "label" && Description}
</Formy.Label> </Formy.Label>
)} )}
{descriptionPlacement === "top" && Description} {descriptionPlacement === "top" && Description}
<div className="flex flex-row gap-2"> <div className="flex flex-row flex-grow gap-2">
<div className="flex flex-1 flex-col gap-3"> <div className="flex flex-1 flex-col gap-3">
{Children.count(children) === 1 && isValidElement(children) {Children.count(children) === 1 && isValidElement(children)
? cloneElement(children, { ? cloneElement(children, {

View File

@@ -80,6 +80,7 @@ export function Form<
onInvalidSubmit, onInvalidSubmit,
validateOn = "submit", validateOn = "submit",
hiddenSubmit = true, hiddenSubmit = true,
beforeSubmit,
ignoreKeys = [], ignoreKeys = [],
options = {}, options = {},
readOnly = false, readOnly = false,
@@ -90,6 +91,7 @@ export function Form<
initialOpts?: LibTemplateOptions; initialOpts?: LibTemplateOptions;
ignoreKeys?: string[]; ignoreKeys?: string[];
onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void; onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void;
beforeSubmit?: (data: Data) => Data;
onSubmit?: (data: Data) => void | Promise<void>; onSubmit?: (data: Data) => void | Promise<void>;
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void; onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
hiddenSubmit?: boolean; hiddenSubmit?: boolean;
@@ -128,7 +130,7 @@ export function Form<
if (errors.length === 0) { if (errors.length === 0) {
await onSubmit(data as Data); await onSubmit(data as Data);
} else { } else {
console.log("invalid", errors); console.error("form: invalid", { data, errors });
onInvalidSubmit?.(errors, data); onInvalidSubmit?.(errors, data);
} }
} catch (e) { } catch (e) {
@@ -177,7 +179,8 @@ export function Form<
}); });
const validate = useEvent((_data?: Partial<Data>) => { const validate = useEvent((_data?: Partial<Data>) => {
const actual = _data ?? getCurrentState()?.data; const before = beforeSubmit ?? ((a: any) => a);
const actual = before((_data as any) ?? getCurrentState()?.data);
const errors = lib.validate(actual, schema); const errors = lib.validate(actual, schema);
setFormState((prev) => ({ ...prev, errors })); setFormState((prev) => ({ ...prev, errors }));
return { data: actual, errors }; return { data: actual, errors };
@@ -378,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) {
if (options?.debug !== true && force !== true) return null; if (options?.debug !== true && force !== true) return null;
const ctx = useFormStateSelector((s) => s); const ctx = useFormStateSelector((s) => s);
return <JsonViewer json={ctx} expand={99} />; return <JsonViewer json={ctx} expand={99} showCopy />;
} }

View File

@@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
import { AnyOfField } from "./AnyOfField"; import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field"; import { Field } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { type JSONSchema, useDerivedFieldContext } from "./Form"; import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form";
import { JsonEditor } from "ui/components/code/JsonEditor";
export type ObjectFieldProps = { export type ObjectFieldProps = {
path?: string; path?: string;
@@ -11,7 +12,7 @@ export type ObjectFieldProps = {
}; };
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => { export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
const { schema, ...ctx } = useDerivedFieldContext(path); const { schema } = useDerivedFieldContext(path);
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`; if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][]; const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
@@ -24,7 +25,7 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
{...wrapperProps} {...wrapperProps}
> >
{properties.length === 0 ? ( {properties.length === 0 ? (
<i className="opacity-50">No properties</i> <ObjectJsonField path={path} />
) : ( ) : (
properties.map(([prop, schema]) => { properties.map(([prop, schema]) => {
const name = [path, prop].filter(Boolean).join("."); const name = [path, prop].filter(Boolean).join(".");
@@ -40,3 +41,9 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
</FieldWrapper> </FieldWrapper>
); );
}; };
export const ObjectJsonField = ({ path }: { path: string }) => {
const { value } = useFormValue(path);
const { setValue, path: absolutePath } = useDerivedFieldContext(path);
return <JsonEditor value={value} onChange={(value) => setValue(absolutePath, value)} />;
};

View File

@@ -67,18 +67,23 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data
return false; return false;
} }
try {
const childSchema = lib.getSchema({ pointer, data, schema }); const childSchema = lib.getSchema({ pointer, data, schema });
if (typeof childSchema === "object" && "const" in childSchema) { if (typeof childSchema === "object" && "const" in childSchema) {
return true; return true;
} }
} catch (e) {
return false;
}
const parentPointer = getParentPointer(pointer); const parentPointer = getParentPointer(pointer);
const parentSchema = lib.getSchema({ pointer: parentPointer, data }); const parentSchema = lib.getSchema({ pointer: parentPointer, data });
const required = parentSchema?.required?.includes(pointer.split("/").pop()!); const l = pointer.split("/").pop();
const required = parentSchema?.required?.includes(l);
return !!required; return !!required;
} catch (e) { } catch (e) {
console.error("isRequired", { pointer, schema, data, e }); console.warn("isRequired", { pointer, schema, data, e });
return false; return false;
} }
} }

View File

@@ -10,23 +10,13 @@ export default function JsonField({
readonly, readonly,
...props ...props
}: FieldProps) { }: FieldProps) {
const value = JSON.stringify(formData, null, 2);
function handleChange(data) {
try {
onChange(JSON.parse(data));
} catch (err) {
console.error(err);
}
}
const isDisabled = disabled || readonly; const isDisabled = disabled || readonly;
const id = props.idSchema.$id; const id = props.idSchema.$id;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label label={props.name} id={id} /> <Label label={props.name} id={id} />
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} /> <JsonEditor value={formData} editable={!isDisabled} onChange={onChange} />
</div> </div>
); );
} }

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