diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index b13935a..caa5566 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -152,12 +152,12 @@ describe("authorize", () => { expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow(); // get the filter for member role - expect(guard.getPolicyFilter(read, { role: "member" })).toEqual({ + expect(guard.filters(read, { role: "member" }).filter).toEqual({ type: "member", }); // get filter for guest - expect(guard.getPolicyFilter(read, {})).toBeUndefined(); + expect(guard.filters(read, {}).filter).toBeUndefined(); }); test("guest should only read posts that are public", () => { @@ -226,7 +226,7 @@ describe("authorize", () => { expect(() => guard.granted(read, {}, { entity: "users" })).toThrow(); // and guests can only read public posts - expect(guard.getPolicyFilter(read, {}, { entity: "posts" })).toEqual({ + expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({ public: true, }); @@ -236,7 +236,7 @@ describe("authorize", () => { // member should not have a filter expect( - guard.getPolicyFilter(read, { role: "member" }, { entity: "posts" }), + guard.filters(read, { role: "member" }, { entity: "posts" }).filter, ).toBeUndefined(); }); }); diff --git a/app/__test__/auth/authorize/data.permissions.test.ts b/app/__test__/auth/authorize/data.permissions.test.ts new file mode 100644 index 0000000..6ff0c3e --- /dev/null +++ b/app/__test__/auth/authorize/data.permissions.test.ts @@ -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 = {}) { + 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 = {}) { + 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); + }); + }); +}); diff --git a/app/__test__/auth/authorize/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts similarity index 93% rename from app/__test__/auth/authorize/SystemController.spec.ts rename to app/__test__/auth/authorize/http/SystemController.spec.ts index 8400f46..40e6493 100644 --- a/app/__test__/auth/authorize/SystemController.spec.ts +++ b/app/__test__/auth/authorize/http/SystemController.spec.ts @@ -10,7 +10,7 @@ async function makeApp(config: Partial = {}) { return app; } -describe("SystemController", () => { +describe.skip("SystemController", () => { it("...", async () => { const app = await makeApp(); const controller = new SystemController(app); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index 78abdd0..14a01ee 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -122,26 +122,10 @@ describe("Guard", () => { const guard = new Guard([p], [r], { enabled: true, }); - expect( - guard.getPolicyFilter( - p, - { - role: r.name, - }, - { a: 1 }, - ), - ).toEqual({ foo: "bar" }); - expect( - guard.getPolicyFilter( - p, - { - role: r.name, - }, - { a: 2 }, - ), - ).toBeUndefined(); + 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.getPolicyFilter(p, {}, { a: 1 })).toBeUndefined(); + expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined(); }); it("collects filters for default role", () => { @@ -172,26 +156,26 @@ describe("Guard", () => { }); expect( - guard.getPolicyFilter( + guard.filters( p, { role: r.name, }, { a: 1 }, - ), + ).filter, ).toEqual({ foo: "bar" }); expect( - guard.getPolicyFilter( + 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.getPolicyFilter(p, {}, { a: 1 })).toEqual({ foo: "bar" }); + expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" }); }); }); diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index b7d4c96..8957e9e 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -272,6 +272,7 @@ describe("Core Utils", async () => { }, /^@([a-z\.]+)$/, variables7, + null, ); expect(result7).toEqual({ number: 123, @@ -288,20 +289,85 @@ describe("Core Utils", async () => { ); expect(result8).toEqual({ message: "The value is 123!" }); - // test mixed scenarios + // test with fallback parameter + const obj9 = { user: "@user.id", config: "@config.theme" }; + const variables9 = {}; // empty context const result9 = utils.recursivelyReplacePlaceholders( - { - fullMatch: "@test.value", // should preserve number type - partialMatch: "Value: @test.value", // should convert to string - noMatch: "static text", - }, + obj9, /^@([a-z\.]+)$/, - variables7, + variables9, + null, ); - expect(result9).toEqual({ - fullMatch: 123, // number preserved - partialMatch: "Value: @test.value", // no replacement (pattern requires full match) - noMatch: "static text", + 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, + }, + }, }); }); }); diff --git a/app/__test__/data/data.test.ts b/app/__test__/data/data.test.ts index 10cf8ac..416b5c0 100644 --- a/app/__test__/data/data.test.ts +++ b/app/__test__/data/data.test.ts @@ -30,9 +30,9 @@ describe("some tests", async () => { const query = await em.repository(users).findId(1); 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(); }); diff --git a/app/package.json b/app/package.json index f932580..c40f3b1 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.5", + "jsonv-ts": "0.8.6", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b94aa4b..99f1000 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -61,7 +61,9 @@ export class AuthController extends Controller { hono.post( "/create", permission(AuthPermissions.createUser, {}), - permission(DataPermissions.entityCreate, {}), + permission(DataPermissions.entityCreate, { + context: (c) => ({ entity: this.auth.config.entity_name }), + }), describeRoute({ summary: "Create a new user", tags: ["auth"], @@ -224,7 +226,6 @@ export class AuthController extends Controller { const roles = Object.keys(this.auth.config.roles ?? {}); mcp.tool( - // @todo: needs permission "auth_user_create", { description: "Create a new user", @@ -246,7 +247,6 @@ export class AuthController extends Controller { ); mcp.tool( - // @todo: needs permission "auth_user_token", { description: "Get a user token", @@ -264,7 +264,6 @@ export class AuthController extends Controller { ); mcp.tool( - // @todo: needs permission "auth_user_password_change", { description: "Change a user's password", @@ -286,7 +285,6 @@ export class AuthController extends Controller { ); mcp.tool( - // @todo: needs permission "auth_user_password_test", { description: "Test a user's password", diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 37ee842..85349a9 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,5 +1,5 @@ import { Exception } from "core/errors"; -import { $console, type s } from "bknd/utils"; +import { $console, mergeObject, type s } from "bknd/utils"; import type { Permission, PermissionContext } from "auth/authorize/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; @@ -232,41 +232,85 @@ export class Guard { }); } - getPolicyFilter

>( + filters

>( permission: P, c: GuardContext, context: PermissionContext

, - ): PolicySchema["filter"] | undefined; - getPolicyFilter

>( - permission: P, - c: GuardContext, - ): PolicySchema["filter"] | undefined; - getPolicyFilter

>( + ); + filters

>(permission: P, c: GuardContext); + filters

>( permission: P, c: GuardContext, context?: PermissionContext

, - ): PolicySchema["filter"] | undefined { + ) { if (!permission.isFilterable()) { - $console.debug("getPolicyFilter: permission is not filterable, returning undefined"); - return; + throw new GuardPermissionsException(permission, undefined, "Permission is not filterable"); } - const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context); + const { + ctx: _ctx, + exists, + role, + user, + rolePermission, + } = this.collect(permission, c, context); // validate context - let ctx = Object.assign({}, _ctx); + let ctx = Object.assign( + { + user, + }, + _ctx, + ); + if (permission.context) { - ctx = permission.parseContext(ctx); + 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); - return meets ? policy.content.filter : undefined; + if (meets) { + policies.push(policy); + filters.push(policy.getReplacedFilter(ctx)); + } } } } - return; + + const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined; + return { + filters, + filter, + policies, + merge: (givenFilter: object | undefined) => { + return mergeObject(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; + }, + }; } } diff --git a/app/src/auth/authorize/Permission.ts b/app/src/auth/authorize/Permission.ts index e18a98c..cfd5963 100644 --- a/app/src/auth/authorize/Permission.ts +++ b/app/src/auth/authorize/Permission.ts @@ -54,6 +54,8 @@ export class Permission< } 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) { diff --git a/app/src/auth/authorize/Policy.ts b/app/src/auth/authorize/Policy.ts index 9995e20..06357f1 100644 --- a/app/src/auth/authorize/Policy.ts +++ b/app/src/auth/authorize/Policy.ts @@ -21,8 +21,15 @@ export class Policy { }) as Schema; } - replace(context: object, vars?: Record) { - return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; + replace(context: object, vars?: Record, 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) { diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 65f18f9..33c6a43 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -372,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean { export function getPath( object: object, _path: string | (string | number)[], - defaultValue = undefined, + defaultValue: any = undefined, ): any { const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path; @@ -517,6 +517,7 @@ export function recursivelyReplacePlaceholders( obj: any, pattern: RegExp, variables: Record, + fallback?: any, ) { if (typeof obj === "string") { // check if the entire string matches the pattern @@ -524,24 +525,28 @@ export function recursivelyReplacePlaceholders( 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); - return value !== undefined ? value : obj; + 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); + const value = getPath(variables, key, null); // convert to string for partial replacements - return value !== undefined ? String(value) : match; + return value !== null + ? String(value) + : fallback !== undefined + ? String(fallback) + : match; }); } } if (Array.isArray(obj)) { - return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables)); + 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); + acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback); return acc; }, {} as object); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index d4f9cdf..adceffa 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -9,6 +9,7 @@ import { pickKeys, mcpTool, convertNumberedObjectToArray, + mergeObject, } from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; @@ -95,7 +96,9 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Retrieve data schema", tags: ["data"], @@ -121,7 +124,9 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Retrieve entity schema", tags: ["data"], @@ -161,7 +166,9 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Retrieve entity info", tags: ["data"], @@ -213,7 +220,9 @@ export class DataController extends Controller { // fn: count hono.post( "/:entity/fn/count", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Count entities", tags: ["data"], @@ -236,7 +245,9 @@ export class DataController extends Controller { // fn: exists hono.post( "/:entity/fn/exists", - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), describeRoute({ summary: "Check if entity exists", tags: ["data"], @@ -285,16 +296,26 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), tags: ["data"], }), - permission(DataPermissions.entityRead, {}), jsc("param", s.object({ entity: entitiesEnum })), jsc("query", repoQuery, { skipOpenAPI: true }), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), async (c) => { const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } + + const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, { + entity, + }); + 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 }); }, @@ -308,7 +329,9 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["offset", "sort", "select"]), tags: ["data"], }), - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_read_one", { inputSchema: { param: s.object({ entity: entitiesEnum, id: idType }), @@ -326,11 +349,19 @@ export class DataController extends Controller { jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { const { entity, id } = c.req.valid("param"); - if (!this.entityExists(entity)) { + if (!this.entityExists(entity) || !id) { return this.notFound(c); } 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 }); }, @@ -344,7 +375,9 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(), tags: ["data"], }), - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ ...c.req.param() }) as any, + }), jsc( "param", s.object({ @@ -361,9 +394,20 @@ export class DataController extends Controller { } const options = c.req.valid("query") as RepoQuery; - const result = await this.em + const { entity: newEntity } = this.em .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 }); }, @@ -390,7 +434,9 @@ export class DataController extends Controller { }, tags: ["data"], }), - permission(DataPermissions.entityRead, {}), + permission(DataPermissions.entityRead, { + context: (c) => ({ entity: c.req.param("entity") }), + }), mcpTool("data_entity_read_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -405,7 +451,13 @@ export class DataController extends Controller { return this.notFound(c); } 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 }); }, @@ -421,7 +473,9 @@ export class DataController extends Controller { summary: "Insert one or many", tags: ["data"], }), - permission(DataPermissions.entityCreate, {}), + permission(DataPermissions.entityCreate, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_insert"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), @@ -438,6 +492,12 @@ export class DataController extends Controller { // to transform all validation targets into a single object const body = convertNumberedObjectToArray(_body); + this.ctx.guard + .filters(DataPermissions.entityCreate, c, { + entity, + }) + .matches(body, { throwOnError: true }); + if (Array.isArray(body)) { const result = await this.em.mutator(entity).insertMany(body); return c.json(result, 201); @@ -455,7 +515,9 @@ export class DataController extends Controller { summary: "Update many", tags: ["data"], }), - permission(DataPermissions.entityUpdate, {}), + permission(DataPermissions.entityUpdate, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_update_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -482,7 +544,10 @@ export class DataController extends Controller { update: EntityData; 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); }, @@ -495,7 +560,9 @@ export class DataController extends Controller { summary: "Update one", tags: ["data"], }), - permission(DataPermissions.entityUpdate, {}), + permission(DataPermissions.entityUpdate, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_update_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("json", s.object({})), @@ -505,6 +572,17 @@ export class DataController extends Controller { return this.notFound(c); } 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); return c.json(result); @@ -518,7 +596,9 @@ export class DataController extends Controller { summary: "Delete one", tags: ["data"], }), - permission(DataPermissions.entityDelete, {}), + permission(DataPermissions.entityDelete, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_delete_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), async (c) => { @@ -526,6 +606,18 @@ export class DataController extends Controller { if (!this.entityExists(entity)) { 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); return c.json(result); @@ -539,7 +631,9 @@ export class DataController extends Controller { summary: "Delete many", tags: ["data"], }), - permission(DataPermissions.entityDelete, {}), + permission(DataPermissions.entityDelete, { + context: (c) => ({ ...c.req.param() }) as any, + }), mcpTool("data_entity_delete_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -554,7 +648,10 @@ export class DataController extends Controller { return this.notFound(c); } 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); }, diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 13554a6..3d8f432 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -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 { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; @@ -280,16 +280,11 @@ export class Repository>, ): Promise> { - const { qb, options } = this.buildQuery( - { - ..._options, - where: { [this.entity.getPrimaryField().name]: id }, - limit: 1, - }, - ["offset", "sort"], - ); + if (typeof id === "undefined" || id === null) { + throw new InvalidSearchParamsException("id is required"); + } - return this.single(qb, options) as any; + return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options); } async findOne( @@ -315,23 +310,27 @@ export class Repository 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 async findManyByReference( id: PrimaryFieldType, reference: string, _options?: Partial>, ): Promise> { - const entity = this.entity; - 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 { entity: newEntity, relation } = this.getEntityByReference(reference); const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference); if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) { throw new Error( diff --git a/app/src/data/permissions/index.ts b/app/src/data/permissions/index.ts index 124980e..f832716 100644 --- a/app/src/data/permissions/index.ts +++ b/app/src/data/permissions/index.ts @@ -1,9 +1,51 @@ import { Permission } from "auth/authorize/Permission"; +import { s } from "bknd/utils"; -export const entityRead = new Permission("data.entity.read"); -export const entityCreate = new Permission("data.entity.create"); -export const entityUpdate = new Permission("data.entity.update"); -export const entityDelete = new Permission("data.entity.delete"); +export const entityRead = new Permission( + "data.entity.read", + { + 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 rawQuery = new Permission("data.raw.query"); export const rawMutate = new Permission("data.raw.mutate"); diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index bc81a83..4669022 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -217,14 +217,14 @@ export type CustomFieldProps = { ) => React.ReactNode; }; -export const CustomField = ({ +export function CustomField({ path: _path, valueStrict = true, deriveFn, children, -}: CustomFieldProps) => { +}: CustomFieldProps) { 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); return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue }); -}; +} diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index 274c162..acfa25b 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -80,6 +80,7 @@ export function Form< onInvalidSubmit, validateOn = "submit", hiddenSubmit = true, + beforeSubmit, ignoreKeys = [], options = {}, readOnly = false, @@ -90,6 +91,7 @@ export function Form< initialOpts?: LibTemplateOptions; ignoreKeys?: string[]; onChange?: (data: Partial, name: string, value: any, context: FormContext) => void; + beforeSubmit?: (data: Data) => Data; onSubmit?: (data: Data) => void | Promise; onInvalidSubmit?: (errors: JsonError[], data: Partial) => void; hiddenSubmit?: boolean; @@ -177,7 +179,8 @@ export function Form< }); const validate = useEvent((_data?: Partial) => { - 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); setFormState((prev) => ({ ...prev, errors })); return { data: actual, errors }; @@ -378,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) { if (options?.debug !== true && force !== true) return null; const ctx = useFormStateSelector((s) => s); - return ; + return ; } diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index ff7c39d..54a1bfd 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -2,12 +2,12 @@ import { useBknd } from "ui/client/bknd"; import { Message } from "ui/components/display/Message"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { useNavigate } from "ui/lib/routes"; import { isDebug } from "core/env"; import { Dropdown } from "ui/components/overlay/Dropdown"; import { IconButton } from "ui/components/buttons/IconButton"; -import { TbAdjustments, TbDots, TbLock, TbLockOpen, TbLockOpen2 } from "react-icons/tb"; +import { TbAdjustments, TbDots, TbFilter, TbTrash } from "react-icons/tb"; import { Button } from "ui/components/buttons/Button"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { routes } from "ui/lib/routes"; @@ -18,17 +18,23 @@ import { ucFirst, type s } from "bknd/utils"; import type { ModuleSchemas } from "bknd"; import { ArrayField, + CustomField, Field, + FieldWrapper, Form, + FormContextOverride, FormDebug, + ObjectField, Subscribe, + useDerivedFieldContext, useFormContext, useFormValue, } from "ui/components/form/json-schema-form"; import type { TPermission } from "auth/authorize/Permission"; import type { RoleSchema } from "auth/authorize/Role"; -import { SegmentedControl, Tooltip } from "@mantine/core"; +import { Indicator, SegmentedControl, Tooltip } from "@mantine/core"; import { cn } from "ui/lib/utils"; +import type { PolicySchema } from "auth/authorize/Policy"; export function AuthRolesEdit(props) { useBrowserTitle(["Auth", "Roles", props.params.role]); @@ -66,21 +72,39 @@ function AuthRolesEditInternal({ params }) { const { config, schema: authSchema, actions } = useBkndAuth(); const roleName = params.role; const role = config.roles?.[roleName]; - const { readonly } = useBknd(); + const { readonly, permissions } = useBknd(); const schema = getSchema(authSchema); + const data = { + ...role, + // this is to maintain array structure + permissions: permissions.map((p) => { + return role?.permissions?.find((v: any) => v.permission === p.name); + }), + }; - async function handleDelete() {} - async function handleUpdate(data: any) { - console.log("data", data); - const success = await actions.roles.patch(roleName, data); - console.log("success", success); - /* if (success) { + async function handleDelete() { + const success = await actions.roles.delete(roleName); + if (success) { navigate(routes.auth.roles.list()); - } */ + } + } + async function handleUpdate(data: any) { + await actions.roles.patch(roleName, data); } return ( -

+ { + return { + ...data, + permissions: [...Object.values(data.permissions)], + }; + }} + onSubmit={handleUpdate} + > @@ -196,14 +220,21 @@ const Permissions = () => { const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => { const path = `permissions.${index}`; - const { value } = useFormValue(path); + const { value } = useDerivedFieldContext("permissions", (ctx) => { + const v = ctx.value; + if (!Array.isArray(v)) return undefined; + return v.find((v) => v && v.permission === permission.name); + }); const { setValue, deleteValue } = useFormContext(); const [open, setOpen] = useState(false); const data = value as PermissionData | undefined; + const policiesCount = data?.policies?.length ?? 0; + const hasContext = !!permission.context; async function handleSwitch() { if (data) { - deleteValue(path); + setValue(path, undefined); + setOpen(false); } else { setValue(path, { permission: permission.name, @@ -220,34 +251,125 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu className={cn("flex flex-col border border-muted", open && "border-primary/20")} >
-
{permission.name}
+
+ {permission.name} + {permission.filterable && ( + + + + )} +
+
- - +
+ {policiesCount > 0 && ( +
+ {policiesCount} +
+ )} setOpen((o) => !o)} /> - +
+
{open && (
- + {/* + /> */}
)}
); }; + +const Policies = ({ path, permission }: { path: string; permission: TPermission }) => { + const { value: _value } = useFormValue(path); + const { setValue, schema: policySchema, lib, deleteValue } = useDerivedFieldContext(path); + const value = _value ?? []; + + function handleAdd() { + setValue( + `${path}.${value.length}`, + lib.getTemplate(undefined, policySchema!.items, { + addOptionalProps: true, + }), + ); + } + + function handleDelete(index: number) { + deleteValue(`${path}.${index}`); + } + + return ( +
0 && "gap-8")}> +
+ {value.map((policy, i) => ( + + {i > 0 &&
} +
+
+ +
+ handleDelete(i)} size="sm" /> +
+ + ))} +
+
+ +
+
+ ); +}; + +const Policy = ({ + permission, +}: { + permission: TPermission; +}) => { + const { value } = useFormValue(""); + return ( +
+ + + + {({ value, setValue }) => ( + + setValue(value)} + data={ + ["allow", "deny", permission.filterable ? "filter" : undefined] + .filter(Boolean) + .map((effect) => ({ + label: ucFirst(effect ?? ""), + value: effect, + })) as any + } + /> + + )} + + + {value?.effect === "filter" && ( + + )} +
+ ); +}; diff --git a/app/src/ui/routes/auth/auth.roles.tsx b/app/src/ui/routes/auth/auth.roles.tsx index 15596bf..75793fe 100644 --- a/app/src/ui/routes/auth/auth.roles.tsx +++ b/app/src/ui/routes/auth/auth.roles.tsx @@ -35,6 +35,9 @@ function AuthRolesListInternal() { transformObject(config.roles ?? {}, (role, name) => ({ role: name, permissions: role.permissions?.map((p) => p.permission) as string[], + policies: role.permissions + ?.flatMap((p) => p.policies?.length ?? 0) + .reduce((acc, curr) => acc + curr, 0), is_default: role.is_default ?? false, implicit_allow: role.implicit_allow ?? false, })), @@ -107,6 +110,9 @@ const renderValue = ({ value, property }) => { if (["is_default", "implicit_allow"].includes(property)) { return value ? Yes : No; } + if (property === "policies") { + return value ? {value} : 0; + } if (property === "permissions") { const max = 3; diff --git a/bun.lock b/bun.lock index 8b5995e..ab63856 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.18.0-rc.6", + "version": "0.18.1", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -35,7 +35,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.4", + "jsonv-ts": "0.8.6", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -1243,7 +1243,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -2529,7 +2529,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.8.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-TZOyAVGBZxHuzk09NgJCx2dbeh0XqVWVKHU1PtIuvjT9XO7zhvAD02RcVisJoUdt2rJNt3zlyeNQ2b8MMPc+ug=="], + "jsonv-ts": ["jsonv-ts@0.8.6", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-z5jJ017LFOvAFFVodAIiCY024yW72RWc/K0Sct+OtuiLN+lKy+g0pI0jaz5JmuXaMIePc6HyopeeYHi8ffbYhw=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], @@ -3847,6 +3847,8 @@ "@bknd/plasmic/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@bknd/postgres/@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], + "@bknd/sqlocal/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -4093,7 +4095,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], "@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], @@ -4701,6 +4703,8 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], + "@bknd/postgres/@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], "@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250917.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0kL/kFnKUSycoo7b3PgM0nRyZ+1MGQAKaXtE6a2+SAeUkZ2FLnuFWmASi0s4rlWGsf/rlTw4AwXROePir9dUcQ=="],