Compare commits

...

133 Commits

Author SHA1 Message Date
dswbx 5c3d3d763e move sort plugin to data plugins 2025-11-26 10:26:58 +01:00
dswbx 407d103a5f fix: remove test.only 2025-11-26 10:12:39 +01:00
dswbx 2fd8b9f9a0 feat: add sort plugin
- Introduced a new sort plugin that allows sorting of entities based on a specified field.
- Added API endpoints for reordering items and recalculating sort order.
- Implemented tests to ensure correct functionality of sorting and reordering features.
2025-11-26 10:08:30 +01:00
dswbx 8f4de33a76 use nullish coalescing and fix mode plugin pick up 2025-11-26 10:04:24 +01:00
dswbx 43dbc856ce fix SystemController config read permission 2025-11-26 07:41:19 +01:00
dswbx 5a8f2b4894 upgrade bun and set linker isolated 2025-11-25 16:28:11 +01:00
dswbx 2627213de7 Merge pull request #292 from bknd-io/feat/add-otp-plugin
OTP plugin
2025-11-25 16:24:20 +01:00
dswbx 3fac740771 Merge branch 'release/0.20' into feat/add-otp-plugin 2025-11-21 20:03:30 +01:00
dswbx 5e5dc62304 Merge pull request #299 from bknd-io/fix/rec-with-join
fix: recursive `with` omitted join clauses
2025-11-21 20:01:46 +01:00
dswbx c1d6384271 Merge pull request #301 from bknd-io/fix/cli-run-open
fix cli url opener and add minimal debug docker config
2025-11-21 19:40:54 +01:00
dswbx f2bd04770a Merge pull request #303 from bknd-io/feat/cf-vite-templates
Add Cloudflare Vite templates for fullstack deployments
2025-11-21 19:39:29 +01:00
dswbx 41d43126cb ignore generated worker-configuration.d.ts files 2025-11-21 18:29:13 +01:00
dswbx a39407c9a3 update cloudflare-vite-code readme to reflect code-only mode
The template documentation now emphasizes code-first approach with manual type management instead of automatic file generation. Includes updated configuration examples, deployment instructions, and a new comparison table between code and hybrid modes.
2025-11-21 18:18:56 +01:00
dswbx 5d6046b149 template: added cloudflare vite code template 2025-11-21 17:55:43 +01:00
dswbx 000f8d20a8 template: added cloudflare vite hybrid template 2025-11-21 17:55:24 +01:00
dswbx 9e7547e787 Merge pull request #302 from bknd-io/fix/cf-proxy-and-modes
Fix: cf proxy and modes
2025-11-21 17:54:23 +01:00
dswbx c3ae4a3999 fix cloudflare adapter warm mode and type improvements
Adds warm mode support to cloudflare adapter to enable app instance caching. Improves BkndConfig type handling and makes hybrid mode reader optional. Fixes entity hook types to properly handle Generated ID types from kysely.
2025-11-21 17:35:45 +01:00
dswbx 5c3eeb7642 fix json schema validation initialization
ensure `getJsonSchema` handles both object and non-object outputs to prevent errors during validation initialization. this improves robustness when handling edge cases in schema configurations.
2025-11-20 21:11:28 +01:00
dswbx a2fa11ccd0 refactor modes implementation and improve validation handling
refactor `code` and `hybrid` modes for better type safety and configuration flexibility. add `_isProd` helper to standardize environment checks and improve plugin syncing warnings. adjust validation logic for clean JSON schema handling and enhance test coverage for modes.
2025-11-20 21:08:16 +01:00
dswbx fdee39da62 fix cli url opener and add minimal debug docker config 2025-11-20 20:04:13 +01:00
dswbx 16a3a67634 fix: recursive with omitted join clauses 2025-11-14 22:17:27 +01:00
dswbx 2b5e1771de refactor: enhance email OTP responses and improve data structure
Updated the email OTP plugin to return a structured response containing the email, action, and expiration details. Adjusted the tests to validate the new response format. Increased password length for user creation
2025-11-14 21:59:06 +01:00
dswbx a16e017e39 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-14 09:01:36 +01:00
dswbx 079d613b2e adjust biome configuration for improved linting and file inclusion
add specific file exclusions in `biome.json` to refine file tracking and adapt linting script in `package.json` to automatically write changes.
2025-11-11 16:42:19 +01:00
dswbx ff56d616d9 chore: upgrade bun to 1.3.2 2025-11-11 15:57:24 +01:00
dswbx 5b3d36c527 Merge pull request #294 from cameronapak/claude/add-loops-email-driver-011CUxMpVqe8AT22gN2k5ZVm
Email Driver: Add Plunk Support
2025-11-11 15:48:18 +01:00
cameronapak 2885fea077 Remove multi-recipient support from Plunk driver 2025-11-11 08:30:26 -06:00
dswbx 5ce22162c3 Merge pull request #297 from jonaspm/patch-1
Update import path for getApi in astro.mdx
2025-11-11 13:56:24 +01:00
dswbx 3f55c0f2c5 fix imports and adjust biome and linting configuration
remove unused `SecretSchema` import, adjust biome settings to modify linting behavior, and clean formatting in `slugify` and other functions.
2025-11-11 13:29:24 +01:00
Jonás Perusquía Morales 21f642161d Update import path for getApi in astro.mdx
Fix import path for getApi.
2025-11-10 18:42:49 -06:00
dswbx 7912729159 Merge pull request #296 from jonaspm/patch-1
Update Astro integration documentation
2025-11-10 19:36:16 +01:00
Jonás Perusquía Morales d3e87be604 Update Astro integration documentation
Removed note about Astro v4 and issues with v5. Issue was fixed on Dec 16, 2024 [https://github.com/withastro/astro/issues/12699]
2025-11-10 12:02:22 -06:00
dswbx ba3d11edab Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-10 13:25:01 +01:00
dswbx 9ec31373e1 Merge pull request #295 from bknd-io/fix/hybrid-schema-sync
fix hybrid schema sync
2025-11-10 10:36:52 +01:00
dswbx 70b25a9f9b hybrid: fix timing for automatic schema syncs to work with plugins 2025-11-10 10:34:50 +01:00
dswbx b6717f0237 otp: update docs on permissions, only require email driver if sendEmail is not false 2025-11-10 10:25:33 +01:00
dswbx c57f3e8070 otp: added missing tests 2025-11-10 10:17:16 +01:00
dswbx c2f4f92d1a refactor otp listeners 2025-11-10 09:40:47 +01:00
dswbx 6eb8525656 otp: add sendEmail option to disable sending for debugging 2025-11-10 09:30:24 +01:00
cameronapak e2ebb57564 Fixed tests :) 2025-11-09 08:11:49 -06:00
cameronapak 2b7bbc5df0 Update tests to use real information 2025-11-09 08:01:14 -06:00
Claude 8766b742c6 add PLUNK_API_KEY to .env.example 2025-11-09 13:50:12 +00:00
Claude 3befa43a9e remove PRD and task list documents from PR 2025-11-09 13:42:08 +00:00
Claude a842808464 mark all tasks as completed in task list 2025-11-09 13:36:18 +00:00
Claude bd1ef8ed57 implement Plunk email driver with comprehensive tests
- Add plunkEmail driver following IEmailDriver interface
- Support single and multiple recipients (up to 5 max per Plunk API)
- Handle both string and { text, html } body formats
- Include sender customization (from, name, reply)
- Add recipient validation and error handling
- Create comprehensive unit tests with 6 test cases
- Export plunkEmail from core drivers index
- Update task list with implementation progress
2025-11-09 13:35:48 +00:00
Claude a0019e5500 add detailed sub-tasks for Plunk email driver implementation 2025-11-09 13:29:42 +00:00
Claude 8ec67a5492 add task list for Plunk email driver implementation 2025-11-09 13:28:04 +00:00
Claude 960f7c3fb2 add PRD for Plunk email driver implementation 2025-11-09 13:25:38 +00:00
dswbx 793c214e6d Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:58:48 +01:00
dswbx 4094004b83 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:50:33 +01:00
dswbx 300d86ff7c Merge pull request #293 from bknd-io/chore/biome-upgrade
upgrade biome and vscode settings
2025-11-07 09:49:22 +01:00
dswbx 4c9b662f6f fix bknd/util imports 2025-11-07 09:47:43 +01:00
dswbx a862cfdcf1 add .vscode/settings.json 2025-11-07 09:45:12 +01:00
dswbx de62f4e729 upgrade biome config 2025-11-07 09:39:02 +01:00
dswbx 7e399830e5 add validation for used OTP codes 2025-11-07 09:12:58 +01:00
dswbx ee2ab982df docs: added emailOTP plugin docs 2025-11-07 09:11:13 +01:00
dswbx 5be55a6fa6 clean up error, it's prefixed with the plugin name already 2025-11-07 08:51:44 +01:00
dswbx 341eb13425 rename to email otp to make it more explicit 2025-11-07 08:48:02 +01:00
dswbx 68fbb6e933 fix events reference for instance checks 2025-11-06 20:47:19 +01:00
dswbx 8b36985252 otp: add entity config, reduce result 2025-11-06 20:38:16 +01:00
dswbx ff86240b0e feat: add OTP plugin 2025-11-06 20:06:51 +01:00
dswbx 4575d89cfe Merge pull request #290 from bknd-io/chore/upgrade-deps
upgrade deps
2025-11-05 10:03:36 +01:00
dswbx 0ca0828581 fix examples 2025-11-05 09:37:49 +01:00
dswbx 07a57ebf67 fix cli build script, add commander, fix types 2025-11-05 08:27:47 +01:00
dswbx e9f1241ec3 fix tests: remove .only 2025-11-05 08:20:36 +01:00
dswbx 80903d4ffa upgrade bun, remove hoisting 2025-11-05 08:15:41 +01:00
dswbx c8290802e9 upgrade biome, typescript, icons, other dev deps. also keep hoisting for now 2025-11-05 07:48:33 +01:00
dswbx ac6cd4a900 upgrade vite + fix types 2025-10-31 21:40:55 +01:00
dswbx a91ddb1ec3 update deps 2025-10-31 21:33:11 +01:00
dswbx 5417aa174e bump 0.19.0, update readme with use cases, small fixes
- fix password strategy openapi tags
- fix cli run picking up memory if specified
- fix view transition chrome crash
2025-10-31 11:55:27 +01:00
dswbx 6093f4f46f Merge pull request #278 from bknd-io/release/0.19
Release 0.19
2025-10-31 09:27:01 +01:00
dswbx 2178e0ee8b fix CodePreview shiki dynamic load for frameworks like Next.js 2025-10-31 09:24:45 +01:00
dswbx be39e8a391 chore: bump version to 0.19.0-rc.3 and update JsonField handling for improved value parsing 2025-10-28 16:07:43 +01:00
dswbx b57f362e3a fix json field 2025-10-28 16:00:58 +01:00
dswbx e055e477ae chore: bump version to 0.19.0-rc.2 and remove unused server initialization callback in Cloudflare config 2025-10-28 13:14:23 +01:00
dswbx 422f7893b5 docs: updated mcp tools 2025-10-28 11:09:37 +01:00
dswbx 0a50f9850c docs: add timestamps plugin description 2025-10-28 10:44:22 +01:00
dswbx 42f340b189 fix: update syncSecretsOptions condition and enhance documentation for mode configuration
- Changed the condition for syncSecretsOptions to check for both existence and false value.
- Added details about explicit fetch export and integration specifics in the introduction documentation.
- Clarified behavior of UI-only and code-only modes, including configuration application and schema syncing.
2025-10-28 10:42:06 +01:00
dswbx ef41b71921 fix: add modes export, fix event firing with modes and cloudflare 2025-10-28 09:18:16 +01:00
dswbx 2847e64b77 feat: enhance query handling by ignoring undefined values
- Updated query conversion logic to skip undefined values, improving robustness.
- Added tests to validate that undefined values are correctly ignored in query specifications.
2025-10-26 21:22:42 +01:00
dswbx 28390b0b84 refactor: move query.spec.ts 2025-10-26 21:08:11 +01:00
dswbx 0b58cadbd0 feat: implement mergeFilters function and enhance query object merging
- Added mergeFilters function to combine filter objects with priority handling.
- Introduced comprehensive tests for mergeFilters in permissions.spec.ts.
- Created query.spec.ts to validate query structure and expression handling.
- Enhanced error messages in query.ts for better debugging and clarity.
2025-10-26 21:05:11 +01:00
dswbx 574b37abcd fix: update key handling in StorageR2Adapter to conditionally prepend keyPrefix
- Modified getKey method to prepend keyPrefix only if it is not empty, ensuring correct key formatting.
2025-10-26 21:04:37 +01:00
dswbx 0dbf71e6b5 fix pagination on entity relations for softscan false 2025-10-26 16:00:15 +01:00
dswbx fd1f0f7c54 Merge pull request #288 from bknd-io/feat/deno-and-docs
deno: add to cli starters, improve `serveStaticViaImport` and add deno to the docs
2025-10-25 10:37:02 +02:00
dswbx ebad3d15ec Enhance Deno integration documentation with installation instructions and versioning options 2025-10-25 10:34:24 +02:00
dswbx 2fd5e71574 finalize deno addition to the cli starters 2025-10-25 10:26:05 +02:00
dswbx b787837dd2 add deno to the cli starters 2025-10-25 10:18:43 +02:00
dswbx 1fc6e810ae feat: improve Deno support and enhance serveStaticViaImport function
- Introduced support for Deno as a runtime in the documentation.
- Updated serveStaticViaImport function to accept additional options: appendRaw and package.
- Improved error logging in serveStaticViaImport for better debugging.
- Added new Deno integration documentation with examples for serving static assets.
2025-10-24 18:22:58 +02:00
dswbx 88cc406002 chore: update version to 0.19.0-rc.1 and improve error handling in App class
- Bumped version in package.json to 0.19.0-rc.1.
- Changed error throw to console.error in fetch method of App class for better debugging.
- Updated permissions in DataController for the "/types" endpoint to include context for schemaRead.
2025-10-24 18:22:13 +02:00
dswbx 511f639d7a Merge pull request #280 from bknd-io/feat/advanced-permissions
feat: advanced permissions (~RLS)
2025-10-24 15:18:05 +02:00
dswbx 0df17221df Merge branch 'release/0.19' into feat/advanced-permissions 2025-10-24 15:15:56 +02:00
dswbx 347fe0f6ce Merge pull request #286 from bknd-io/feat/data-types-endpoint
feat: add endpoint/tool to retrieve TypeScript definitions for data entities
2025-10-24 15:13:36 +02:00
dswbx f3c6cd7620 fix typo on AdminController flash message 2025-10-24 15:12:47 +02:00
dswbx 649c465bd0 Merge pull request #279 from bknd-io/fix/dropzone-improve-mime
fix: dropzone improve mime type validation
2025-10-24 15:11:19 +02:00
dswbx 67604b344a Merge pull request #284 from bknd-io/fix/sdk-api-auth
fix: SDK improvements to API and `useAuth` for remote instance
2025-10-24 15:10:29 +02:00
dswbx aedae8e757 Merge pull request #285 from bknd-io/feat/mode-helpers
feat: introduce new modes helpers
2025-10-24 15:09:05 +02:00
dswbx 166409fdf4 Revert "make non-fillable fields visible but disabled in UI"
This reverts commit f2aad9caac.
2025-10-24 14:08:32 +02:00
dswbx f2aad9caac make non-fillable fields visible but disabled in UI 2025-10-24 14:07:37 +02:00
dswbx 292e4595ea feat: add endpoint/tool to retrieve TypeScript definitions for data entities
Implemented a new endpoint at "/types" in the DataController to return TypeScript definitions for data entities, enhancing type safety and developer experience.
2025-10-24 12:49:39 +02:00
dswbx 869031bbfa Refactor CustomFieldWrapper and enhance schema handling in Policy component
- Updated `CustomFieldWrapper` to accept a more structured schema object, improving clarity and type safety.
- Modified schema handling in the `Policy` component to ensure proper context and variable naming, enhancing the overall user experience.
- Introduced `autoFormatString` utility for dynamic button labeling based on schema name.
2025-10-24 12:43:32 +02:00
dswbx 88e5c06e9d Enhance SystemController to improve config modification checks
Updated the `SystemController` to include additional checks for read-only status and user permissions when modifying configurations.
2025-10-24 10:37:52 +02:00
dswbx cfb4b0e336 Refactor JsonEditor and Permission components for improved state management and performance
- Implemented debounced input handling in `JsonEditor` to enhance user experience and reduce unnecessary updates.
- Updated `Permission` component to streamline permission state management and improve clarity in policy handling.
- Refactored `Policies` component to utilize derived field context for better data handling and rendering efficiency.
2025-10-24 09:59:00 +02:00
dswbx 2d56b54e0c Enhance Guard and Form components with improved error handling and debugging
- Added debug logging in the `Guard` class to track policy evaluations and conditions.
- Updated error logging in the `Form` component to provide more context on invalid submissions.
- Introduced state management for form errors in the `AuthRolesEdit` component, displaying alerts for invalid data submissions.
2025-10-24 09:40:02 +02:00
dswbx 5d4a77fb10 Update permission context handling and improve JSON field component
- Enhanced `MediaController` to include context in the `entityCreate` permission for better access control.
- Refactored permission checks in `useBkndAuth` to ensure correct validation of role permissions.
- Modified `JsonField` component to directly use `formData` in `JsonEditor`, simplifying data handling and improving user experience.
2025-10-24 09:20:59 +02:00
dswbx eb0822bbff Enhance authentication and authorization components
- Refactored `AppAuth` to introduce `getGuardContextSchema` for improved user context handling.
- Updated `Authenticator` to utilize `pickKeys` for user data extraction in JWT generation.
- Enhanced `Guard` class to improve permission checks and error handling.
- Modified `SystemController` to return context schema alongside permissions in API responses.
- Added new `permissions` method in `SystemApi` for fetching permissions.
- Improved UI components with additional props and tooltip support for better user experience.
2025-10-24 09:14:31 +02:00
dswbx 38902ebcba Update permissions handling and enhance Guard functionality
- Bump `jsonv-ts` dependency to 0.8.6.
- Refactor permission checks in the `Guard` class to improve context validation and error handling.
- Update tests to reflect changes in permission handling, ensuring robust coverage for new scenarios.
- Introduce new test cases for data permissions, enhancing overall test coverage and reliability.
2025-10-21 16:44:08 +02:00
dswbx 22e43c2523 feat: introduce new modes helpers 2025-10-18 16:58:54 +02:00
dswbx e68e5792be set raw state to ClientProviders auth state 2025-10-17 08:47:00 +02:00
dswbx 511c6539fb fix: update authentication verification logic in Api tests
- Adjusted test cases in Api.spec.ts to reflect the correct authentication verification state.
- Updated expectations to ensure that the `isAuthVerified` method returns true when no claims are provided, aligning with the intended behavior of the API.
2025-10-15 18:46:21 +02:00
dswbx 9070f96571 feat: enhance API and AuthApi with credentials support and async storage handling
- Added `credentials` option to `ApiOptions` and `BaseModuleApiOptions` for better request handling.
- Updated `AuthApi` to pass `verified` status during token updates.
- Refactored storage handling in `Api` to support async operations using a Proxy.
- Improved `Authenticator` to handle cookie domain configuration and JSON request detection.
- Adjusted `useAuth` to ensure logout and verify methods return promises for better async handling.
- Fixed navigation URL construction in `useNavigate` and updated context menu actions in `_data.root.tsx`.
2025-10-15 18:41:04 +02:00
dswbx 0347efa592 fix Role creation method and permission checks in tests 2025-10-14 16:49:42 +02:00
dswbx 1b8ce41837 role and permission handling in auth module
- Updated the `Role` class to change the `create` method signature for improved clarity and flexibility.
- Refactored the `guardRoleSchema` to utilize the new `roleSchema` for better consistency.
- Introduced a new `TPermission` type to enhance type safety in permission handling across the application.
- Updated various components and forms to accommodate the new permission structure, ensuring backward compatibility.
- Enhanced the `AuthRolesEdit` and `AuthRolesList` components to improve role management and permissions display.
- Added new API endpoints for fetching permissions, improving the overall functionality of the auth module.
2025-10-14 16:36:42 +02:00
dswbx 6624927286 enhance form field components and add JsonEditor support
- Updated `ObjectField`, `ArrayField`, and `FieldWrapper` components to improve flexibility and integration options by supporting additional props like `wrapperProps`.
- Added `JsonEditor` for enhanced object editing capabilities with state management and safety checks.
- Refactored utility functions and error handling for improved stability and developer experience.
- Introduced new test cases to validate `JsonEditor` functionality and schema-based forms handling.
2025-10-14 16:36:16 +02:00
dswbx 803f42a72b Merge remote-tracking branch 'origin/main' into feat/advanced-permissions 2025-10-14 10:16:43 +02:00
dswbx 7e5c28d621 enhance Guard and permission handling with new test cases
- Updated the `Guard` class to improve context validation and permission checks, ensuring clearer error messages for unmet conditions.
- Refactored the `Policy` and `RolePermission` classes to support default effects and better handle conditions and filters.
- Enhanced tests in `authorize.spec.ts` and `permissions.spec.ts` to cover new permission scenarios, including guest and member role behaviors.
- Added new tests for context validation in permission middleware, ensuring robust error handling for invalid contexts.
- Improved utility functions for better integration with the updated permission structure.
2025-10-13 21:03:49 +02:00
dswbx 2f88c2216c refactor: restructure permission handling and enhance Guard functionality
- Introduced a new `createGuard` function to streamline the creation of Guard instances with permissions and roles.
- Updated tests in `authorize.spec.ts` to reflect changes in permission checks, ensuring they now return undefined for denied permissions.
- Added new `Permission` and `Policy` classes to improve type safety and flexibility in permission management.
- Refactored middleware and controller files to utilize the updated permission structure, including context handling for permissions.
- Created a new `SystemController.spec.ts` file to test the integration of the new permission system within the SystemController.
- Removed legacy permission handling from core security files, consolidating permission logic within the new structure.
2025-10-13 18:20:46 +02:00
dswbx b89c090f87 Merge pull request #281 from stormbyte/main
Update docker image
2025-10-13 11:11:10 +02:00
Your Name 5377ac1a41 Update docker builder 2025-10-08 22:41:43 +02:00
Your Name 0e870cda81 Bump version 2025-10-08 22:09:03 +02:00
dswbx b784e1c1c4 Merge remote-tracking branch 'origin/release/0.19' into feat/advanced-permissions 2025-10-03 20:27:07 +02:00
dswbx 90f93caff4 refactor: enhance permission handling and introduce new Permission and Policy classes
- Updated the `Guard` class to improve permission checking by utilizing the new `Permission` class.
- Refactored tests in `authorize.spec.ts` to use `Permission` instances instead of strings for better type safety.
- Introduced a new `permissions.spec.ts` file to test the functionality of the `Permission` and `Policy` classes.
- Enhanced the `recursivelyReplacePlaceholders` utility function to support various object structures and types.
- Updated middleware and controller files to align with the new permission handling structure.
2025-10-03 20:22:42 +02:00
dswbx d6dcfe3acc feat: implement file acceptance validation in utils and integrate with Dropzone component 2025-10-01 09:46:16 +02:00
dswbx b974fe7ec7 Merge pull request #270 from bknd-io/feat/cli-user-role
feat: add role selection and auth checks in user create command
2025-10-01 09:40:20 +02:00
dswbx 6483ff74bb Merge pull request #271 from bknd-io/feat/auth-helper-cookie
feat: add helper methods for auth cookie headers
2025-10-01 09:40:03 +02:00
dswbx 27e2064f0c Merge pull request #272 from bknd-io/feat/media-add-body-max
feat: add media option to module to restrict body max size
2025-10-01 09:39:38 +02:00
dswbx 5cf91bb35a Merge pull request #273 from bknd-io/feat/schema-batch-statements
feat: batch schema manager statements
2025-10-01 09:39:19 +02:00
dswbx 81533d855a Merge pull request #277 from bknd-io/feat/timestamps-plugin
feat: timestamps plugin
2025-10-01 09:39:10 +02:00
dswbx 1fdee8435d feat: add timestamps plugin to manage created_at and updated_at fields
Introduced a new timestamps plugin that allows the addition of `created_at` and `updated_at` fields to specified entities. Included tests to verify functionality, ensuring timestamps are correctly set on entity creation and updates. Updated the plugin index to export the new timestamps functionality.
2025-09-29 22:12:23 +02:00
dswbx 06d7558c3c feat: batch schema manager statements
run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan.
2025-09-24 14:48:45 +02:00
dswbx 7344b1cf3d feat: add media option to module to restrict body max size
updated media schema to enforce strict validation, introduced `options` for AppMedia, and added a key prefix feature for StorageR2Adapter to enhance flexibility and control.
2025-09-24 10:29:03 +02:00
dswbx ace9c1b2b9 feat: add helper methods for auth cookie headers
introduced `getAuthCookieHeader` and `removeAuthCookieHeader` methods to simplify header management for authentication cookies. added tests to validate the new methods.
2025-09-24 10:26:07 +02:00
dswbx 0629e1bc50 feat: add role selection and auth checks in user create command
integrated role selection prompt during user creation and added an auth-enabled check to ensure correct configuration before executing commands. adjusted CLI commands to include role assignment for newly created users.
2025-09-24 10:24:37 +02:00
225 changed files with 10654 additions and 1697 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.2.22" bun-version: "1.3.3"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
+2 -1
View File
@@ -27,7 +27,8 @@ packages/media/.env
.npmrc .npmrc
/.verdaccio /.verdaccio
.idea .idea
.vscode .vscode/*
!.vscode/settings.json
.git_old .git_old
docker/tmp docker/tmp
.debug .debug
+19
View File
@@ -0,0 +1,19 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [
"**/dist/**",
"**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
],
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
}
}
+18 -2
View File
@@ -8,13 +8,17 @@
</a> </a>
</p> </p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! bknd simplifies app development by providing a fully functional visual backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way.
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc. * **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
* **Databases**: * **Databases**:
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal * SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata * Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku * **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem * **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
**For documentation and examples, please visit https://docs.bknd.io.** **For documentation and examples, please visit https://docs.bknd.io.**
@@ -24,6 +28,18 @@ bknd simplifies app development by providing a fully functional backend for data
> Please keep in mind that **bknd** is still under active development > Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Use Cases
bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to:
- **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend
- **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server.
- **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider
- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead
- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI
- **IoT & Embedded Devices** where minimal footprint matters
## Size ## Size
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
@@ -46,13 +62,13 @@ Creating digital products always requires developing both the backend (the logic
* **Media**: Effortlessly manage and serve all your media files. * **Media**: Effortlessly manage and serve all your media files.
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!) * **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
* 🌐 Built on Web Standards for maximum compatibility * 🌐 Built on Web Standards for maximum compatibility
* 🛠️ MCP server, client and UI built-in to control your backend
* 🏃‍♂️ Multiple run modes * 🏃‍♂️ Multiple run modes
* standalone using the CLI * standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd) * using a JavaScript runtime (Node, Bun, workerd)
* using a React framework (Next.js, React Router, Astro) * using a React framework (Next.js, React Router, Astro)
* 📦 Official API and React SDK with type-safety * 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components * ⚛️ React elements for auto-configured authentication and media components
* 🛠️ MCP server, client and UI built-in to control your backend
## Structure ## Structure
The package is mainly split into 4 parts, each serving a specific purpose: The package is mainly split into 4 parts, each serving a specific purpose:
+1
View File
@@ -20,6 +20,7 @@ VITE_SHOW_ROUTES=
# ===== Test Credentials ===== # ===== Test Credentials =====
RESEND_API_KEY= RESEND_API_KEY=
PLUNK_API_KEY=
R2_TOKEN= R2_TOKEN=
R2_ACCESS_KEY= R2_ACCESS_KEY=
+12
View File
@@ -0,0 +1,12 @@
{
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"search.exclude": {
"**/dist/**": true,
"**/node_modules/**": true
},
"files.exclude": {
"**/dist/**": true
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ describe("App tests", async () => {
expect(Array.from(app.plugins.keys())).toEqual(["test"]); expect(Array.from(app.plugins.keys())).toEqual(["test"]);
}); });
test.only("drivers", async () => { test("drivers", async () => {
const called: string[] = []; const called: string[] = [];
const app = new App(dummyConnection, undefined, { const app = new App(dummyConnection, undefined, {
drivers: { drivers: {
+5 -2
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 () => {
+1 -1
View File
@@ -15,7 +15,7 @@ const mockedBackend = new Hono()
.get("/file/:name", async (c) => { .get("/file/:name", async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
const file = Bun.file(`${assetsPath}/${name}`); const file = Bun.file(`${assetsPath}/${name}`);
return new Response(file, { return new Response(new File([await file.bytes()], name, { type: file.type }), {
headers: { headers: {
"Content-Type": file.type, "Content-Type": file.type,
"Content-Length": file.size.toString(), "Content-Length": file.size.toString(),
+8 -3
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", {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { code, hybrid } from "modes";
describe("modes", () => {
describe("code", () => {
test("verify base configuration", async () => {
const c = code({}) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["options"]);
expect(config.options.mode).toEqual("code");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
test("keeps overrides", async () => {
const c = code({
connection: {
url: ":memory:",
},
}) as any;
const config = await c.app?.({} as any);
expect(config.connection.url).toEqual(":memory:");
});
});
describe("hybrid", () => {
test("fails if no reader is provided", () => {
// @ts-ignore
expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/);
});
test("verify base configuration", async () => {
const c = hybrid({ reader: async () => ({}) }) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]);
expect(config.options.mode).toEqual("db");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
});
});
+1 -1
View File
@@ -76,7 +76,7 @@ describe("repros", async () => {
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
}); });
test.only("verify inversedBy", async () => { test("verify inversedBy", async () => {
const schema = proto.em( const schema = proto.em(
{ {
products: proto.entity("products", { products: proto.entity("products", {
+39 -1
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");
});
});
+177 -19
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();
});
}); });
}); });
@@ -0,0 +1,327 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import * as proto from "data/prototype";
import { mergeObject } from "core/utils/objects";
import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({
config: mergeObject(
{
data: proto
.em(
{
users: proto.systemEntity("users", {}),
posts: proto.entity("posts", {
title: proto.text(),
content: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
},
({ relation }, { users, posts, comments }) => {
relation(posts).manyToOne(users);
relation(comments).manyToOne(posts);
},
)
.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
},
config,
),
});
await app.build();
return app;
}
async function createUsers(app: App, users: CreateUserPayload[]) {
return Promise.all(
users.map(async (user) => {
return await app.createUser(user);
}),
);
}
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
const results = {} as any;
for (const [entity, data] of Object.entries(fixtures)) {
results[entity] = await app.em
.mutator(entity as any)
.insertMany(data)
.then((result) => result.data);
}
return results;
}
describe("data permissions", async () => {
const app = await makeApp({
server: {
mcp: {
enabled: true,
},
},
auth: {
guard: {
enabled: true,
},
roles: {
guest: {
is_default: true,
permissions: [
{
permission: "system.access.api",
},
{
permission: "data.entity.read",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.create",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.update",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.delete",
policies: [
{
condition: { entity: "posts" },
},
{
condition: { entity: "posts" },
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
],
},
},
},
});
const users = [
{ email: "foo@example.com", password: "password" },
{ email: "bar@example.com", password: "password" },
];
const fixtures = {
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 },
{ content: "post 3", users_id: null },
],
comments: [
{ content: "comment 1", posts_id: 1 },
{ content: "comment 2", posts_id: 2 },
{ content: "comment 3", posts_id: 3 },
],
};
await createUsers(app, users);
const results = await loadFixtures(app, fixtures);
describe("http", async () => {
it("read many", async () => {
// many only includes posts with users_id is null
const res = await app.server.request("/api/data/entity/posts");
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
// same with /query
{
const res = await app.server.request("/api/data/entity/posts/query", {
method: "POST",
});
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
}
});
it("read one", async () => {
// one only includes posts with users_id is null
{
const res = await app.server.request("/api/data/entity/posts/1");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(404);
expect(data).toBeUndefined();
}
// read one by allowed id
{
const res = await app.server.request("/api/data/entity/posts/3");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.posts[2]);
}
});
it("read many by reference", async () => {
const res = await app.server.request("/api/data/entity/posts/1/comments");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
});
it("mutation create one", async () => {
// not allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
}
// allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4", users_id: null }),
});
expect(res.status).toBe(201);
}
});
it("mutation update one", async () => {
// update one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "PATCH",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
{
// update one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "PATCH",
body: JSON.stringify({ content: "post 3 (updated)" }),
});
expect(res.status).toBe(200);
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
}
});
it("mutation update many", async () => {
// update many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
update: { content: "post 4" },
where: { users_id: { $isnull: 0 } },
}),
});
expect(res.status).toBe(200); // because filtered
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
expect(_data.every((u: any) => u === null)).toBe(true);
// verify
const data = await app.em
.repo("posts")
.findMany({ select: ["content", "users_id"] })
.then((r) => r.data);
// expect non null users_id to not have content "post 4"
expect(
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
).toBe(true);
// expect null users_id to have content "post 4"
expect(
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
).toBe(true);
});
const count = async () => {
const {
data: { count: _count },
} = await app.em.repo("posts").count();
return _count;
};
it("mutation delete one", async () => {
const initial = await count();
// delete one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "DELETE",
});
expect(res.status).toBe(403);
expect(await count()).toBe(initial);
{
// delete one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await count()).toBe(initial - 1);
}
});
it("mutation delete many", async () => {
// delete many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
where: {},
}),
});
expect(res.status).toBe(200);
// only deleted posts with users_id is null
const remaining = await app.em
.repo("posts")
.findMany()
.then((r) => r.data);
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
});
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from "bun:test";
import { SystemController } from "modules/server/SystemController";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
async function makeApp(config: Partial<CreateAppConfig> = {}) {
const app = createApp(config);
await app.build();
return app;
}
describe.skip("SystemController", () => {
it("...", async () => {
const app = await makeApp();
const controller = new SystemController(app);
const hono = controller.getController();
console.log(getPermissionRoutes(hono));
});
});
@@ -0,0 +1,543 @@
import { describe, it, expect } from "bun:test";
import { s } from "bknd/utils";
import { Permission } from "auth/authorize/Permission";
import { Policy } from "auth/authorize/Policy";
import { Hono } from "hono";
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
import { auth } from "auth/middlewares/auth.middleware";
import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
import { Role, RolePermission } from "auth/authorize/Role";
import { Exception } from "bknd";
import { convert } from "core/object/query/object-query";
describe("Permission", () => {
it("works with minimal schema", () => {
expect(() => new Permission("test")).not.toThrow();
});
it("parses context", () => {
const p = new Permission(
"test3",
{
filterable: true,
},
s.object({
a: s.string(),
}),
);
// @ts-expect-error
expect(() => p.parseContext({ a: [] })).toThrow();
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
// @ts-expect-error
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
});
});
describe("Policy", () => {
it("works with minimal schema", () => {
expect(() => new Policy().toJSON()).not.toThrow();
});
it("checks condition", () => {
const p = new Policy({
condition: {
a: 1,
},
});
expect(p.meetsCondition({ a: 1 })).toBe(true);
expect(p.meetsCondition({ a: 2 })).toBe(false);
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
expect(p.meetsCondition({})).toBe(false);
const p2 = new Policy({
condition: {
a: { $gt: 1 },
$or: {
b: { $lt: 2 },
},
},
});
expect(p2.meetsCondition({ a: 2 })).toBe(true);
expect(p2.meetsCondition({ a: 1 })).toBe(false);
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
});
it("filters", () => {
const p = new Policy({
filter: {
age: { $gt: 18 },
},
});
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
expect(p.meetsFilter({ age: 19 })).toBe(true);
expect(p.meetsFilter({ age: 17 })).toBe(false);
expect(p.meetsFilter({ age: 12 })).toBe(false);
});
it("replaces placeholders", () => {
const p = new Policy({
condition: {
a: "@auth.username",
},
filter: {
a: "@auth.username",
},
});
const vars = { auth: { username: "test" } };
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
expect(p.meetsCondition({ a: "test2" })).toBe(false);
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
expect(p.meetsFilter({ a: "test2" })).toBe(false);
});
});
describe("Guard", () => {
it("collects filters", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
]);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
// if no user context given, filter cannot be applied
expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
});
it("collects filters for default role", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
],
true,
);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 1 },
).filter,
).toEqual({ foo: "bar" });
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 2 },
).filter,
).toBeUndefined();
// if no user context given, the default role is applied
// hence it can be found
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
});
it("merges filters correctly", () => {
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
foo: { $eq: "bar", $lt: 1 },
});
// overwrite base $or with priority
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
$or: {
foo: {
$eq: "bar",
},
},
foo: {
$eq: "bar",
},
});
// ignore base $or if priority has different key
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
$or: {
other: {
$eq: "one",
},
},
foo: {
$eq: "bar",
},
});
});
});
describe("permission middleware", () => {
const makeApp = (
permissions: Permission<any, any, any, any>[],
roles: Role[] = [],
config: Partial<GuardConfig> = {},
) => {
const app = {
module: {
auth: {
enabled: true,
},
},
modules: {
ctx: () => ({
guard: new Guard(permissions, roles, {
enabled: true,
...config,
}),
}),
},
};
return new Hono()
.use(async (c, next) => {
// @ts-expect-error
c.set("app", app);
await next();
})
.use(auth())
.onError((err, c) => {
if (err instanceof Exception) {
return c.json(err.toJSON(), err.code as any);
}
return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500);
});
};
it("allows if guard is disabled", async () => {
const p = new Permission("test");
const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) =>
c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
expect(await res.text()).toBe("test");
});
it("denies if guard is enabled", async () => {
const p = new Permission("test");
const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user has (plain) role", async () => {
const p = new Permission("test");
const r = Role.create("test", { permissions: [p.name] });
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("allows if user has role with policy", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $gte: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("denies if user with role doesn't meet condition", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(
p,
[
new Policy({
condition: {
a: { $lt: 1 },
},
// default effect is allow
}),
],
// change default effect to deny if no condition is met
"deny",
),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user with role doesn't meet condition (from middleware)", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("throws if permission context is invalid", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
// expecting 500 because bknd should have handled it correctly
expect(res.status).toBe(500);
});
it("checks context on routes with permissions", async () => {
const make = (user: any) => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
return makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user });
await next();
})
.get(
"/valid",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid",
permission(p, {
// @ts-expect-error
context: (c) => ({
b: "1",
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid2",
permission(p, {
// @ts-expect-error
context: (c) => ({}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid3",
// @ts-expect-error
permission(p),
async (c) => c.text("test"),
);
};
const hono = make({ id: 0, role: "test" });
const valid = await hono.request("/valid");
expect(valid.status).toBe(200);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
{
const hono = make(null);
const valid = await hono.request("/valid");
expect(valid.status).toBe(403);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
}
});
});
describe("Role", () => {
it("serializes and deserializes", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
effect: "deny",
filter: {
b: { $lt: 1 },
},
}),
]),
],
true,
);
const json = JSON.parse(JSON.stringify(r.toJSON()));
const r2 = Role.create(p.name, json);
expect(r2.toJSON()).toEqual(r.toJSON());
});
});
@@ -66,4 +66,14 @@ describe("object-query", () => {
expect(result).toBe(expected); expect(result).toBe(expected);
} }
}); });
test("paths", () => {
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
expect(result).toBe(true);
});
test("empty filters", () => {
const result = validate({}, { user: { age: 17 } });
expect(result).toBe(true);
});
}); });
+120
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" } });
});
});
+205
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", () => {
+2 -2
View File
@@ -30,9 +30,9 @@ describe("some tests", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
expect(query.sql).toBe( expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? 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();
}); });
+36 -1
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 */
});
}); });
@@ -7,7 +7,7 @@ describe("[data] JsonField", async () => {
const field = new JsonField("test"); const field = new JsonField("test");
fieldTestSuite(bunTestRunner, JsonField, { fieldTestSuite(bunTestRunner, JsonField, {
defaultValue: { a: 1 }, defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1], //sampleValues: ["string", { test: 1 }, 1],
schemaType: "text", schemaType: "text",
}); });
@@ -33,9 +33,9 @@ describe("[data] JsonField", async () => {
}); });
test("getValue", async () => { test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}'); expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
expect(field.getValue("string", "form")).toBe('"string"'); expect(field.getValue("string", "form")).toBe("string");
expect(field.getValue(1, "form")).toBe("1"); expect(field.getValue(1, "form")).toBe(1);
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 }); expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
expect(field.getValue('"string"', "submit")).toBe("string"); expect(field.getValue('"string"', "submit")).toBe("string");
@@ -43,6 +43,5 @@ describe("[data] JsonField", async () => {
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}'); expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
expect(field.getValue("string", "table")).toBe('"string"'); expect(field.getValue("string", "table")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
}); });
}); });
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/auth/middlewares"; import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
-20
View File
@@ -1,30 +1,10 @@
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors"; import c from "picocolors";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild";
const deps = Object.keys(pkg.dependencies); const deps = Object.keys(pkg.dependencies);
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps]; const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
if (process.env.DEBUG) {
const result = await esbuild.build({
entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli",
platform: "node",
minify: true,
format: "esm",
metafile: true,
bundle: true,
external,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
process.exit(0);
}
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"], entrypoints: ["./src/cli/index.ts"],
target: "node", target: "node",
+8 -1
View File
@@ -85,12 +85,18 @@ async function buildApi() {
sourcemap, sourcemap,
watch, watch,
define, define,
entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"], entry: [
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist", outDir: "dist",
external: [...external], external: [...external],
metafile: true, metafile: true,
target: "esnext", target: "esnext",
platform: "browser", platform: "browser",
removeNodeProtocol: false,
format: ["esm"], format: ["esm"],
splitting: false, splitting: false,
loader: { loader: {
@@ -223,6 +229,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
outDir: `dist/adapter/${adapter}`, outDir: `dist/adapter/${adapter}`,
metafile: true, metafile: true,
splitting: false, splitting: false,
removeNodeProtocol: false,
onSuccess: async () => { onSuccess: async () => {
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
-6
View File
@@ -1,6 +0,0 @@
[install]
#registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true
console.depth = 10
+6 -3
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.");
+60 -50
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",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -13,7 +13,7 @@
"bugs": { "bugs": {
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"packageManager": "bun@1.2.22", "packageManager": "bun@1.3.3",
"engines": { "engines": {
"node": ">=22.13" "node": ">=22.13"
}, },
@@ -49,93 +49,98 @@
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.2",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1", "@hono/swagger-ui": "^0.5.2",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10", "@uiw/react-codemirror": "^4.25.2",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.9.2",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.19",
"fast-xml-parser": "^5.0.8", "fast-xml-parser": "^5.3.1",
"hono": "4.8.3", "hono": "4.10.4",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.5", "jsonv-ts": "0.9.3",
"kysely": "0.27.6", "kysely": "0.28.8",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"swr": "^2.3.3" "radix-ui": "^1.1.3",
"swr": "^2.3.6",
"use-sync-external-store": "^1.6.0",
"zustand": "^4"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.922.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.6",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@cloudflare/vitest-pool-workers": "^0.9.3", "@cloudflare/vitest-pool-workers": "^0.10.4",
"@cloudflare/workers-types": "^4.20250606.0", "@cloudflare/workers-types": "^4.20251014.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/vite-dev-server": "^0.21.0", "@hono/vite-dev-server": "^0.23.0",
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^5.2.2",
"@libsql/client": "^0.15.9", "@libsql/client": "^0.15.15",
"@mantine/modals": "^7.17.1", "@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1", "@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.56.1",
"@rjsf/core": "5.22.2", "@rjsf/core": "5.22.2",
"@rjsf/utils": "5.22.0",
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.35.0",
"@tailwindcss/postcss": "^4.0.12", "@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/vite": "^4.0.12", "@tailwindcss/vite": "^4.1.16",
"@tanstack/react-store": "^0.8.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/node": "^22.13.10", "@types/node": "^24.10.0",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.0.9", "@vitest/coverage-v8": "3.0.9",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "commander": "^14.0.2",
"dotenv": "^17.2.3",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.1.0",
"kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1", "kysely-generic-sqlite": "^1.2.1",
"libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0", "libsql-stateless-easy": "^1.8.0",
"open": "^10.1.0", "miniflare": "^4.20251011.2",
"open": "^10.2.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2", "posthog-js-lite": "^3.6.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.66.0",
"react-icons": "5.2.1", "react-icons": "5.5.0",
"react-json-view-lite": "^2.4.1", "react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.4.11", "sql-formatter": "^15.6.10",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.12", "tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.11", "tsc-alias": "^1.8.16",
"tsup": "^8.4.0", "tsup": "^8.5.0",
"tsx": "^4.19.3", "tsx": "^4.20.6",
"uuid": "^11.1.0", "uuid": "^13.0.0",
"vite": "^6.3.5", "vite": "^7.1.12",
"vite-plugin-circular-dependency": "^0.5.0", "vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9", "vitest": "3.0.9",
"wouter": "^3.6.0", "wouter": "^3.7.1",
"wrangler": "^4.37.1", "wrangler": "^4.45.4"
"miniflare": "^4.20250913.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.14.3" "@hono/node-server": "^1.19.6"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=19", "react": ">=19",
@@ -180,6 +185,11 @@
"import": "./dist/plugins/index.js", "import": "./dist/plugins/index.js",
"require": "./dist/plugins/index.js" "require": "./dist/plugins/index.js"
}, },
"./modes": {
"types": "./dist/types/modes/index.d.ts",
"import": "./dist/modes/index.js",
"require": "./dist/modes/index.js"
},
"./adapter/sqlite": { "./adapter/sqlite": {
"types": "./dist/types/adapter/sqlite/edge.d.ts", "types": "./dist/types/adapter/sqlite/edge.d.ts",
"import": { "import": {
+45 -31
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);
}, },
}, },
+8 -5
View File
@@ -5,7 +5,6 @@ import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
type InitialModuleConfigs,
type ModuleConfigs, type ModuleConfigs,
type Modules, type Modules,
ModuleManager, ModuleManager,
@@ -245,9 +244,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 +294,7 @@ export class App<
return this.module.auth.createUser(p); return this.module.auth.createUser(p);
} }
// @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners
getApi(options?: LocalApiOptions) { getApi(options?: LocalApiOptions) {
const fetcher = this.server.request as typeof fetch; const fetcher = this.server.request as typeof fetch;
if (options && options instanceof Request) { if (options && options instanceof Request) {
@@ -311,8 +310,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,
}); });
} }
@@ -380,12 +380,15 @@ export class App<
if (results.length > 0) { if (results.length > 0) {
for (const { name, result } of results) { for (const { name, result } of results) {
if (result) { if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result); ctx.helper.ensureSchema(result);
if (ctx.flags.sync_required) {
$console.log(`[Plugin:${name}] schema, sync required`);
} }
} }
} }
} }
await this.options?.manager?.onModulesBuilt?.(ctx);
}
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {
+6 -3
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);
}; };
+12 -10
View File
@@ -1,18 +1,16 @@
/// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "."; import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd"; import { config, type App } from "bknd";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
type BunEnv = Bun.Env; type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">; export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> &
Omit<Bun.Serve.Options<undefined, string>, "fetch">;
export async function createApp<Env = BunEnv>( export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {}, { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = Bun.env as Env,
) { ) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -26,18 +24,18 @@ export async function createApp<Env = BunEnv>(
}), }),
...config, ...config,
}, },
args ?? (process.env as Env), args,
); );
} }
export function createHandler<Env = BunEnv>( export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {}, config: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = Bun.env as Env,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env)); app = await createApp(config, args);
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -45,6 +43,7 @@ export function createHandler<Env = BunEnv>(
export function serve<Env = BunEnv>( export function serve<Env = BunEnv>(
{ {
app,
distPath, distPath,
connection, connection,
config: _config, config: _config,
@@ -54,15 +53,17 @@ export function serve<Env = BunEnv>(
buildConfig, buildConfig,
adminOptions, adminOptions,
serveStatic, serveStatic,
beforeBuild,
...serveOptions ...serveOptions
}: BunBkndConfig<Env> = {}, }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = Bun.env as Env,
) { ) {
Bun.serve({ Bun.serve({
...serveOptions, ...(serveOptions as any),
port, port,
fetch: createHandler( fetch: createHandler(
{ {
app,
connection, connection,
config: _config, config: _config,
options, options,
@@ -71,6 +72,7 @@ export function serve<Env = BunEnv>(
adminOptions, adminOptions,
distPath, distPath,
serveStatic, serveStatic,
beforeBuild,
}, },
args, args,
), ),
+8
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();
}
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
/* beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); */ afterAll(enableConsoleLog);
describe("cf adapter", () => { describe("cf adapter", () => {
const DB_URL = ":memory:"; const DB_URL = ":memory:";
@@ -3,7 +3,7 @@
import type { RuntimeBkndConfig } from "bknd/adapter"; import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { MaybePromise } from "bknd"; import type { App, MaybePromise } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import { createRuntimeApp } from "bknd/adapter"; import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
@@ -37,26 +37,30 @@ 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
export const getFresh = createApp; export const getFresh = createApp;
let app: App | undefined;
export function serve<Env extends CloudflareEnv = CloudflareEnv>( export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {}, config: CloudflareBkndConfig<Env> = {},
serveOptions?: (args: Env) => {
warm?: boolean;
},
) { ) {
return { return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) { async fetch(request: Request, env: Env, ctx: ExecutionContext) {
@@ -92,8 +96,11 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
} }
} }
const { warm } = serveOptions?.(env) ?? {};
if (!app || warm !== true) {
const context = { request, env, ctx } as CloudflareContext<Env>; const context = { request, env, ctx } as CloudflareContext<Env>;
const app = await createApp(config, context); app = await createApp(config, context);
}
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
+15 -21
View File
@@ -65,7 +65,20 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
} }
return { return {
...config, // @ts-ignore
app: async (_env) => {
const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
const appConfig = typeof config.app === "function" ? await config.app(env) : config;
const connection =
use_proxy && binding
? d1Sqlite({
binding: binding.value as any,
})
: appConfig.connection;
return {
...appConfig,
beforeBuild: async (app, registries) => { beforeBuild: async (app, registries) => {
if (!use_proxy) return; if (!use_proxy) return;
const env = await getEnv(); const env = await getEnv();
@@ -75,27 +88,8 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
bindings: async (env) => { bindings: async (env) => {
return (await config?.bindings?.(await getEnv(env))) || {}; return (await config?.bindings?.(await getEnv(env))) || {};
}, },
// @ts-ignore connection,
app: async (_env) => {
const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
if (config?.app === undefined && use_proxy && binding) {
return {
connection: d1Sqlite({
binding: binding.value,
}),
}; };
} else if (typeof config?.app === "function") {
const appConfig = await config?.app(env);
if (binding) {
appConfig.connection = d1Sqlite({
binding: binding.value,
}) as any;
}
return appConfig;
}
return config?.app || {};
}, },
} satisfies CloudflareBkndConfig<Env>; } satisfies CloudflareBkndConfig<Env>;
} }
@@ -49,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;
} }
+35 -18
View File
@@ -6,18 +6,24 @@ 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 &
Omit<Additional, "app"> & {
app?:
| Omit<BkndConfig<Args, Additional>, "app">
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
onBuilt?: (app: App) => MaybePromise<void>; onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>; beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; }
>;
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
@@ -51,11 +57,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 +70,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 +111,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 +124,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 +132,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 +155,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 +193,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);
} }
} }
+7 -4
View File
@@ -1,5 +1,6 @@
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter"; import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils"; import { isNode } from "bknd/utils";
// @ts-expect-error next is not installed
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"]; type NextjsEnv = NextApiRequest["env"];
@@ -9,16 +10,18 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>( export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>, config: NextjsBkndConfig<Env>,
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
return await createFrameworkApp(config, args ?? (process.env as Env)); return await createFrameworkApp(config, args);
} }
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
if (!cleanRequest) return req; if (!cleanRequest) return req;
const url = new URL(req.url); const url = new URL(req.url);
cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k)); cleanRequest?.searchParams?.forEach((k) => {
url.searchParams.delete(k);
});
if (isNode()) { if (isNode()) {
return new Request(url.toString(), { return new Request(url.toString(), {
@@ -39,7 +42,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>( export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {}, { cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
return async (req: Request) => { return async (req: Request) => {
const app = await getApp(config, args); const app = await getApp(config, args);
+10
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");
}
+6 -7
View File
@@ -17,14 +17,14 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>( export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {}, { distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
); );
if (relativeDistPath) { if (relativeDistPath) {
console.warn("relativeDistPath is deprecated, please use distPath instead"); $console.warn("relativeDistPath is deprecated, please use distPath instead");
} }
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -33,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(
{ {
@@ -8,14 +8,14 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>( export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>, config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
return await createFrameworkApp(config, args ?? process.env); return await createFrameworkApp(config, args);
} }
export function serve<Env = ReactRouterEnv>( export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {}, config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = process.env as Env,
) { ) {
return async (fnArgs: ReactRouterFunctionArgs) => { return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
+17 -2
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),
+11 -10
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));
} }
} }
+8 -9
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);
+1 -1
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");
+4 -6
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",
+34 -6
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) {
@@ -1,7 +1,7 @@
import type { User } from "bknd"; import type { User } from "bknd";
import type { Authenticator } from "auth/authenticate/Authenticator"; import type { Authenticator } from "auth/authenticate/Authenticator";
import { InvalidCredentialsException } from "auth/errors"; import { InvalidCredentialsException } from "auth/errors";
import { hash, $console, s, parse, jsc } from "bknd/utils"; import { hash, $console, s, parse, jsc, describeRoute } from "bknd/utils";
import { Hono } from "hono"; import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import { AuthStrategy } from "./Strategy"; import { AuthStrategy } from "./Strategy";
@@ -84,7 +84,14 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
}); });
const payloadSchema = this.getPayloadSchema(); const payloadSchema = this.getPayloadSchema();
hono.post("/login", jsc("query", redirectQuerySchema), async (c) => { hono.post(
"/login",
describeRoute({
summary: "Login with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try { try {
const body = parse(payloadSchema, await authenticator.getBody(c), { const body = parse(payloadSchema, await authenticator.getBody(c), {
onError: (errors) => { onError: (errors) => {
@@ -100,9 +107,17 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
} catch (e) { } catch (e) {
return authenticator.respondWithError(c, e as any); return authenticator.respondWithError(c, e as any);
} }
}); },
);
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => { hono.post(
"/register",
describeRoute({
summary: "Register a new user with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try { try {
const { redirect } = c.req.valid("query"); const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse( const { password, email, ...body } = parse(
@@ -128,7 +143,8 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
} catch (e) { } catch (e) {
return authenticator.respondWithError(c, e as any); return authenticator.respondWithError(c, e as any);
} }
}); },
);
return hono; return hono;
} }
+237 -68
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;
}
+77
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,
};
}
}
+52
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;
}
}
+48 -27
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,
);
} }
} }
@@ -1,4 +1,3 @@
import type { Permission } from "core/security/Permission";
import { $console, patternMatch } from "bknd/utils"; import { $console, patternMatch } from "bknd/utils";
import type { Context } from "hono"; import type { Context } from "hono";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
@@ -49,7 +48,7 @@ export const auth = (options?: {
// make sure to only register once // make sure to only register once
if (authCtx.registered) { if (authCtx.registered) {
skipped = true; skipped = true;
$console.warn(`auth middleware already registered for ${getPath(c)}`); $console.debug(`auth middleware already registered for ${getPath(c)}`);
} else { } else {
authCtx.registered = true; authCtx.registered = true;
@@ -67,48 +66,3 @@ export const auth = (options?: {
authCtx.resolved = false; authCtx.resolved = false;
authCtx.user = undefined; authCtx.user = undefined;
}); });
export const permission = (
permission: Permission | Permission[],
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
},
) =>
// @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined
if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p, c))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p, c));
}
}
await next();
});
@@ -0,0 +1,94 @@
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import { $console, threw } from "bknd/utils";
import type { Context, Hono } from "hono";
import type { RouterRoute } from "hono/types";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller";
import type { MaybePromise } from "core/types";
import { GuardPermissionsException } from "auth/authorize/Guard";
function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
return new URL(req.url).pathname;
}
const permissionSymbol = Symbol.for("permission");
type PermissionMiddlewareOptions<P extends Permission<any, any, any, any>> = {
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
} & (P extends Permission<any, any, infer PC, any>
? PC extends undefined
? {
context?: never;
}
: {
context: (c: Context<ServerEnv>) => MaybePromise<PermissionContext<P>>;
}
: {
context?: never;
});
export function permission<P extends Permission<any, any, any, any>>(
permission: P,
options: PermissionMiddlewareOptions<P>,
) {
// @ts-ignore (middlewares do not always return)
const handler = createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined
if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const context = (await options?.context?.(c)) ?? ({} as any);
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) {
returned = await options?.onDenied?.(c);
} else {
returned = await options?.onGranted?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
guard.granted(permission, c, context);
}
}
await next();
});
return Object.assign(handler, {
[permissionSymbol]: { permission, options },
});
}
export function getPermissionRoutes(hono: Hono<any>) {
const routes: {
route: RouterRoute;
permission: Permission;
options: PermissionMiddlewareOptions<Permission>;
}[] = [];
for (const route of hono.routes) {
if (permissionSymbol in route.handler) {
routes.push({
route,
...(route.handler[permissionSymbol] as any),
});
}
}
return routes;
}
+6 -2
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({
+2
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) {}
} }
@@ -0,0 +1,21 @@
import { overrideJson } from "cli/commands/create/npm";
import type { Template } from "cli/commands/create/templates";
import { getVersion } from "cli/utils/sys";
export const deno = {
key: "deno",
title: "Deno Basic",
integration: "deno",
description: "A basic bknd Deno server with static assets",
path: "gh:bknd-io/bknd/examples/deno",
installDeps: false,
ref: true,
setup: async (ctx) => {
const version = await getVersion();
await overrideJson(
"deno.json",
(json) => ({ ...json, links: undefined, imports: { bknd: `npm:bknd@${version}` } }),
{ dir: ctx.dir },
);
},
} satisfies Template;
@@ -1,3 +1,4 @@
import { deno } from "cli/commands/create/templates/deno";
import { cloudflare } from "./cloudflare"; import { cloudflare } from "./cloudflare";
export type TemplateSetupCtx = { export type TemplateSetupCtx = {
@@ -15,6 +16,7 @@ export type Integration =
| "react-router" | "react-router"
| "astro" | "astro"
| "aws" | "aws"
| "deno"
| "custom"; | "custom";
type TemplateScripts = "install" | "dev" | "build" | "start"; type TemplateScripts = "install" | "dev" | "build" | "start";
@@ -34,6 +36,11 @@ export type Template = {
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd * adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
*/ */
ref?: true | string; ref?: true | string;
/**
* control whether to install dependencies automatically
* e.g. on deno, this is not needed
*/
installDeps?: boolean;
scripts?: Partial<Record<TemplateScripts, string>>; scripts?: Partial<Record<TemplateScripts, string>>;
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>; preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>; postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
@@ -90,4 +97,5 @@ export const templates: Template[] = [
path: "gh:bknd-io/bknd/examples/aws-lambda", path: "gh:bknd-io/bknd/examples/aws-lambda",
ref: true, ref: true,
}, },
deno,
]; ];
+4 -1
View File
@@ -67,7 +67,10 @@ export async function startServer(
$console.info("Server listening on", url); $console.info("Server listening on", url);
if (options.open) { if (options.open) {
await open(url); const p = await open(url, { wait: false });
p.on("error", () => {
$console.warn("Couldn't open url in browser");
});
} }
} }
+4 -1
View File
@@ -110,7 +110,10 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
// try to use an in-memory connection // try to use an in-memory connection
} else if (options.memory) { } else if (options.memory) {
console.info("Using", c.cyan("in-memory"), "connection"); console.info("Using", c.cyan("in-memory"), "connection");
app = await makeApp({ server: { platform: options.server } }); app = await makeApp({
server: { platform: options.server },
connection: { url: ":memory:" },
});
// finally try to use env variables // finally try to use env variables
} else { } else {
+29 -1
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);
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "bun:test";
import { plunkEmail } from "./plunk";
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("plunk", () => {
it("should throw on failed", async () => {
const driver = plunkEmail({ apiKey: "invalid" });
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
});
it("should send an email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined, // Default to what Plunk sets
});
const response = await driver.send(
"help@bknd.io",
"Test Email from Plunk",
"This is a test email",
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
expect(response.emails).toBeDefined();
expect(response.timestamp).toBeDefined();
});
it("should send HTML email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send(
"help@bknd.io",
"HTML Test",
htmlBody,
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send with text and html", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const response = await driver.send("test@example.com", "Test Email", {
text: "help@bknd.io",
html: "<p>This is HTML</p>",
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { IEmailDriver } from "./index";
export type PlunkEmailOptions = {
apiKey: string;
host?: string;
from?: string;
};
export type PlunkEmailSendOptions = {
subscribed?: boolean;
name?: string;
from?: string;
reply?: string;
headers?: Record<string, string>;
};
export type PlunkEmailResponse = {
success: boolean;
emails: Array<{
contact: {
id: string;
email: string;
};
email: string;
}>;
timestamp: string;
};
export const plunkEmail = (
config: PlunkEmailOptions,
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
const host = config.host ?? "https://api.useplunk.com/v1/send";
const from = config.from;
return {
send: async (
to: string,
subject: string,
body: string | { text: string; html: string },
options?: PlunkEmailSendOptions,
) => {
const payload: any = {
from,
to,
subject,
};
if (typeof body === "string") {
payload.body = body;
} else {
payload.body = body.html;
}
const res = await fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ ...payload, ...options }),
});
if (!res.ok) {
throw new Error(`Plunk API error: ${await res.text()}`);
}
return (await res.json()) as PlunkEmailResponse;
},
};
};
+1 -1
View File
@@ -4,7 +4,7 @@ import { resendEmail } from "./resend";
const ALL_TESTS = !!process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("resend", () => { describe.skipIf(ALL_TESTS)("resend", () => {
it.only("should throw on failed", async () => { it("should throw on failed", async () => {
const driver = resendEmail({ apiKey: "invalid" } as any); const driver = resendEmail({ apiKey: "invalid" } as any);
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
}); });
+1
View File
@@ -5,3 +5,4 @@ export type { IEmailDriver } from "./email";
export { resendEmail } from "./email/resend"; export { resendEmail } from "./email/resend";
export { sesEmail } from "./email/ses"; export { sesEmail } from "./email/ses";
export { mailchannelsEmail } from "./email/mailchannels"; export { mailchannelsEmail } from "./email/mailchannels";
export { plunkEmail } from "./email/plunk";
+2 -1
View File
@@ -1,3 +1,4 @@
import type { MaybePromise } from "bknd";
import type { Event } from "./Event"; import type { Event } from "./Event";
import type { EventClass } from "./EventManager"; import type { EventClass } from "./EventManager";
@@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event<any, any>> = ( export type ListenerHandler<E extends Event<any, any>> = (
event: E, event: E,
slug: string, slug: string,
) => E extends Event<any, infer R> ? R | Promise<R | void> : never; ) => E extends Event<any, infer R> ? MaybePromise<R | void> : never;
export class EventListener<E extends Event = Event> { export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async"; mode: ListenerMode = "async";
+11 -1
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;
+34 -8
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) =>
-11
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,
};
}
}
+4
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];
};
+43
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;
});
}
+41 -1
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;
}
+32
View File
@@ -61,3 +61,35 @@ 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;
}
}
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
try {
await 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;
}
}
+6 -1
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,
+2 -5
View File
@@ -120,17 +120,14 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
} }
export function slugify(str: string): string { export function slugify(str: string): string {
return ( return String(str)
String(str)
.normalize("NFKD") // split accented characters into their base characters and diacritical marks .normalize("NFKD") // split accented characters into their base characters and diacritical marks
// biome-ignore lint/suspicious/noMisleadingCharacterClass: <explanation>
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
.trim() // trim leading or trailing whitespace .trim() // trim leading or trailing whitespace
.toLowerCase() // convert to lowercase .toLowerCase() // convert to lowercase
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
.replace(/\s+/g, "-") // replace spaces with hyphens .replace(/\s+/g, "-") // replace spaces with hyphens
.replace(/-+/g, "-") // remove consecutive hyphens .replace(/-+/g, "-"); // remove consecutive hyphens
);
} }
export function truncate(str: string, length = 50, end = "..."): string { export function truncate(str: string, length = 50, end = "..."): string {
+137 -24
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);
}, },
+4 -9
View File
@@ -17,6 +17,7 @@ import {
type Simplify, type Simplify,
sql, sql,
} from "kysely"; } from "kysely";
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector"; import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
import type { DB } from "bknd"; import type { DB } from "bknd";
import type { Constructor } from "core/registry/Registry"; import type { Constructor } from "core/registry/Registry";
@@ -70,15 +71,9 @@ export type IndexSpec = {
}; };
export type DbFunctions = { export type DbFunctions = {
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>; jsonObjectFrom: typeof jsonObjectFrom;
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>; jsonArrayFrom: typeof jsonArrayFrom;
jsonBuildObject<O extends Record<string, Expression<unknown>>>( jsonBuildObject: typeof jsonBuildObject;
obj: O,
): RawBuilder<
Simplify<{
[K in keyof O]: O[K] extends Expression<infer V> ? V : never;
}>
>;
}; };
export type ConnQuery = CompiledQuery | Compilable; export type ConnQuery = CompiledQuery | Compilable;
@@ -18,7 +18,7 @@ export type LibsqlClientFns = {
function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client { function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client {
if (clientOrCredentials && "url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
const { url, authToken } = clientOrCredentials; const { url, authToken } = clientOrCredentials;
return createClient({ url, authToken }); return createClient({ url, authToken }) as unknown as Client;
} }
return clientOrCredentials as Client; return clientOrCredentials as Client;
+1 -6
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
+20 -21
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(
+4 -5
View File
@@ -4,6 +4,7 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation";
import type { RepoQuery } from "data/server/query"; import type { RepoQuery } from "data/server/query";
import { InvalidSearchParamsException } from "data/errors"; import { InvalidSearchParamsException } from "data/errors";
import type { Entity, EntityManager, RepositoryQB } from "data/entities"; import type { Entity, EntityManager, RepositoryQB } from "data/entities";
import { $console } from "bknd/utils";
export class WithBuilder { export class WithBuilder {
static addClause( static addClause(
@@ -13,7 +14,7 @@ export class WithBuilder {
withs: RepoQuery["with"], withs: RepoQuery["with"],
) { ) {
if (!withs || !isObject(withs)) { if (!withs || !isObject(withs)) {
console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); $console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
return qb; return qb;
} }
@@ -37,9 +38,7 @@ export class WithBuilder {
let subQuery = relation.buildWith(entity, ref)(eb); let subQuery = relation.buildWith(entity, ref)(eb);
if (query) { if (query) {
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, { subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter( ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
Boolean,
) as any,
}); });
} }
@@ -57,7 +56,7 @@ export class WithBuilder {
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) { static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
let depth = 0; let depth = 0;
if (!withs || !isObject(withs)) { if (!withs || !isObject(withs)) {
withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
return depth; return depth;
} }
+11 -4
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;
+6 -1
View File
@@ -26,7 +26,12 @@ export class JsonSchemaField<
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) { constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
super(name, config); super(name, config);
this.validator = new Validator({ ...this.getJsonSchema() });
// make sure to hand over clean json
const schema = this.getJsonSchema();
this.validator = new Validator(
typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {},
);
} }
protected getSchema() { protected getSchema() {
+1 -1
View File
@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
switch (context) { switch (context) {
case "submit": case "submit":
return Number.parseInt(value); return Number.parseInt(value, 10);
} }
return value; return value;
+1 -1
View File
@@ -28,7 +28,7 @@ export function getChangeSet(
const value = _value === "" ? null : _value; const value = _value === "" ? null : _value;
// normalize to null if undefined // normalize to null if undefined
const newValue = field.getValue(value, "submit") || null; const newValue = field.getValue(value, "submit") ?? null;
// @todo: add typing for "action" // @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) { if (action === "create" || newValue !== data[key]) {
acc[key] = newValue; acc[key] = newValue;
+47 -5
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");
+15 -27
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)}`);
}
}
} }
} }
+33
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,
},
};
});
}); });
+1 -15
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;
}; */
+3 -2
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";
+3
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) {
+12 -6
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");
+1 -1
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");
+1 -1
View File
@@ -48,7 +48,7 @@ export function buildMediaSchema() {
{ {
default: {}, default: {},
}, },
); ).strict();
} }
export const mediaConfigSchema = buildMediaSchema(); export const mediaConfigSchema = buildMediaSchema();
+52
View File
@@ -0,0 +1,52 @@
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<
Config extends BkndConfig,
Args = Config extends BkndConfig<infer A> ? A : unknown,
>(codeConfig: CodeMode<Config>): BkndConfig<Args> {
return {
...codeConfig,
app: async (args) => {
const {
config: appConfig,
plugins,
isProd,
syncSchemaOptions,
} = await makeModeConfig(codeConfig, 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,
},
},
};
},
};
}
+90
View File
@@ -0,0 +1,90 @@
import type { BkndConfig } from "bknd/adapter";
import { makeModeConfig, type BkndModeConfig } from "./shared";
import { getDefaultConfig, type MaybePromise, 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 | object>;
/**
* 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<
Config extends BkndConfig,
Args = Config extends BkndConfig<infer A> ? A : unknown,
>(hybridConfig: HybridMode<Config>): BkndConfig<Args> {
return {
...hybridConfig,
app: async (args) => {
const {
config: appConfig,
isProd,
plugins,
syncSchemaOptions,
} = await makeModeConfig(hybridConfig, args);
const configFilePath = appConfig.configFilePath ?? "bknd-config.json";
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 a `reader` option when using hybrid mode",
);
const fileContent = await appConfig.reader?.(configFilePath);
let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent;
if (!fileConfig) {
$console.warn("No config found, using default config");
fileConfig = getDefaultConfig();
await appConfig.writer?.(configFilePath, JSON.stringify(fileConfig, null, 2));
}
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,
onModulesBuilt: async (ctx) => {
if (ctx.flags.sync_required && !isProd && syncSchemaOptions.force) {
$console.log("[hybrid] syncing schema");
await ctx.em.schema().sync(syncSchemaOptions);
}
await appConfig?.options?.manager?.onModulesBuilt?.(ctx);
},
...appConfig?.options?.manager,
},
},
};
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./code";
export * from "./hybrid";
export * from "./shared";
+197
View File
@@ -0,0 +1,197 @@
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
import { syncTypes, syncConfig } from "bknd/plugins";
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
import { $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>
>;
function _isProd() {
try {
return process.env.NODE_ENV === "production";
} catch (_e) {
return false;
}
}
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 with writing plugins and syncing schema. As fallback, it is set to",
_isProd(),
);
}
let needsWriter = false;
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
const isProd = config.isProduction ?? _isProd();
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
const syncSchemaOptions =
typeof config.syncSchema === "object"
? config.syncSchema
: {
force: syncFallback,
drop: syncFallback,
};
if (!isProd) {
if (typesFilePath) {
if (plugins.some((p) => p.name === "bknd-sync-types")) {
throw new Error("You have to unregister the `syncTypes` plugin");
}
needsWriter = true;
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");
}
needsWriter = true;
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(".");
}
needsWriter = true;
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,
);
}
}
if (needsWriter && typeof config.writer !== "function") {
$console.warn("You must set a `writer` function, attempts to write will fail");
}
return {
config,
isProd,
plugins,
syncSchemaOptions,
};
}

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