Compare commits

...

169 Commits

Author SHA1 Message Date
Cameron Pak 5ea40093a6 fix: include admin_basepath in absolute navigation paths
Fixes navigate(..., { absolute: true }) only prepending basepath but
missing admin_basepath, causing links like /data instead of /admin/data.

Adds admin_basepath to BkndAdminConfig type.

Amp-Thread-ID: https://ampcode.com/threads/T-019cfd9f-89ca-703c-a86c-99eef60e3fe8
Co-authored-by: Amp <amp@ampcode.com>
2026-03-17 16:20:45 -05:00
dswbx feb3911d46 Merge pull request #322 from bknd-io/fix/auth-strategy-respect-register-setting
fix(auth): `allow_register` was not respected in strategy controllers
2026-03-14 13:43:55 +01:00
dswbx ab545d3ca2 Merge pull request #347 from bknd-io/fix/with-builder-respect-alias
fix: `WithBuilder` produced embedding errored when `mappedBy` was used
2026-03-14 13:42:50 +01:00
dswbx 06c3a6f89b Merge remote-tracking branch 'origin/main' into fix/with-builder-respect-alias
# Conflicts:
#	bun.lock
2026-03-14 13:41:01 +01:00
dswbx 898a825f6e fix(WithBuilder test): updated snapshots 2026-03-14 13:40:35 +01:00
dswbx a5ddf6aa93 Merge pull request #363 from bknd-io/release/0.21
Release 0.21
2026-03-14 13:34:23 +01:00
dswbx 5aa286298c Merge remote-tracking branch 'origin/main' into release/0.21
# Conflicts:
#	bun.lock
2026-03-14 13:31:45 +01:00
dswbx 55cb2bacef chore: upgrade bun.lock 2026-03-14 13:29:47 +01:00
dswbx e6ea3f64d7 Merge pull request #351 from shishantbiswas/main
feat: add Tanstack Start adapter
2026-03-14 13:25:35 +01:00
dswbx a5f5ea2084 Merge pull request #339 from cameronapak/allow-seed
Allow CLI `sync` command to seed data properly when schema changes aren't present
2026-03-14 13:18:33 +01:00
dswbx 6fb8e9a0e7 Merge pull request #218 from cameronapak/cp/216-fix-users-link
Fix Sidebar Users Route Links
2026-03-14 13:12:53 +01:00
dswbx abc6de6ad3 Merge pull request #332 from bknd-io/feat/timestamps-index
feat(plugin/timestamps): add indexStrategy option for entity indexing
2026-03-14 13:07:49 +01:00
Shishant Biswas 71b9a0f546 Merge branch 'bknd-io:main' into main 2026-03-13 17:27:35 +05:30
Cameron Pak 038ec0d672 Merge pull request #357 from cameronapak/cp/add-contributing-md
Adding CONTRIBUTING.md doc
2026-03-08 14:16:10 -05:00
Cameron Pak 7832328a50 Merge pull request #359 from jonaspm/patch-1
Update client:only attribute in astro.mdx
2026-03-03 11:37:59 -06:00
Jonás Perusquía Morales e5bfc602e1 Update client:only attribute in astro.mdx
Astro requires client:only to specify the frontend library used
2026-03-03 11:27:59 -06:00
Cameron Pak 0318e595f2 docs: add CONTRIBUTING.md with dev setup, project structure, and contribution guidelines
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ca575-cf6e-7388-9bd4-6ddd4c27609c
2026-02-28 12:40:44 -06:00
shishantbiswas 80b0716a4a Remove unrelated Next.js file changes 2026-02-28 23:32:20 +05:30
Cameron Pak 3894271b17 Merge pull request #353 from jonaspm/docs/auth-change-password
docs: Document changePassword method in auth module
2026-02-28 11:38:33 -06:00
shishantbiswas 8bf8b22e57 refactor: remove duplicate tsup build config and clean up 2026-02-28 23:05:38 +05:30
Cameron Pak 367d5878df Fixed test 2026-02-28 11:26:27 -06:00
Cameron Pak fb429e57e2 fix: resolve undefined variable in MediaApi test and remove dead comment
Amp-Thread-ID: https://ampcode.com/threads/T-019ca53d-a225-706c-923e-28123c84d83e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 11:21:39 -06:00
Cameron Pak 9008f9c6a3 fix: normalize admin basepath to prevent double slashes in URLs
Strip trailing slashes from adminBasepath in AdminController window context
and wouter Router base to prevent paths like /admin//data when users
configure adminBasepath with a trailing slash.

Amp-Thread-ID: https://ampcode.com/threads/T-019ca537-4cc4-7174-bf9a-5325d782f097
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 11:11:44 -06:00
Cameron Pak 6321a9935a Merge branch 'main' into cp/216-fix-users-link 2026-02-28 11:03:54 -06:00
Cameron Pak eb22289460 Update @types/bun to 1.3.9
This commit updates the `@types/bun` package to version 1.3.9.
2026-02-28 11:00:37 -06:00
shishantbiswas c3198394de docs: add tanstack start integration guide 2026-02-26 09:40:42 +05:30
shishantbiswas 51237f5e1a docs(tanstack-start): add JSDoc comments to adapter functions 2026-02-26 09:40:42 +05:30
shishantbiswas 01483b912f add: example for tanstack start 2026-02-26 09:40:42 +05:30
shishantbiswas 6288faef33 Triggering CI 2026-02-26 09:40:42 +05:30
shishantbiswas 384e337bbd add: Tanstack Start adapter 2026-02-26 09:38:28 +05:30
Jonas Perusquia Morales cc1e1976f7 chore: Document error cases for password update method 2026-02-23 13:03:37 -06:00
dswbx 224d98879a Merge pull request #356 from jonaspm/fix/cross-platform-cleanup
Make dist clean cross-platform without shell commands
2026-02-20 18:00:46 +01:00
Jonas Perusquia Morales d1522c97ea Make dist clean cross-platform without shell commands 2026-02-20 09:50:30 -06:00
Jonas Perusquia Morales 8805f017a1 Update auth docs to clarify server module usage 2026-02-18 11:12:14 -06:00
Jonas Perusquia Morales 0ef06375c0 docs: Document changePassword method in auth module 2026-02-18 11:03:58 -06:00
dswbx 3d5c2cd3a8 Merge pull request #350 from xretic/main
refactor adapter tests for improved structure
2026-02-11 10:02:56 +01:00
roma fc60dfd042 refactor adapter tests for improved structure
reduced test duplication by extracting a small helper to strip connection from configs
2026-02-09 03:07:58 +01:00
Cameron Pak c8ee30b3da Merge pull request #348 from bknd-io/intro-docs-updates
Docs: consolidate dual introductions, restructure setup page
2026-02-07 17:23:21 -06:00
Cameron Pak b85c3a8a9b Remove redundant redirects config 2026-02-07 17:17:36 -06:00
Cameron Pak e20c2ef92c Docs: move redirects to global config, remove client-side redirect page
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
2026-02-07 17:14:11 -06:00
Cameron Pak 5873baad3c Docs: add redirect from /usage/introduction to /usage/setup
Co-Authored-By: Craft Agent <agents-noreply@craft.do>
2026-02-07 17:09:36 -06:00
Cameron Pak aca162f9c8 Refactor setup documentation for clarity 2026-02-07 16:58:49 -06:00
Cameron Pak 9f0997c28a Refactor and update documentation links for introduction 2026-02-07 16:50:51 -06:00
dswbx 1c114d9533 fix: WithBuilder produced embedding errored when mappedBy was used 2026-02-03 18:15:40 +01:00
dswbx 68063986d1 Merge pull request #343 from cameronapak/doc-touchups 2026-01-31 11:22:25 +01:00
Cameron Pak 53f8609e9d Docs: updating copy to reflect bknd's open opt-in philosophy 2026-01-30 03:39:53 -06:00
Cameron Pak 833bfa5049 Docs: Enhance the pun
https://github.com/bknd-io/bknd/pull/343#discussion_r2743055999
2026-01-30 03:28:12 -06:00
dswbx defcdfef0b Merge pull request #342 from cameronapak/guard-config
Docs: Guard lockout and how to set up Guard properly
2026-01-29 19:39:42 +01:00
cameronapak 348ac7f8b0 Docs: Refactor start page for clarity and flow 2026-01-25 15:20:20 -06:00
cameronapak 16b1cde8ea Docs: Add beta warning callout to start page 2026-01-25 13:58:43 -06:00
cameronapak 436b195242 Docs: Add warning about Guard lockout
Adds a callout to the `config.mdx` documentation warning users that
enabling the Guard without first creating an admin user will lock them
out.
2026-01-25 13:30:31 -06:00
dswbx ccd504440e Merge pull request #340 from bknd-io/chore/license
update license to Apache-2.0 in package.json
2026-01-21 13:30:02 +01:00
dswbx 023f5391e4 chore: update license to Apache-2.0 in package.json 2026-01-21 11:02:18 +01:00
cameronapak 11e3488917 Allow seeding without forcing sync 2026-01-19 11:46:48 -06:00
dswbx 9685c5d4e1 Merge pull request #337 from jonaspm/chore/upgrade-astro-version
chore: Update Astro to version 5.16.9 in example package
2026-01-15 14:25:20 +01:00
Jonas Perusquia Morales 9b008be512 chore: Update Astro to version 5.16.9 in example package 2026-01-14 12:52:38 -06:00
dswbx 5352331464 Merge pull request #327 from matschik/main
docs(cloudflare): fix import in Cloudflare configuration example
2026-01-11 19:32:50 +01:00
dswbx d9bce049ef feat(plugin/timestamps): add indexStrategy option for entity indexing
Introduced a new `indexStrategy` option in the timestamps plugin to configure index generation for `created_at` and `updated_at` fields. Supports "composite" and "individual" strategies for better control over database indexing. Added test coverage for different index strategies.
2026-01-09 16:04:54 +01:00
dswbx 0e4f25cf11 refactor: update type handling in SvelteKit adapter tests and MediaApi uploadFile method 2026-01-09 14:51:42 +01:00
dswbx 4a5bdade8d fix: handle lazy import errors for TestRoutes in development for nextjs 2026-01-09 14:42:18 +01:00
dswbx 047bb80a97 Merge pull request #330 from jonaspm/fix/upload-media-entity-overwrite
[WIP] fix: Add overwrite option to uploadToEntity method
2026-01-09 14:34:41 +01:00
dswbx d826cb23a3 Merge pull request #291 from bknd-io/release/0.20
Release 0.20
2026-01-09 14:32:01 +01:00
dswbx 23a9e7c278 bump version to 0.20.0 2026-01-09 14:30:31 +01:00
dswbx a9fe43130e Merge pull request #324 from screenfluent/feat/sveltekit-adapter
feat: add SvelteKit adapter
2026-01-09 14:23:57 +01:00
dswbx 3b7ec06703 Merge pull request #331 from bknd-io/change-license
change license to apache 2.0
2026-01-09 13:46:14 +01:00
dswbx 55a124b519 feat(plugin/email-otp): preserve additional payload data during user creation
Replaced `s.object` with `s.strictObject` to enforce schema validation. Updated logic to include unprocessed JSON properties (`...rest`) when creating a user, ensuring additional payload data is preserved.
2026-01-09 13:46:01 +01:00
dswbx dc3fe330d7 change license to Apache 2.0 2026-01-09 13:43:51 +01:00
dswbx d541167fb3 docs: add sveltekit integration guide 2026-01-09 13:26:28 +01:00
dswbx 7e8b357bbb add support for /admin route in backend handler and enable asset copying
updated `hooks.server.ts` to handle `/admin` routes and prevent fallback for 404 responses. added `adminBasepath` config to match backend paths. introduced assets copying step via `postinstall` script and included `robots.txt` and `favicon.ico` under `static`.
2026-01-09 13:11:01 +01:00
dswbx 521d8bc6ff refactor(adapter): replaced FrameworkBkndConfig with RuntimeBkndConfig 2026-01-09 12:59:03 +01:00
dswbx 19f1941544 Merge remote-tracking branch 'origin/main' into release/0.20 2026-01-09 11:31:43 +01:00
dswbx f9373bd0e6 test: remove only 2026-01-09 11:30:04 +01:00
dswbx 5384738b67 tests: ensuring overwrite is passed and respected in media api 2026-01-09 11:28:19 +01:00
dswbx d82e28e6b1 Merge remote-tracking branch 'origin/release/0.20' into fork/jonaspm/fix/upload-media-entity-overwrite 2026-01-09 10:12:21 +01:00
Jonas Perusquia Morales c38bc95b1d fix: Add query support to media upload API methods 2026-01-08 12:37:13 -06:00
Mathieu Schimmerling 6651fce099 Merge pull request #1 from matschik/matschik-patch-1
docs(cloudflare): modify import in Cloudflare configuration example
2026-01-02 19:38:19 +01:00
Mathieu Schimmerling 91a1fc8a0c docs(cloudflare): modify import in Cloudflare configuration example
Updated import statement to include 'd1' from 'bknd/adapter/cloudflare'.
2026-01-02 19:13:24 +01:00
cameronapak d00e5fe68c Remove redundant basepath option 2025-12-30 07:34:00 -06:00
cameronapak 6faf0d421f Fix test for Blob to File conversion
The Response.blob() method in JavaScript returns a Blob, not a File.
This means that file-specific metadata like `name` and `lastModified` is
lost. The tests were updated to reflect this by manually constructing a
File object from the Blob, mimicking how client code would handle this
conversion. The MIME type is now correctly preserved from the response
headers when creating the File.
2025-12-30 07:33:55 -06:00
cameronapak 19e51fae63 Refactor AppReduced to use basepath config option
The `admin_basepath` configuration option has been renamed to `basepath`
for clarity and consistency. This change affects how the admin base path
is accessed and utilized within the `AppReduced` utility. Tests have
been updated to reflect this renaming.
2025-12-30 07:09:00 -06:00
cameronapak 7e01cc6101 Updated bun lockfile 2025-12-30 06:59:36 -06:00
cameronapak 103cbd11df Merge branch 'main' into cp/216-fix-users-link 2025-12-30 06:55:20 -06:00
Szymon Rączka 03d3b8e407 feat(sveltekit): use createRuntimeApp for built-in Admin UI 2025-12-28 20:13:50 +01:00
Szymon Rączka a97a79f11e fix(sveltekit): make adapter runtime-agnostic
Remove process.env default to support all SvelteKit runtimes
(Node, Cloudflare Workers, Vercel Edge, Deno). Users now pass
env explicitly via $env/dynamic/private.
2025-12-28 12:47:09 +01:00
Szymon Rączka 90b4de7093 feat: add SvelteKit adapter
Add framework adapter for SvelteKit with:
- `getApp()` - get bknd app instance
- `serve()` - request handler for hooks.server.ts

Usage in hooks.server.ts:
```typescript
import { serve } from "bknd/adapter/sveltekit";
import config from "../bknd.config";

const bkndHandler = serve(config);

export const handle = async ({ event, resolve }) => {
  if (event.url.pathname.startsWith("/api/")) {
    return bkndHandler(event);
  }
  return resolve(event);
};
```

Includes:
- Adapter implementation (app/src/adapter/sveltekit/)
- Test suite
- Working example (examples/sveltekit/)
- Package exports and types
2025-12-26 17:30:03 +01:00
dswbx 81f3914e7f admin ui: made context overlays scrollable 2025-12-22 15:10:36 +01:00
dswbx 5163e2142e Merge pull request #323 from jonaspm/docs/add-readOneBy
docs: Document data.readOneBy method with usage examples
2025-12-17 17:40:37 +01:00
Jonas Perusquia Morales 15ed075de3 docs: Document data.readOneBy method with usage examples 2025-12-17 09:35:24 -06:00
dswbx 09b9a5e857 fix(auth): allow_register was not respected in strategy controllers 2025-12-06 20:51:09 +01:00
dswbx 4f7925dfd8 bump rc, hide flows by isDebug instead 2025-12-05 17:12:07 +01:00
dswbx 21e970cd8c Merge pull request #321 from bknd-io/feat/admin/mcp-in-nav
feat(admin): add mcp as main navigation item when enabled, and make it route-aware
2025-12-05 17:03:35 +01:00
dswbx 40b70e7a20 feat(admin): add mcp as main navigation item when enabled, and make it route-aware 2025-12-05 15:25:06 +01:00
dswbx 9958dd7308 Merge pull request #320 from bknd-io/feat/auth-role-and-password
feat: add `default_role_register` and password `minLength` config
2025-12-05 14:16:05 +01:00
dswbx fef77eea61 feat: add minLength to pw strategy, and enforce 2025-12-05 14:03:09 +01:00
dswbx ed41887d74 feat/fix: adding auth.default_role_register and disallow giving role on registration payload 2025-12-05 14:02:42 +01:00
dswbx 8ba4793160 Merge pull request #319 from jonaspm/docs/add-auth-logout
feat: Add documentation for auth.logout method in SDK usage
2025-12-04 22:06:09 +01:00
Jonas Perusquia Morales 65e17db2a2 feat: Add documentation for auth.logout method in SDK usage 2025-12-04 14:48:11 -06:00
dswbx e21b5e5b55 fix tsup broken watch 2025-12-03 18:08:54 +01:00
dswbx 959dee013e fix admin local auth 2025-12-03 17:41:25 +01:00
dswbx a39e76a440 Merge pull request #315 from bknd-io/feat/admin-local-auth
feat: add local auth support if api storage provided
2025-12-02 19:13:07 +01:00
dswbx acc10377ca feat: add local auth support if api storage provided 2025-12-02 18:18:45 +01:00
dswbx 506c7d84cc chore: bump version to 0.20.0-rc.1 and fix client context 2025-12-02 15:14:27 +01:00
dswbx 7ba542c040 removed sqlocal package, updated readme 2025-12-02 14:21:30 +01:00
dswbx b784d8611a Merge pull request #308 from bknd-io/feat/opfs-and-sqlocal
feat: opfs and sqlocal
2025-12-02 14:18:55 +01:00
dswbx a21d4ad6a7 docs: update sqlocal doc 2025-12-02 14:16:27 +01:00
dswbx 731b7a7e8d tests: fix node tests + add connection test suite for sqlocal 2025-12-02 14:11:26 +01:00
dswbx e56fc9c368 finalized sqlocal, added BkndBrowserApp, updated react example 2025-12-02 14:03:41 +01:00
dswbx d1aa2da5b1 Merge remote-tracking branch 'origin/release/0.20' into feat/opfs-and-sqlocal 2025-12-02 10:31:39 +01:00
dswbx 52fe9aa085 Merge pull request #289 from bknd-io/feat/postgres-fc
feat: move postgres as part of the main repo
2025-12-02 10:29:43 +01:00
dswbx 065821d9a5 chore: github tests with isolated deps 2025-12-02 10:25:49 +01:00
dswbx 0fc817382a Merge remote-tracking branch 'origin/release/0.20' into feat/postgres-fc
# Conflicts:
#	app/src/core/utils/runtime.ts
2025-12-02 10:20:58 +01:00
dswbx 2e6b95e484 Merge pull request #314 from bknd-io/docs/update-nov-react
docs: updated react-router and react sdk
2025-12-02 10:05:32 +01:00
dswbx 4e31f48917 docs: updated react-router and react sdk 2025-12-02 10:01:29 +01:00
dswbx b2872eb196 Merge pull request #313 from bknd-io/feat/data-implicit-joins
feat: Add implicit joins in repository where clauses
2025-12-02 09:49:44 +01:00
dswbx 3d804f5ac5 Merge pull request #312 from bknd-io/fix/controller-schema-access
fix: putting schema related endpoints behind schema permission and added tests
2025-12-02 09:49:25 +01:00
dswbx da025efee1 Merge pull request #307 from bknd-io/fix/ui-client-import
Fix UI client import
2025-12-02 09:48:35 +01:00
dswbx ab1fa4c895 fix: add infinite back to dropzone container 2025-12-02 09:46:09 +01:00
dswbx ebe3bc2ff5 Merge pull request #311 from bknd-io/fix/admin-field-config
fix config reconciliation for specific field types, remove lodash
2025-12-02 09:43:58 +01:00
dswbx f792d8b93e feat(repository): add implicit joins in where clauses 2025-12-02 09:38:16 +01:00
dswbx 319469f44b fix: putting schema related endpoints behind schema permission and add tests 2025-12-02 08:53:49 +01:00
dswbx 1359741e5f fix config reconciliation for specific field types, remove lodash
Replace lodash with native utilities and fix config merging to preserve common properties when switching between field types.
2025-11-29 14:18:57 +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 36e1bb1867 init opfs and sqlocal as another browser adapter 2025-11-25 16:21:16 +01:00
dswbx 0cf1d86213 fix client imports to prevent multiple react context's 2025-11-25 16:16:10 +01:00
dswbx 3fac740771 Merge branch 'release/0.20' into feat/add-otp-plugin 2025-11-21 20:03:30 +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 ba3d11edab Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-10 13:25:01 +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
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 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 40bbdb904f refactor: expose additional kysely postgres options 2025-11-05 10:47:47 +01:00
dswbx a333d537b0 refactor postgres functions to not rely on the packages 2025-11-05 10:21:35 +01:00
dswbx 108c108d82 merge origin/release/0.20 2025-11-05 10:05:57 +01:00
dswbx ed47c5bf51 postgres: move examples up 2025-10-31 20:11:16 +01:00
dswbx dbb19a27f4 fix pg imports for node tests 2025-10-31 17:48:50 +01:00
dswbx 71a17696eb fix ".only" tests 2025-10-31 17:44:37 +01:00
dswbx 2dbafebcea pg test: change is running check approach 2025-10-31 17:38:17 +01:00
dswbx 84d4635ec3 fix bun 1.3 new hoisting (for now) 2025-10-31 17:31:19 +01:00
dswbx e9c92b6086 upgrade bun, add zustand 2025-10-31 17:27:04 +01:00
dswbx 5fa7601ad5 revert bun upgrade (should be separate pr) 2025-10-31 17:21:58 +01:00
dswbx 2c7054c317 feat: move postgres as part of the main repo 2025-10-31 17:13:23 +01:00
dswbx ac891a4b61 Merge remote-tracking branch 'origin/fix/release-0.16-examples' into fork/cameronapak/cp/216-fix-users-link 2025-08-03 13:21:05 +02:00
dswbx 412eab6653 examples: fixing imports due to 0.16 2025-08-03 13:15:01 +02:00
dswbx 2fb07698d5 fixing AppReduced.spec.ts 2025-08-03 13:10:49 +02:00
dswbx 64e400c4f1 ui AppReduced: fixing basepath handling in static and embedded modes 2025-08-03 13:08:04 +02:00
dswbx 86d6e891a8 Merge remote-tracking branch 'origin/main' into fork/cameronapak/cp/216-fix-users-link
# Conflicts:
#	bun.lock
2025-08-03 12:55:20 +02:00
cameronapak 7b86611f35 bun run format 2025-07-22 08:58:30 -05:00
cameronapak 9e316a92f6 refactor: use AppTheme type and handle empty admin_basepath
Replace literal theme union with AppTheme type in AdminController options and
make path helpers resilient when admin_basepath is empty.
2025-07-22 08:57:06 -05:00
cameronapak 12ef72d595 test: add comprehensive tests for AppReduced utility and path normalization 2025-07-22 08:52:32 -05:00
cameronapak 75c29ae5e2 Add .env.local to app/.gitignore 2025-07-22 08:37:31 -05:00
cameronapak f0551f50d1 fix: set default admin_basepath and logo_return_path in constructor
Move default option values into the constructor parameter initializer to
prevent them from being overwritten by the spread operator in
`get options()`.
2025-07-22 08:36:38 -05:00
cameronapak 03aac3437b refactor: unify theme type and admin options from AdminController 2025-07-22 08:33:42 -05:00
cameronapak f07f3d1007 refactor: rename basepath to admin_basepath and normalize admin paths 2025-07-22 08:28:23 -05:00
cameronapak c833b2b539 feat: add @clack/prompts because somehow it was missing? 2025-07-22 05:53:23 -05:00
cameronapak 6070394cca Updated bun.lock file 2025-07-22 05:50:44 -05:00
227 changed files with 7993 additions and 2547 deletions
+17 -2
View File
@@ -9,6 +9,21 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -20,11 +35,11 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.3.2" bun-version: "1.3.3"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
run: bun install run: bun install #--linker=hoisted
- name: Build - name: Build
working-directory: ./app working-directory: ./app
+267
View File
@@ -0,0 +1,267 @@
# Contributing to bknd
Thank you for your interest in contributing to bknd. This guide will help you get started, understand the codebase, and submit contributions that align with the project's direction.
## Table of Contents
- [Before You Start](#before-you-start)
- [Important Resources](#important-resources)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Understanding the Codebase](#understanding-the-codebase)
- [Running Tests](#running-tests)
- [Code Style](#code-style)
- [Submitting Changes](#submitting-changes)
- [AI-Generated Code](#ai-generated-code)
- [Contributing to Documentation](#contributing-to-documentation)
- [Reporting Bugs](#reporting-bugs)
- [Getting Help](#getting-help)
- [Contributors](#contributors)
- [Maintainers](#maintainers)
## Before You Start
**Open a GitHub Issue before writing code.** This is the preferred way to propose any change. It lets maintainers and the community align on the approach before time is spent on implementation. If you have discussed a contribution in the [Discord server](https://discord.com/invite/952SFk8Tb8) instead, include a link to the relevant Discord thread in your pull request. Pull requests submitted without a corresponding issue or Discord discussion may be closed.
**Unsolicited architectural changes will be closed.** The internal architecture of bknd is intentional. Refactors, restructuring, or changes to core patterns must be discussed and approved before any code is written.
Contributions that are generally welcome (after opening an issue):
- Bug fixes
- New adapters, strategies, or storage backends
- Documentation improvements
- New examples
- Test coverage improvements
**A note on versioning**: bknd is pre-1.0 and under active development. Full backward compatibility is not guaranteed before v1.0.0. Contributors should be aware that APIs and internal interfaces may change between releases.
## Important Resources
- **Documentation**: https://docs.bknd.io
- **Issue Tracker**: https://github.com/bknd-io/bknd/issues
- **Discord**: https://discord.com/invite/952SFk8Tb8
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
## Development Setup
### Prerequisites
- Node.js 22.13 or higher
- Bun 1.3.3
### Getting Started
1. Fork the repository and clone your fork.
2. Install dependencies from the repo root:
```bash
bun install
```
3. Navigate to the main app package:
```bash
cd app
```
4. Copy the example environment file:
```bash
cp .env.example .env
```
The default `.env.example` includes a file-based SQLite database (`file:.db/dev.db`). You can change `VITE_DB_URL` to `:memory:` for an in-memory database instead.
5. Start the dev server:
```bash
bun run dev
```
The dev server runs on `http://localhost:28623` using Vite with Hono.
## Project Structure
This is a Bun monorepo using workspaces. The vast majority of the code lives in `app/`.
```
bknd/
app/ # Main "bknd" npm package (this is where most work happens)
src/
adapter/ # Runtime/framework adapters (node, bun, cloudflare, nextjs, astro, etc.)
auth/ # Authentication module (strategies, sessions, user pool)
cli/ # CLI commands
core/ # Shared utilities, event system, drivers, server helpers
data/ # Data module (entities, fields, relations, queries, schema)
flows/ # Workflow engine and tasks
media/ # Media module (storage adapters, uploads)
modes/ # Configuration modes (code, hybrid, db)
modules/ # Module system, MCP server, permissions framework
plugins/ # Built-in plugins (auth, data, cloudflare, dev)
ui/ # All frontend code
client/ # TypeScript SDK and React hooks (exported as bknd/client)
elements/ # React components for auth/media (exported as bknd/elements)
(everything else) # Admin UI (exported as bknd/ui)
App.ts # Central application orchestrator
index.ts # Main package exports
__test__/ # Unit tests (mirrors src/ structure)
e2e/ # End-to-end tests (Playwright)
build.ts # Build script (tsup/esbuild)
build.cli.ts # CLI build script
packages/ # Small satellite packages
cli/ # Standalone CLI package (bknd-cli)
plasmic/ # Plasmic integration
postgres/ # Postgres helper
sqlocal/ # SQLocal helper
docs/ # Documentation site (Next.js + Fumadocs, deployed to docs.bknd.io)
examples/ # Example projects across runtimes and frameworks
docker/ # Docker configuration
```
## Understanding the Codebase
### Path Aliases
The project uses TypeScript path aliases defined in `app/tsconfig.json`. Imports like `core/utils`, `data/connection`, or `auth/authenticate` resolve to directories under `app/src/`. When reading source code, keep this in mind -- an import like `import { Connection } from "data/connection"` refers to `app/src/data/connection`, not an external package.
### Module System
bknd is built around four core modules, each with its own schema, API routes, and permissions:
- **Data** -- entity definitions, field types, relations, queries (backed by Kysely)
- **Auth** -- authentication strategies, sessions, user management
- **Media** -- file storage with pluggable adapters (S3, Cloudinary, local filesystem, etc.)
- **Flows** -- workflow automation and task execution
These modules are managed by the `ModuleManager` (in `app/src/modules/`), which handles configuration, building, and lifecycle.
### Adapter Pattern
Adapters in `app/src/adapter/` allow bknd to run on different runtimes and frameworks. Each adapter provides the glue between bknd's Hono-based server and a specific environment (Node, Bun, Cloudflare Workers, Next.js, Astro, etc.).
### Plugin System
Plugins (in `app/src/plugins/`) hook into the app lifecycle via callbacks like `beforeBuild`, `onBuilt`, `onServerInit`, and `schema`. They can add entities, register routes, and extend behavior.
### Build System
The build is handled by a custom `app/build.ts` script using tsup (esbuild under the hood). It builds four targets in parallel:
- **API** -- the core backend
- **UI** -- the admin interface
- **Elements** -- standalone React components
- **Adapters** -- all runtime/framework adapters
## Running Tests
All test commands run from the `app/` directory.
| Command | Runner | What it runs |
|---|---|---|
| `bun run test` | Bun test | Unit tests (`*.spec.ts` files) |
| `bun run test:node` | Vitest | Node-specific tests (`*.vi-test.ts`, `*.vitest.ts`) |
| `bun run test:e2e` | Playwright | End-to-end tests (`*.e2e-spec.ts` in `e2e/`) |
| `bun run types` | TypeScript | Type checking (no emit) |
CI runs both Bun and Node tests, with a Postgres 17 service for database integration tests.
## Code Style
The project uses **Biome** for formatting and linting.
- 3-space indentation for JavaScript/TypeScript
- 100-character line width
- Spaces (not tabs)
To format and lint, run from the repo root:
```bash
bunx biome format --write ./app
bunx biome lint --changed --write ./app
```
Run both before submitting a PR. CI will catch style issues, but it is better to fix them locally first.
## Submitting Changes
1. Open a GitHub Issue describing your proposed change, or link to a Discord thread where it was discussed. Wait for feedback before writing code.
2. Fork the repo and create a branch from `main`.
3. Keep changes focused and minimal. One PR per issue.
4. Add or update tests if applicable.
5. Run the test suite and linter before pushing.
6. Open a pull request against `main`. Reference the issue number in the PR description.
Expect a response from maintainers, but be patient -- this is an actively developed project and review may take some time.
## AI-Generated Code
If you use AI tools to write or assist with your code, you must:
- **Fully review all AI-generated code yourself** before submitting. You are responsible for understanding and standing behind every line in your PR.
- **Include the prompts used** in the PR description. This gives maintainers additional context for reviewing the code.
Pull requests with unreviewed AI-generated code will be closed.
## Contributing to Documentation
The documentation site lives in the `docs/` directory and is a **separate Next.js application** built with [Fumadocs](https://fumadocs.dev). It uses **npm** (not Bun) as its package manager.
### Docs Setup
```bash
cd docs
npm install
npm run dev
```
The docs dev server runs on `http://localhost:3000`.
### Where Documentation Lives
Documentation content is written in MDX and located in `docs/content/docs/`. The directory is organized into:
- `(documentation)/` -- core documentation pages
- `guide/` -- guides and tutorials
- `api-reference/` -- API reference (partially auto-generated from OpenAPI)
To add or edit a page, create or modify an `.mdx` file in the appropriate directory. Page metadata is defined via frontmatter at the top of each file. Navigation order is controlled by `meta.json` files in each directory.
### Building the Docs
```bash
npm run build
```
This generates the OpenAPI and MCP reference pages before building the Next.js site. Make sure the build succeeds locally before submitting a docs PR.
## Reporting Bugs
Open a GitHub Issue with:
- A clear title describing the problem.
- Steps to reproduce the bug.
- Expected behavior vs. actual behavior.
- Your environment (runtime, database, adapter, bknd version).
- Any relevant error messages or logs.
## Getting Help
- **Discord**: https://discord.com/invite/952SFk8Tb8
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
- **GitHub Issues**: https://github.com/bknd-io/bknd/issues
## Contributors
Thank you to everyone who has contributed to bknd.
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
## Maintainers
- **Dennis** ([@dswbx](https://github.com/dswbx)) -- Creator and maintainer of bknd
- **Cameron Pak** ([@cameronapak](https://github.com/cameronapak)) -- Maintainer of bknd docs
+169 -77
View File
@@ -1,110 +1,202 @@
# Functional Source License, Version 1.1, MIT Future License
## Abbreviation Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
FSL-1.1-MIT TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
## Notice 1. Definitions.
Copyright 2025 Dennis Senn "License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
## Terms and Conditions "Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
### Licensor ("We") "Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
The party offering the Software under these Terms and Conditions. "You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
### The Software "Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
The "Software" is each version of the software that we make available under "Object" form shall mean any form resulting from mechanical
these Terms and Conditions, as indicated by our inclusion of these Terms and transformation or translation of a Source form, including but
Conditions with the Software. not limited to compiled object code, generated documentation,
and conversions to other media types.
### License Grant "Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Subject to your compliance with this License Grant and the Patents, "Derivative Works" shall mean any work, whether in Source or Object
Redistribution and Trademark clauses below, we hereby grant you the right to form, that is based on (or derived from) the Work and for which the
use, copy, modify, create derivative works, publicly perform, publicly display editorial revisions, annotations, elaborations, or other modifications
and redistribute the Software for any Permitted Purpose identified below. represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
### Permitted Purpose "Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use "Contributor" shall mean Licensor and any individual or Legal Entity
means making the Software available to others in a commercial product or on behalf of whom a Contribution has been received by Licensor and
service that: subsequently incorporated within the Work.
1. substitutes for the Software; 2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
2. substitutes for any other product or service we offer using the Software 3. Grant of Patent License. Subject to the terms and conditions of
that exists as of the date we make the Software available; or this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
3. offers the same or substantially similar functionality as the Software. 4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
Permitted Purposes specifically include using the Software: (a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
1. for your internal use and access; (b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
2. for non-commercial education; (c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
3. for non-commercial research; and (d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
4. in connection with professional services that you provide to a licensee You may add Your own copyright statement to Your modifications and
using the Software in accordance with these Terms and Conditions. may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
### Patents 5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
To the extent your use for a Permitted Purpose would necessarily infringe our 6. Trademarks. This License does not grant permission to use the trade
patents, the license grant above includes a license under our patents. If you names, trademarks, service marks, or product names of the Licensor,
make a claim against any party that the Software infringes or contributes to except as required for reasonable and customary use in describing the
the infringement of any patent, then your patent license to the Software ends origin of the Work and reproducing the content of the NOTICE file.
immediately.
### Redistribution 7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
The Terms and Conditions apply to all copies, modifications and derivatives of 8. Limitation of Liability. In no event and under no legal theory,
the Software. whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
If you redistribute any copies, modifications or derivatives of the Software, 9. Accepting Warranty or Additional Liability. While redistributing
you must include a copy of or a link to these Terms and Conditions and not the Work or Derivative Works thereof, You may choose to offer,
remove any copyright notices provided in or with the Software. and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
### Disclaimer END OF TERMS AND CONDITIONS
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR APPENDIX: How to apply the Apache License to your work.
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE To apply the Apache License to your work, attach the following
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, boilerplate notice, with the fields enclosed by brackets "[]"
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
### Trademarks Copyright 2026 Dennis Senn
Except for displaying the License Details and identifying us as the origin of Licensed under the Apache License, Version 2.0 (the "License");
the Software, you have no right under these Terms and Conditions to use our you may not use this file except in compliance with the License.
trademarks, trade names, service marks or product names. You may obtain a copy of the License at
## Grant of Future License http://www.apache.org/licenses/LICENSE-2.0
We hereby irrevocably grant you an additional license to use the Software under Unless required by applicable law or agreed to in writing, software
the MIT license that is effective on the second anniversary of the date we make distributed under the License is distributed on an "AS IS" BASIS,
the Software available. On or after that date, you may use the Software under WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
the MIT license, in which case the following will apply: See the License for the specific language governing permissions and
limitations under the License.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17 -1
View File
@@ -17,7 +17,7 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu
* 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, Origin Private File System (OPFS)
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc. * **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.**
@@ -165,3 +165,19 @@ npx bknd run
```bash ```bash
npm install bknd npm install bknd
``` ```
## Contributing
We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) before getting started. It covers dev setup, project structure, testing, code style, and how to submit changes.
Before writing code, please open a GitHub Issue or start a conversation in [Discord](https://discord.com/invite/952SFk8Tb8) so we can align on the approach.
## Contributors
Thank you to everyone who has contributed to bknd.
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
+2 -1
View File
@@ -1,4 +1,5 @@
playwright-report playwright-report
test-results test-results
bknd.config.* bknd.config.*
__test__/helper.d.ts __test__/helper.d.ts
.env.local
+57 -47
View File
@@ -1,66 +1,76 @@
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test"; import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter"; import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("adapter", () => { describe("adapter", () => {
it("makes config", async () => { describe("makeConfig", () => {
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({}); it("returns empty config for empty inputs", async () => {
expect( const cases: Array<Parameters<typeof adapter.makeConfig>> = [
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]), [{}],
).toEqual({}); [{}, { env: { TEST: "test" } }],
];
// merges everything returned from `app` with the config for (const args of cases) {
expect( const cfg = await adapter.makeConfig(...(args as any));
omitKeys( expect(stripConnection(cfg)).toEqual({});
await adapter.makeConfig( }
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, });
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
config: { server: { cors: { origin: "test" } } },
});
});
it("allows all properties in app function", async () => { it("merges app output into config", async () => {
const called = mock(() => null); const cfg = await adapter.makeConfig(
const config = await adapter.makeConfig( { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ { env: { TEST: "test" } },
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
{ foo: "bar" },
); );
expect(config.connection).toEqual({ url: "test" });
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
});
adapterTestSuite(bunTestRunner, { expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
});
});
it("allows all properties in app() result", async () => {
const called = mock(() => null);
const cfg = await adapter.makeConfig(
{
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const },
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
{ foo: "bar" },
);
expect(cfg.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp, makeApp: adapter.createFrameworkApp,
label: "framework app", label: "framework app",
}); });
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp, makeApp: adapter.createRuntimeApp,
label: "runtime app", label: "runtime app",
}); });
});
}); });
+36 -12
View File
@@ -1,7 +1,7 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test"; import { describe, expect, it, mock } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "core/utils"; import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
const res = await mockedBackend.request("/api/media/file/" + name); const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res); await Bun.write(path, res);
const file = await Bun.file(path); const file = Bun.file(path);
expect(file.size).toBeGreaterThan(0); expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png"); expect(file.type).toBe("image/png");
await file.delete(); await file.delete();
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
expect(isReadableStream(res.body)).toBe(true); expect(isReadableStream(res.body)).toBe(true);
expect(isReadableStream(res.res.body)).toBe(true); expect(isReadableStream(res.res.body)).toBe(true);
const blob = await res.res.blob(); const blob = (await res.res.blob()) as File;
expect(isFile(blob)).toBe(true); expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");
@@ -110,10 +110,11 @@ describe("MediaApi", () => {
const api = new MediaApi({}, mockedBackend.request); const api = new MediaApi({}, mockedBackend.request);
const name = "image.png"; const name = "image.png";
const res = await api.getFileStream(name); const res = await api.getFile(name);
expect(isReadableStream(res)).toBe(true); const stream = await api.getFileStream(name);
expect(isReadableStream(stream)).toBe(true);
const blob = await new Response(res).blob(); const blob = (await res.res.blob()) as File;
expect(isFile(blob)).toBe(true); expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");
@@ -154,15 +155,38 @@ describe("MediaApi", () => {
} }
// upload via readable from bun // upload via readable from bun
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response // upload via readable from response
{ {
const response = (await mockedBackend.request(url)) as Response; const response = (await mockedBackend.request(url)) as Response;
await matches( await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png",
);
} }
}); });
it("should add overwrite query for entity upload", async (c) => {
const call = mock(() => null);
const hono = new Hono().post(
"/api/media/entity/:entity/:id/:field",
jsc("query", s.object({ overwrite: s.boolean().optional() })),
async (c) => {
const { overwrite } = c.req.valid("query");
expect(overwrite).toBe(true);
call();
return c.json({ ok: true });
},
);
const api = new MediaApi(
{
upload_fetcher: hono.request,
},
hono.request,
);
const file = Bun.file(`${assetsPath}/image.png`);
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
overwrite: true,
});
expect(res.ok).toBe(true);
expect(call).toHaveBeenCalled();
});
}); });
+5 -1
View File
@@ -1,8 +1,12 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd"; import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper"; import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager"; import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function createApp(config: CreateAppConfig = {}) { async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({ const app = internalCreateApp({
+5 -1
View File
@@ -1,7 +1,11 @@
import { AppEvents } from "App"; import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock } from "bun:test"; import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils"; import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/** /**
* - [x] system_config * - [x] system_config
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission"; import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role"; import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils"; import { objectTransform, s } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
function createGuard( function createGuard(
permissionNames: string[], permissionNames: string[],
@@ -7,8 +7,8 @@ import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog()); beforeAll(disableConsoleLog);
afterAll(() => enableConsoleLog()); afterAll(enableConsoleLog);
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) { async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({ const app = createApp({
@@ -0,0 +1,40 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { em, entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const schema = em(
{
posts: entity("posts", {
title: text(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
({ relation }, { posts, comments }) => {
relation(posts).manyToOne(comments);
},
);
describe("DataController (auth)", () => {
test("reading schema.json", async () => {
const { request } = await createAuthTestApp(
{
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
request: new Request("http://localhost/api/data/schema.json"),
},
{
config: { data: schema.toJSON() },
},
);
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
@@ -1,20 +0,0 @@
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,41 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("SystemController (auth)", () => {
test("reading info", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.info"],
request: new Request("http://localhost/api/system/info"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("reading permissions", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.schema.read"],
request: new Request("http://localhost/api/system/permissions"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("access openapi", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.openapi"],
request: new Request("http://localhost/api/system/openapi.json"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import type { RoleSchema } from "auth/authorize/Role";
import { isPlainObject } from "core/utils";
export type AuthTestConfig = {
guest?: RoleSchema;
member?: RoleSchema;
authorized?: RoleSchema;
};
export async function createAuthTestApp(
testConfig: {
permission: AuthTestConfig | string | string[];
request: Request;
},
config: Partial<CreateAppConfig> = {},
) {
let member: RoleSchema | undefined;
let authorized: RoleSchema | undefined;
let guest: RoleSchema | undefined;
if (isPlainObject(testConfig.permission)) {
if (testConfig.permission.guest)
guest = {
...testConfig.permission.guest,
is_default: true,
};
if (testConfig.permission.member) member = testConfig.permission.member;
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
} else {
member = {
permissions: [],
};
authorized = {
permissions: Array.isArray(testConfig.permission)
? testConfig.permission
: [testConfig.permission],
};
guest = {
permissions: [],
is_default: true,
};
}
console.log("authorized", authorized);
const app = createApp({
...config,
config: {
...config.config,
auth: {
...config.config?.auth,
enabled: true,
guard: {
enabled: true,
...config.config?.auth?.guard,
},
jwt: {
...config.config?.auth?.jwt,
secret: "secret",
},
roles: {
...config.config?.auth?.roles,
guest,
member,
authorized,
admin: {
implicit_allow: true,
},
},
},
},
});
await app.build();
const users = {
guest: null,
member: await app.createUser({
email: "member@test.com",
password: "12345678",
role: "member",
}),
authorized: await app.createUser({
email: "authorized@test.com",
password: "12345678",
role: "authorized",
}),
admin: await app.createUser({
email: "admin@test.com",
password: "12345678",
role: "admin",
}),
} as const;
const tokens = {} as Record<keyof typeof users, string>;
for (const [key, user] of Object.entries(users)) {
if (user) {
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
}
}
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
const headers = new Headers(init.headers ?? {});
if (user in tokens) {
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
}
const res = await app.server.request(input, {
...init,
headers,
});
let data: any;
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
data = await res.json();
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
data = await res.text();
}
return {
status: res.status,
ok: res.ok,
headers: Object.fromEntries(res.headers.entries()),
data,
};
}
const requestFn = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async (input: string, init: RequestInit = {}) => {
return makeRequest(prop, input, init);
};
},
},
) as {
[K in keyof typeof users]: (
input: string,
init?: RequestInit,
) => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
const request = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async () => {
return makeRequest(prop, testConfig.request.url, {
headers: testConfig.request.headers,
method: testConfig.request.method,
body: testConfig.request.body,
});
};
},
},
) as {
[K in keyof typeof users]: () => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
return { app, users, request, requestFn };
}
@@ -0,0 +1,13 @@
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { describe, expect, it } from "bun:test";
describe("PasswordStrategy", () => {
it("should enforce provided minimum length", async () => {
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
expect(strategy.verify("password")({} as any)).rejects.toThrow();
expect(
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
).resolves.toBeUndefined();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const credentials = {
host: "localhost",
port: 5430,
user: "postgres",
password: "postgres",
database: "bknd",
};
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
async function isPostgresRunning() {
try {
// Try to actually connect to PostgreSQL
const conn = pg({ pool: new Pool(credentials) });
await conn.ping();
await conn.close();
return true;
} catch (e) {
return false;
}
}
describe("postgres", () => {
beforeAll(async () => {
if (!(await isPostgresRunning())) {
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
await $waitUntil("Postgres is running", isPostgresRunning);
await new Promise((resolve) => setTimeout(resolve, 500));
}
disableConsoleLog();
});
afterAll(async () => {
if (await isPostgresRunning()) {
try {
await $`docker stop bknd-test-postgres`;
} catch (e) {}
}
enableConsoleLog();
});
describe.serial.each([
["pg", () => pg({ pool: new Pool(credentials) })],
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
])("%s", (name, createConnection) => {
connectionTestSuite(
{
...bunTestRunner,
test: test.serial,
},
{
makeConnection: () => {
const connection = createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection);
await connection.close();
},
};
},
rawDialectDetails: [],
disableConsoleLog: false,
},
);
});
});
@@ -124,6 +124,81 @@ describe("[Repository]", async () => {
.then((r) => [r.count, r.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]); ).resolves.toEqual([undefined, undefined]);
}); });
test("auto join", async () => {
const schema = $em(
{
posts: $entity("posts", {
title: $text(),
content: $text(),
}),
comments: $entity("comments", {
content: $text(),
}),
another: $entity("another", {
title: $text(),
}),
},
({ relation }, { posts, comments }) => {
relation(comments).manyToOne(posts);
},
);
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
await em
.mutator("comments")
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
const res = await em.repo("comments").findMany({
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
{
// manual join should still work
const res = await em.repo("comments").findMany({
join: ["posts"],
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
}
// inexistent should be detected and thrown
expect(
em.repo("comments").findMany({
where: {
"random.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
// existing alias, but not a relation should throw
expect(
em.repo("comments").findMany({
where: {
"another.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
});
}); });
describe("[data] Repository (Events)", async () => { describe("[data] Repository (Events)", async () => {
+5 -13
View File
@@ -88,7 +88,7 @@ describe("[data] WithBuilder", async () => {
const res2 = qb2.compile(); const res2 = qb2.compile();
expect(res2.sql).toBe( expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"', 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "author"."id" asc limit ?) as obj) as "author" from "posts"',
); );
expect(res2.parameters).toEqual([1]); expect(res2.parameters).toEqual([1]);
}); });
@@ -192,9 +192,7 @@ describe("[data] WithBuilder", async () => {
{ single: {} }, { single: {} },
); );
const res = qb.compile(); const res = qb.compile();
expect(res.sql).toBe( expect(res.sql).toMatchSnapshot();
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
);
expect(res.parameters).toEqual(["categories.single", 1]); expect(res.parameters).toEqual(["categories.single", 1]);
const qb2 = WithBuilder.addClause( const qb2 = WithBuilder.addClause(
@@ -204,9 +202,7 @@ describe("[data] WithBuilder", async () => {
{ multiple: {} }, { multiple: {} },
); );
const res2 = qb2.compile(); const res2 = qb2.compile();
expect(res2.sql).toBe( expect(res2.sql).toMatchSnapshot();
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"',
);
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]); expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
}); });
@@ -271,9 +267,7 @@ describe("[data] WithBuilder", async () => {
); );
//prettyPrintQb(qb); //prettyPrintQb(qb);
expect(qb.compile().sql).toBe( expect(qb.compile().sql).toMatchSnapshot();
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]); expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
}); });
@@ -313,9 +307,7 @@ describe("[data] WithBuilder", async () => {
}, },
); );
expect(qb.compile().sql).toBe( expect(qb.compile().sql).toMatchSnapshot();
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
);
expect(qb.compile().parameters).toEqual([1, 12, 0]); expect(qb.compile().parameters).toEqual([1, 12, 0]);
}); });
@@ -0,0 +1,9 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`[data] WithBuilder recursive compiles with singles 1`] = `"select (select json_object('id', "obj"."id", 'username', "obj"."username", 'avatar', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "avatar"."id" as "id", "avatar"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "avatar"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts""`;
exports[`[data] WithBuilder recursive compiles with many 1`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'posts_id', "agg"."posts_id", 'users_id', "agg"."users_id", 'users', "agg"."users")), '[]') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object('username', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts""`;
exports[`[data] WithBuilder polymorphic 1`] = `"select (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "single"."id" as "id", "single"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "single"."id" asc limit ?) as obj) as "single" from "categories""`;
exports[`[data] WithBuilder polymorphic 2`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'path', "agg"."path")), '[]') from (select "multiple"."id" as "id", "multiple"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "multiple"."id" asc limit ? offset ?) as agg) as "multiple" from "categories""`;
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: false, hasDefaultValue: true,
comment: undefined, comment: undefined,
}, },
{ {
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: false, hasDefaultValue: true,
comment: undefined, comment: undefined,
}, },
{ {
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares"; import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import {
mergeObject,
randomString,
secureRandomString,
withDisabledConsole,
} from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import type { AppAuthSchema } from "auth/auth-schema";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -62,12 +68,12 @@ const configs = {
}, },
}; };
function createAuthApp() { function createAuthApp(config?: Partial<AppAuthSchema>) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
config: { config: {
auth: configs.auth, auth: mergeObject(configs.auth, config ?? {}),
}, },
}); });
@@ -132,6 +138,16 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
return { res, data }; return { res, data };
}, },
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
const res = (await app.server.request("/api/auth/password/register", {
method: "POST",
headers: headers(),
body: body(user),
})) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data };
},
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => { me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", { const res = (await app.server.request("/api/auth/me", {
method: "GET", method: "GET",
@@ -245,4 +261,61 @@ describe("integration auth", () => {
expect(await $fns.me()).toEqual({ user: null as any }); expect(await $fns.me()).toEqual({ user: null as any });
} }
}); });
it("should register users with default role", async () => {
const app = createAuthApp({ default_role_register: "guest" });
await app.build();
const $fns = fns(app);
// takes default role
expect(
await app
.createUser({
email: "test@bknd.io",
password: "12345678",
})
.then((r) => r.role),
).toBe("guest");
// throws error if role doesn't exist
expect(
app.createUser({
email: "test@bknd.io",
password: "12345678",
role: "doesnt exist",
}),
).rejects.toThrow();
// takes role if provided
expect(
await app
.createUser({
email: "test2@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.role),
).toBe("admin");
// registering with role is not allowed
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.res.ok),
).toBe(false);
// takes default role
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
})
.then((r) => r.data.user.role),
).toBe("guest");
});
}); });
+85 -1
View File
@@ -8,9 +8,10 @@ import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import * as proto from "data/prototype";
beforeAll(() => { beforeAll(() => {
//disableConsoleLog(); disableConsoleLog();
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
}); });
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -128,4 +129,87 @@ describe("MediaController", () => {
expect(destFile.exists()).resolves.toBe(true); expect(destFile.exists()).resolves.toBe(true);
await destFile.delete(); await destFile.delete();
}); });
test("entity upload with max_items and overwrite", async () => {
const app = createApp({
config: {
media: mergeObject(
{
enabled: true,
adapter: {
type: "local",
config: {
path: assetsTmpPath,
},
},
},
{},
),
data: {
entities: {
posts: proto
.entity("posts", {
title: proto.text(),
cover: proto.medium(),
})
.toJSON(),
},
},
},
});
await app.build();
// create a post first
const createRes = await app.server.request("/api/data/entity/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test Post" }),
});
expect(createRes.status).toBe(201);
const { data: post } = (await createRes.json()) as any;
const file = Bun.file(path);
const uploadedFiles: string[] = [];
// upload first file to entity (should succeed)
const res1 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
method: "POST",
body: file,
});
expect(res1.status).toBe(201);
const result1 = (await res1.json()) as any;
uploadedFiles.push(result1.name);
// upload second file without overwrite (should fail - max_items reached)
const res2 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
method: "POST",
body: file,
});
expect(res2.status).toBe(400);
const result2 = (await res2.json()) as any;
expect(result2.error).toContain("Max items");
// upload third file with overwrite=true (should succeed and delete old file)
const res3 = await app.server.request(
`/api/media/entity/posts/${post.id}/cover?overwrite=true`,
{
method: "POST",
body: file,
},
);
expect(res3.status).toBe(201);
const result3 = (await res3.json()) as any;
uploadedFiles.push(result3.name);
// verify old file was deleted from storage
const oldFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[0]);
expect(await oldFile.exists()).toBe(false);
// verify new file exists
const newFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[1]);
expect(await newFile.exists()).toBe(true);
// cleanup
await newFile.delete();
});
}); });
+28 -12
View File
@@ -10,12 +10,6 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("AppAuth", () => { describe("AppAuth", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
});
moduleTestSuite(AppAuth); moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext; let ctx: ModuleBuildContext;
@@ -39,11 +33,9 @@ describe("AppAuth", () => {
await auth.build(); await auth.build();
const oldConfig = auth.toJSON(true); const oldConfig = auth.toJSON(true);
//console.log(oldConfig);
await auth.schema().patch("enabled", true); await auth.schema().patch("enabled", true);
await auth.build(); await auth.build();
const newConfig = auth.toJSON(true); const newConfig = auth.toJSON(true);
//console.log(newConfig);
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
}); });
@@ -69,7 +61,6 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -80,7 +71,6 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -119,7 +109,6 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -130,7 +119,6 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -235,4 +223,32 @@ describe("AppAuth", () => {
} }
} }
}); });
test("default role for registration must be a valid role", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "123456",
},
allow_register: true,
roles: {
guest: {
is_default: true,
},
},
},
},
});
await app.build();
const auth = app.module.auth;
// doesn't allow invalid role
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
// allows valid role
await auth.schema().patch("default_role_register", "guest");
expect(auth.toJSON().default_role_register).toBe("guest");
});
}); });
+5 -1
View File
@@ -1,10 +1,14 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/media/AppMedia"; import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppMedia", () => { describe("AppMedia", () => {
test.skip("...", () => { test.skip("...", () => {
+5 -1
View File
@@ -1,7 +1,11 @@
import { it, expect, describe } from "bun:test"; import { it, expect, describe, beforeAll, afterAll } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager"; import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations"; import { TABLE_NAME } from "modules/db/migrations";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("DbModuleManager", () => { describe("DbModuleManager", () => {
it("should extract secrets", async () => { it("should extract secrets", async () => {
+1 -12
View File
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype"; import { entity, text } from "data/prototype";
beforeAll(disableConsoleLog); beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
@@ -82,7 +82,6 @@ describe("ModuleManager", async () => {
}, },
}, },
} as any; } as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely; const db = dummyConnection.kysely;
@@ -97,10 +96,6 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
/* console.log({
json,
configs: mm2.configs(),
}); */
//expect(stripMark(json)).toEqual(stripMark(mm2.configs())); //expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
@@ -228,8 +223,6 @@ describe("ModuleManager", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -275,7 +268,6 @@ describe("ModuleManager", async () => {
} }
override async build() { override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value && this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value); throw new Error("value must be positive, given: " + this.config.value);
} }
@@ -296,9 +288,6 @@ describe("ModuleManager", async () => {
} }
} }
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => { test("it builds", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection); const mm = new TestModuleManager(dummyConnection);
@@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach } from "vitest";
import { AppReduced, type AppType } from "ui/client/utils/AppReduced";
import type { BkndAdminProps } from "ui/Admin";
// Import the normalizeAdminPath function for testing
// Note: This assumes the function is exported or we need to test it indirectly through public methods
describe("AppReduced", () => {
let mockAppJson: AppType;
let appReduced: AppReduced;
beforeEach(() => {
mockAppJson = {
data: {
entities: {},
relations: {},
},
flows: {
flows: {},
},
auth: {},
} as AppType;
});
describe("getSettingsPath", () => {
it("should return settings path with basepath", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
expect(result).toBe("~/admin/settings");
});
it("should return settings path with empty basepath", () => {
const options: BkndAdminProps["config"] = {
basepath: "",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
expect(result).toBe("~/settings");
});
it("should append additional path segments", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["user", "profile"]);
expect(result).toBe("~/admin/settings/user/profile");
});
it("should normalize multiple slashes", () => {
const options: BkndAdminProps["config"] = {
basepath: "//admin//",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["//user//"]);
expect(result).toBe("~/admin/settings/user");
});
it("should handle basepath without leading slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
expect(result).toBe("~/admin/settings");
});
});
describe("getAbsolutePath", () => {
it("should return absolute path with basepath", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("dashboard");
expect(result).toBe("~/admin/dashboard");
});
it("should return base path when no path provided", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath();
expect(result).toBe("~/admin");
});
it("should normalize paths correctly", () => {
const options: BkndAdminProps["config"] = {
basepath: "//admin//",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("//dashboard//");
expect(result).toBe("~/admin/dashboard");
});
});
describe("options getter", () => {
it("should return merged options with defaults", () => {
const customOptions: BkndAdminProps["config"] = {
basepath: "/custom-admin",
logo_return_path: "/custom-home",
};
appReduced = new AppReduced(mockAppJson, customOptions);
const options = appReduced.options;
expect(options).toEqual({
logo_return_path: "/custom-home",
basepath: "/custom-admin",
});
});
it("should use default logo_return_path when not provided", () => {
const customOptions: BkndAdminProps["config"] = {
basepath: "/admin",
};
appReduced = new AppReduced(mockAppJson, customOptions);
const options = appReduced.options;
expect(options.logo_return_path).toBe("/");
expect(options.basepath).toBe("/admin");
});
});
describe("path normalization behavior", () => {
it("should normalize duplicate slashes in settings path", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["//nested//path//"]);
expect(result).toBe("~/admin/settings/nested/path");
});
it("should handle root path normalization", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath();
// The normalizeAdminPath function removes trailing slashes except for root "/"
// When basepath is "/", the result is "~/" which becomes "~" after normalization
expect(result).toBe("~");
});
it("should preserve entity paths ending with slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("entity/");
expect(result).toBe("~/admin/entity");
});
it("should remove trailing slashes from non-entity paths", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("dashboard/");
expect(result).toBe("~/admin/dashboard");
});
});
describe("withBasePath - double slash fix (admin_basepath with trailing slash)", () => {
it("should not produce double slashes when admin_basepath has trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.withBasePath("/data");
expect(result).toBe("/admin/data");
expect(result).not.toContain("//");
});
it("should work correctly when admin_basepath has no trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.withBasePath("/data");
expect(result).toBe("/admin/data");
});
it("should handle absolute paths with admin_basepath trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("data");
expect(result).toBe("~/admin/data");
expect(result).not.toContain("//");
});
it("should handle settings path with admin_basepath trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["general"]);
expect(result).toBe("~/admin/settings/general");
expect(result).not.toContain("//");
});
});
describe("edge cases", () => {
it("should handle undefined basepath", () => {
const options: BkndAdminProps["config"] = {
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
// When basepath is undefined, it defaults to empty string
expect(result).toBe("~/settings");
});
it("should handle null path segments", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["", "valid", ""]);
expect(result).toBe("~/admin/settings/valid");
});
});
});
+343 -247
View File
@@ -2,6 +2,8 @@ import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors"; import c from "picocolors";
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -12,163 +14,178 @@ const clean = args.includes("--clean");
// silence tsup // silence tsup
const oldConsole = { const oldConsole = {
log: console.log, log: console.log,
warn: console.warn, warn: console.warn,
}; };
console.log = () => {}; console.log = () => {};
console.warn = () => {}; console.warn = () => {};
const define = { const define = {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
}; };
if (clean) { if (clean) {
console.info("Cleaning dist (w/o static)"); console.info("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`; // Cross-platform clean: remove all files/folders in dist except static
const distPath = join(import.meta.dir, "dist");
try {
const entries = readdirSync(distPath);
for (const entry of entries) {
if (entry === "static") continue;
const entryPath = join(distPath, entry);
rmSync(entryPath, { recursive: true, force: true });
}
} catch (e) {
// dist may not exist yet, ignore
}
} }
let types_running = false; let types_running = false;
function buildTypes() { function buildTypes() {
if (types_running || !types) return; if (types_running || !types) return;
types_running = true; types_running = true;
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built")); oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased")); oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false; types_running = false;
}, },
}); });
}, },
}); });
} }
if (types && !watch) { if (types && !watch) {
buildTypes(); buildTypes();
} }
let watcher_timeout: any; let watcher_timeout: any;
function delayTypes() { function delayTypes() {
if (!watch || !types) return; if (!watch || !types) return;
if (watcher_timeout) { if (watcher_timeout) {
clearTimeout(watcher_timeout); clearTimeout(watcher_timeout);
} }
watcher_timeout = setTimeout(buildTypes, 1000); watcher_timeout = setTimeout(buildTypes, 1000);
} }
const dependencies = Object.keys(pkg.dependencies); const dependencies = Object.keys(pkg.dependencies);
// collection of always-external packages // collection of always-external packages
const external = [ const external = [
...dependencies, ...dependencies,
"bun:test", "bun:test",
"node:test", "node:test",
"node:assert/strict", "node:assert/strict",
"@libsql/client", "@libsql/client",
"bknd", "bknd",
/^bknd\/.*/, /^bknd\/.*/,
"jsonv-ts", "jsonv-ts",
/^jsonv-ts\/.*/, /^jsonv-ts\/.*/,
] as const; ] as const;
/** /**
* Building backend and general API * Building backend and general API
*/ */
async function buildApi() { async function buildApi() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch, // don't use tsup's broken watch, we'll handle it ourselves
define, watch: false,
entry: [ define,
"src/index.ts", entry: [
"src/core/utils/index.ts", "src/index.ts",
"src/plugins/index.ts", "src/core/utils/index.ts",
"src/modes/index.ts", "src/plugins/index.ts",
], "src/modes/index.ts",
outDir: "dist", ],
external: [...external], outDir: "dist",
metafile: true, external: [...external],
target: "esnext", metafile: true,
platform: "browser", target: "esnext",
removeNodeProtocol: false, platform: "browser",
format: ["esm"], removeNodeProtocol: false,
splitting: false, format: ["esm"],
loader: { splitting: false,
".svg": "dataurl", loader: {
}, ".svg": "dataurl",
onSuccess: async () => { },
delayTypes(); onSuccess: async () => {
oldConsole.log(c.cyan("[API]"), c.green("built")); delayTypes();
}, oldConsole.log(c.cyan("[API]"), c.green("built"));
}); },
});
} }
async function rewriteClient(path: string) { async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text(); const bundle = await Bun.file(path).text();
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client")); await Bun.write(
path,
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
);
} }
/** /**
* Building UI for direct imports * Building UI for direct imports
*/ */
async function buildUi() { async function buildUi() {
const base = { const base = {
minify, minify,
sourcemap, sourcemap,
watch, watch: false,
define, define,
external: [ external: [
...external, ...external,
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
"react/jsx-dev-runtime", "react/jsx-dev-runtime",
"use-sync-external-store", "use-sync-external-store",
/codemirror/, /codemirror/,
"@xyflow/react", "@xyflow/react",
"@mantine/core", "@mantine/core",
], ],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm"], format: ["esm"],
splitting: false, splitting: false,
bundle: true, bundle: true,
treeshake: true, treeshake: true,
loader: { loader: {
".svg": "dataurl", ".svg": "dataurl",
}, },
esbuildOptions: (options) => { esbuildOptions: (options) => {
options.logLevel = "silent"; options.logLevel = "silent";
}, },
} satisfies tsup.Options; } satisfies tsup.Options;
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui", outDir: "dist/ui",
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/index.js"); await rewriteClient("./dist/ui/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built")); oldConsole.log(c.cyan("[UI]"), c.green("built"));
}, },
}); });
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/client/index.ts"], entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client", outDir: "dist/ui/client",
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js"); await rewriteClient("./dist/ui/client/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built")); oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
}, },
}); });
} }
/** /**
@@ -177,154 +194,233 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client" * - ui/client is external, and after built replaced with "bknd/client"
*/ */
async function buildUiElements() { async function buildUiElements() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch, watch: false,
define, define,
entry: ["src/ui/elements/index.ts"], entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
"ui/client", "ui/client",
"react", "bknd",
"react-dom", /^bknd\/.*/,
"react/jsx-runtime", "wouter",
"react/jsx-dev-runtime", "react",
"use-sync-external-store", "react-dom",
], "react/jsx-runtime",
metafile: true, "react/jsx-dev-runtime",
platform: "browser", "use-sync-external-store",
format: ["esm"], ],
splitting: false, metafile: true,
bundle: true, platform: "browser",
treeshake: true, format: ["esm"],
loader: { splitting: false,
".svg": "dataurl", bundle: true,
}, treeshake: true,
esbuildOptions: (options) => { loader: {
options.alias = { ".svg": "dataurl",
// not important for elements, mock to reduce bundle },
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts", esbuildOptions: (options) => {
}; options.alias = {
}, // not important for elements, mock to reduce bundle
onSuccess: async () => { "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
await rewriteClient("./dist/ui/elements/index.js"); };
delayTypes(); },
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built")); onSuccess: async () => {
}, await rewriteClient("./dist/ui/elements/index.js");
}); delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
},
});
} }
/** /**
* Building adapters * Building adapters
*/ */
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options { function baseConfig(
return { adapter: string,
minify, overrides: Partial<tsup.Options> = {},
sourcemap, ): tsup.Options {
watch, return {
entry: [`src/adapter/${adapter}/index.ts`], minify,
format: ["esm"], sourcemap,
platform: "neutral", watch: false,
outDir: `dist/adapter/${adapter}`, entry: [`src/adapter/${adapter}/index.ts`],
metafile: true, format: ["esm"],
splitting: false, platform: "neutral",
removeNodeProtocol: false, outDir: `dist/adapter/${adapter}`,
onSuccess: async () => { metafile: true,
delayTypes(); splitting: false,
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); removeNodeProtocol: false,
}, onSuccess: async () => {
...overrides, delayTypes();
define: { oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
...define, },
...overrides.define, ...overrides,
}, define: {
external: [ ...define,
/^cloudflare*/, ...overrides.define,
/^@?hono.*?/, },
/^(bknd|react|next|node).*?/, external: [
/.*\.(html)$/, /^cloudflare*/,
...external, /^@?hono.*?/,
...(Array.isArray(overrides.external) ? overrides.external : []), /^(bknd|react|next|node).*?/,
], /.*\.(html)$/,
}; ...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
} }
async function buildAdapters() { async function buildAdapters() {
await Promise.all([ await Promise.all([
// base adapter handles // base adapter handles
tsup.build({ tsup.build({
...baseConfig(""), ...baseConfig(""),
target: "esnext", target: "esnext",
platform: "neutral", platform: "neutral",
entry: ["src/adapter/index.ts"], entry: ["src/adapter/index.ts"],
outDir: "dist/adapter", outDir: "dist/adapter",
// only way to keep @vite-ignore comments // only way to keep @vite-ignore comments
minify: false, minify: false,
}), }),
// specific adatpers // specific adatpers
tsup.build(baseConfig("react-router")), tsup.build(baseConfig("react-router")),
tsup.build( tsup.build(
baseConfig("bun", { baseConfig("browser", {
external: [/^bun\:.*/], external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
}), }),
),
tsup.build({ tsup.build(
...baseConfig("nextjs"), baseConfig("bun", {
platform: "node", external: [/^bun\:.*/],
}), }),
),
tsup.build({ tsup.build(baseConfig("astro")),
...baseConfig("node"), tsup.build(baseConfig("aws")),
platform: "node", tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}), }),
),
tsup.build({ tsup.build(
...baseConfig("sqlite/edge"), baseConfig("cloudflare/proxy", {
entry: ["src/adapter/sqlite/edge.ts"], target: "esnext",
outDir: "dist/adapter/sqlite", entry: ["src/adapter/cloudflare/proxy.ts"],
metafile: false, outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}), }),
),
tsup.build({ tsup.build({
...baseConfig("sqlite/node"), ...baseConfig("vite"),
entry: ["src/adapter/sqlite/node.ts"], platform: "node",
outDir: "dist/adapter/sqlite", }),
platform: "node",
metafile: false,
}),
tsup.build({ tsup.build({
...baseConfig("sqlite/bun"), ...baseConfig("nextjs"),
entry: ["src/adapter/sqlite/bun.ts"], platform: "node",
outDir: "dist/adapter/sqlite", }),
metafile: false,
external: [/^bun\:.*/], tsup.build({
}), ...baseConfig("tanstack-start"),
]); platform: "node",
}),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
tsup.build({
...baseConfig("node"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
} }
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]); async function buildAll() {
await Promise.all([
buildApi(),
buildUi(),
buildUiElements(),
buildAdapters(),
]);
}
// initial build
await buildAll();
// custom watcher since tsup's watch is broken in 8.3.5+
if (watch) {
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false;
const rebuild = async () => {
if (isBuilding) return;
isBuilding = true;
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
try {
await buildAll();
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
} catch (e) {
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
}
isBuilding = false;
};
const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100);
};
// watch src directory recursively
fsWatch(
join(import.meta.dir, "src"),
{ recursive: true },
(event, filename) => {
if (!filename) return;
// ignore non-source files
if (
!filename.endsWith(".ts") &&
!filename.endsWith(".tsx") &&
!filename.endsWith(".css")
)
return;
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
debouncedRebuild();
},
);
// keep process alive
await new Promise(() => {});
}
+36 -33
View File
@@ -1,44 +1,47 @@
const adapter = process.env.TEST_ADAPTER; const adapter = process.env.TEST_ADAPTER;
const default_config = { const default_config = {
media_adapter: "local", media_adapter: "local",
base_path: "", base_path: "",
} as const; } as const;
const configs = { const configs = {
cloudflare: { cloudflare: {
media_adapter: "r2", media_adapter: "r2",
}, },
"react-router": { "react-router": {
base_path: "/admin", base_path: "/admin",
}, },
nextjs: { nextjs: {
base_path: "/admin", base_path: "/admin",
}, },
astro: { astro: {
base_path: "/admin", base_path: "/admin",
}, },
node: { node: {
base_path: "", base_path: "",
}, },
bun: { bun: {
base_path: "", base_path: "",
}, },
"tanstack-start": {
base_path: "/admin",
},
}; };
export function getAdapterConfig(): typeof default_config { export function getAdapterConfig(): typeof default_config {
if (adapter) { if (adapter) {
if (!configs[adapter]) { if (!configs[adapter]) {
console.warn( console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`, `Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
); );
} else { } else {
return { return {
...default_config, ...default_config,
...configs[adapter], ...configs[adapter],
}; };
} }
} }
return default_config; return default_config;
} }
+28 -4
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.19.0", "version": "0.20.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.3.2", "packageManager": "bun@1.3.3",
"engines": { "engines": {
"node": ">=22.13" "node": ">=22.13"
}, },
@@ -46,7 +46,7 @@
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report", "test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
"docs:build-assets": "bun internal/docs.build-assets.ts" "docs:build-assets": "bun internal/docs.build-assets.ts"
}, },
"license": "FSL-1.1-MIT", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
@@ -65,7 +65,7 @@
"hono": "4.10.4", "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.9.3", "jsonv-ts": "^0.10.1",
"kysely": "0.28.8", "kysely": "0.28.8",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
@@ -99,6 +99,7 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/pg": "^8.15.6",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-react": "^5.1.0",
@@ -110,14 +111,17 @@
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"kysely-generic-sqlite": "^1.2.1", "kysely-generic-sqlite": "^1.2.1",
"kysely-postgres-js": "^2.0.0",
"libsql": "^0.5.22", "libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0", "libsql-stateless-easy": "^1.8.0",
"miniflare": "^4.20251011.2", "miniflare": "^4.20251011.2",
"open": "^10.2.0", "open": "^10.2.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"pg": "^8.16.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"postgres": "^3.4.7",
"posthog-js-lite": "^3.6.0", "posthog-js-lite": "^3.6.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -125,6 +129,7 @@
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0", "react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10", "sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -248,11 +253,26 @@
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.js" "require": "./dist/adapter/astro/index.js"
}, },
"./adapter/sveltekit": {
"types": "./dist/types/adapter/sveltekit/index.d.ts",
"import": "./dist/adapter/sveltekit/index.js",
"require": "./dist/adapter/sveltekit/index.js"
},
"./adapter/aws": { "./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts", "types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js", "import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js" "require": "./dist/adapter/aws/index.js"
}, },
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./adapter/tanstack-start": {
"types": "./dist/types/adapter/tanstack-start/index.d.ts",
"import": "./dist/adapter/tanstack-start/index.js",
"require": "./dist/adapter/tanstack-start/index.js"
},
"./dist/main.css": "./dist/ui/main.css", "./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css", "./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json", "./dist/manifest.json": "./dist/static/.vite/manifest.json",
@@ -270,6 +290,8 @@
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"], "adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"], "adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"], "adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"] "adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
} }
}, },
@@ -299,6 +321,8 @@
"remix", "remix",
"react-router", "react-router",
"astro", "astro",
"sveltekit",
"svelte",
"bun", "bun",
"node" "node"
] ]
+1 -1
View File
@@ -61,7 +61,7 @@ export class Api {
private token?: string; private token?: string;
private user?: TApiUser; private user?: TApiUser;
private verified = false; private verified = false;
private token_transport: "header" | "cookie" | "none" = "header"; public token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi; public system!: SystemApi;
public data!: DataApi; public data!: DataApi;
+153
View File
@@ -0,0 +1,153 @@
import {
createContext,
lazy,
Suspense,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { checksum } from "bknd/utils";
import { App, registries, sqlocal, type BkndConfig } from "bknd";
import { Route, Router, Switch } from "wouter";
import { ClientProvider } from "bknd/client";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig, DatabasePath } from "sqlocal";
import { OpfsStorageAdapter } from "bknd/adapter/browser";
import type { BkndAdminConfig } from "bknd/ui";
const Admin = lazy(() =>
Promise.all([
import("bknd/ui"),
// @ts-ignore
import("bknd/dist/styles.css"),
]).then(([mod]) => ({
default: mod.Admin,
})),
);
function safeViewTransition(fn: () => void) {
if (document.startViewTransition) {
document.startViewTransition(fn);
} else {
fn();
}
}
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
BkndConfig<Args>,
"connection" | "app"
> & {
adminConfig?: BkndAdminConfig;
connection?: ClientConfig | DatabasePath;
};
export type BkndBrowserAppProps = {
children: ReactNode;
header?: ReactNode;
loading?: ReactNode;
notFound?: ReactNode;
} & BrowserBkndConfig;
const BkndBrowserAppContext = createContext<{
app: App;
hash: string;
}>(undefined!);
export function BkndBrowserApp({
children,
adminConfig,
header,
loading,
notFound,
...config
}: BkndBrowserAppProps) {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
async function onBuilt(app: App) {
safeViewTransition(async () => {
setApp(app);
setHash(await checksum(app.toJSON()));
});
}
useEffect(() => {
setup({ ...config, adminConfig })
.then((app) => onBuilt(app as any))
.catch(console.error);
}, []);
if (!app) {
return (
loading ?? (
<Center>
<span style={{ opacity: 0.2 }}>Loading...</span>
</Center>
)
);
}
return (
<BkndBrowserAppContext.Provider value={{ app, hash }}>
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
{header}
<Router key={hash}>
<Switch>
{children}
<Route path={adminRoutePath}>
<Suspense>
<Admin config={adminConfig} />
</Suspense>
</Route>
<Route path="*">
{notFound ?? (
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
)}
</Route>
</Switch>
</Router>
</ClientProvider>
</BkndBrowserAppContext.Provider>
);
}
export function useApp() {
return useContext(BkndBrowserAppContext);
}
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
style={{
width: "100%",
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
...(props.style ?? {}),
}}
/>
);
let initialized = false;
async function setup(config: BrowserBkndConfig = {}) {
if (initialized) return;
initialized = true;
registries.media.register("opfs", OpfsStorageAdapter);
const app = App.create({
...config,
// @ts-ignore
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
});
await config.beforeBuild?.(app);
await app.build({ sync: true });
await config.onBuilt?.(app);
return app;
}
@@ -0,0 +1,34 @@
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
// @ts-ignore
import { assetsPath } from "../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
describe("OpfsStorageAdapter", async () => {
let mockRoot: MockFileSystemDirectoryHandle;
let testSuiteAdapter: OpfsStorageAdapter;
const _mock = spyOn(global, "navigator");
beforeAll(() => {
// mock navigator.storage.getDirectory()
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
const mockNavigator = {
storage: {
getDirectory: vi.fn().mockResolvedValue(mockRoot),
},
};
// @ts-ignore
_mock.mockReturnValue(mockNavigator);
testSuiteAdapter = new OpfsStorageAdapter();
});
afterAll(() => {
_mock.mockRestore();
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
});
@@ -0,0 +1,265 @@
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
import { StorageAdapter, guessMimeType } from "bknd";
import { parse, s, isFile, isBlob } from "bknd/utils";
export const opfsAdapterConfig = s.object(
{
root: s.string({ default: "" }).optional(),
},
{
title: "OPFS",
description: "Origin Private File System storage",
additionalProperties: false,
},
);
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
/**
* Storage adapter for OPFS (Origin Private File System)
* Provides browser-based file storage using the File System Access API
*/
export class OpfsStorageAdapter extends StorageAdapter {
private config: OpfsAdapterConfig;
private rootPromise: Promise<FileSystemDirectoryHandle>;
constructor(config: Partial<OpfsAdapterConfig> = {}) {
super();
this.config = parse(opfsAdapterConfig, config);
this.rootPromise = this.initializeRoot();
}
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
if (!this.config.root) {
return opfsRoot;
}
// navigate to or create nested directory structure
const parts = this.config.root.split("/").filter(Boolean);
let current = opfsRoot;
for (const part of parts) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
getSchema() {
return opfsAdapterConfig;
}
getName(): string {
return "opfs";
}
async listObjects(prefix?: string): Promise<FileListObject[]> {
const root = await this.rootPromise;
const files: FileListObject[] = [];
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
if (!prefix || name.startsWith(prefix)) {
const file = await (handle as FileSystemFileHandle).getFile();
files.push({
key: name,
last_modified: new Date(file.lastModified),
size: file.size,
});
}
}
}
return files;
}
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
// wrap the hex string in quotes for ETag format
return `"${hashHex}"`;
}
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) {
throw new Error("Body is empty");
}
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
try {
let contentBuffer: ArrayBuffer;
if (isFile(body)) {
contentBuffer = await body.arrayBuffer();
await writable.write(contentBuffer);
} else if (body instanceof ReadableStream) {
const chunks: Uint8Array[] = [];
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
await writable.write(value);
}
} finally {
reader.releaseLock();
}
// compute total size and combine chunks for etag
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
contentBuffer = combined.buffer;
} else if (isBlob(body)) {
contentBuffer = await (body as Blob).arrayBuffer();
await writable.write(contentBuffer);
} else {
// body is ArrayBuffer or ArrayBufferView
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
contentBuffer = view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength,
) as ArrayBuffer;
} else {
contentBuffer = body as ArrayBuffer;
}
await writable.write(body);
}
await writable.close();
return await this.computeEtagFromArrayBuffer(contentBuffer);
} catch (error) {
await writable.abort();
throw error;
}
}
async deleteObject(key: string): Promise<void> {
try {
const root = await this.rootPromise;
await root.removeEntry(key);
} catch {
// file doesn't exist, which is fine
}
}
async objectExists(key: string): Promise<boolean> {
try {
const root = await this.rootPromise;
await root.getFileHandle(key);
return true;
} catch {
return false;
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
const fileSize = file.size;
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const arrayBuffer = await file.arrayBuffer();
const chunk = arrayBuffer.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.byteLength.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// normal request - return entire file
const content = await file.arrayBuffer();
responseHeaders.set("Content-Length", content.byteLength.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch {
// handle file reading errors
return new Response("", { status: 404 });
}
}
getObjectUrl(_key: string): string {
throw new Error("Method not implemented.");
}
async getObjectMeta(key: string): Promise<FileMeta> {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
return {
type: guessMimeType(key) || "application/octet-stream",
size: file.size,
};
}
toJSON(_secrets?: boolean) {
return {
type: this.getName(),
config: this.config,
};
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./OpfsStorageAdapter";
export * from "./BkndBrowserApp";
+136
View File
@@ -0,0 +1,136 @@
// mock OPFS API for testing
class MockFileSystemFileHandle {
kind: "file" = "file";
name: string;
private content: ArrayBuffer;
private lastModified: number;
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
this.name = name;
this.content = content;
this.lastModified = Date.now();
}
async getFile(): Promise<File> {
return new File([this.content], this.name, {
lastModified: this.lastModified,
type: this.guessMimeType(),
});
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: any) {
if (data instanceof ArrayBuffer) {
handle.content = data;
} else if (ArrayBuffer.isView(data)) {
handle.content = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
} else if (data instanceof Blob) {
handle.content = await data.arrayBuffer();
}
handle.lastModified = Date.now();
},
async close() {},
async abort() {},
async seek(_position: number) {},
async truncate(_size: number) {},
} as FileSystemWritableFileStream;
}
private guessMimeType(): string {
const ext = this.name.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
txt: "text/plain",
json: "application/json",
pdf: "application/pdf",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
}
export class MockFileSystemDirectoryHandle {
kind: "directory" = "directory";
name: string;
private files: Map<string, MockFileSystemFileHandle> = new Map();
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
constructor(name: string = "root") {
this.name = name;
}
async getFileHandle(
name: string,
options?: FileSystemGetFileOptions,
): Promise<FileSystemFileHandle> {
if (this.files.has(name)) {
return this.files.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemFileHandle(name);
this.files.set(name, handle);
return handle as any;
}
throw new Error(`File not found: ${name}`);
}
async getDirectoryHandle(
name: string,
options?: FileSystemGetDirectoryOptions,
): Promise<FileSystemDirectoryHandle> {
if (this.directories.has(name)) {
return this.directories.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemDirectoryHandle(name);
this.directories.set(name, handle);
return handle as any;
}
throw new Error(`Directory not found: ${name}`);
}
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
this.files.delete(name);
this.directories.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, handle] of this.files) {
yield [name, handle as any];
}
for (const [name, handle] of this.directories) {
yield [name, handle as any];
}
}
async *keys(): AsyncIterableIterator<string> {
for (const name of this.files.keys()) {
yield name;
}
for (const name of this.directories.keys()) {
yield name;
}
}
async *values(): AsyncIterableIterator<FileSystemHandle> {
for (const handle of this.files.values()) {
yield handle as any;
}
for (const handle of this.directories.values()) {
yield handle as any;
}
}
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
return this.entries();
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, beforeAll, afterAll } from "vitest"; import { describe } from "vitest";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
+1
View File
@@ -0,0 +1 @@
export * from "./sveltekit.adapter";
@@ -0,0 +1,16 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as sveltekit from "./sveltekit.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("sveltekit adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: (c, a) => sveltekit.getApp(c as any, a ?? ({} as any)),
makeHandler: (c, a) => (request: Request) =>
sveltekit.serve(c as any, a ?? ({} as any))({ request }),
});
});
@@ -0,0 +1,33 @@
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
type TSvelteKit = {
request: Request;
};
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
/**
* Get bknd app instance
* @param config - bknd configuration
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
*/
export async function getApp<Env>(
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
args: Env,
) {
return await createRuntimeApp(config, args);
}
/**
* Create request handler for hooks.server.ts
* @param config - bknd configuration
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
*/
export function serve<Env>(
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
args: Env,
) {
return async (fnArgs: TSvelteKit) => {
return (await getApp(config, args)).fetch(fnArgs.request);
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./tanstack-start.adapter";
@@ -0,0 +1,16 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as tanstackStart from "./tanstack-start.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { TanstackStartConfig } from "./tanstack-start.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("tanstack start adapter", () => {
adapterTestSuite<TanstackStartConfig>(bunTestRunner, {
makeApp: tanstackStart.getApp,
makeHandler: tanstackStart.serve,
});
});
@@ -0,0 +1,33 @@
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
export type TanstackStartEnv = NodeJS.ProcessEnv;
export type TanstackStartConfig<Env = TanstackStartEnv> =
FrameworkBkndConfig<Env>;
/**
* Get bknd app instance
* @param config - bknd configuration
* @param args - environment variables
*/
export async function getApp<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args);
}
/**
* Create request handler for src/routes/api.$.ts
* @param config - bknd configuration
* @param args - environment variables
*/
export function serve<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return async (request: Request) => {
const app = await getApp(config, args);
return app.fetch(request);
};
}
+29 -1
View File
@@ -46,6 +46,22 @@ export class AppAuth extends Module<AppAuthSchema> {
to.strategies!.password!.enabled = true; to.strategies!.password!.enabled = true;
} }
if (to.default_role_register && to.default_role_register?.length > 0) {
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
if (!valid_to_role) {
const msg = `Default role for registration not found: ${to.default_role_register}`;
// if changing to a new value
if (from.default_role_register !== to.default_role_register) {
throw new Error(msg);
}
// resetting gracefully, since role doesn't exist anymore
$console.warn(`${msg}, resetting to undefined`);
to.default_role_register = undefined;
}
}
return to; return to;
} }
@@ -82,6 +98,7 @@ export class AppAuth extends Module<AppAuthSchema> {
this._authenticator = new Authenticator(strategies, new AppUserPool(this), { this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
jwt: this.config.jwt, jwt: this.config.jwt,
cookie: this.config.cookie, cookie: this.config.cookie,
default_role_register: this.config.default_role_register,
}); });
this.registerEntities(); this.registerEntities();
@@ -171,10 +188,20 @@ export class AppAuth extends Module<AppAuthSchema> {
} catch (e) {} } catch (e) {}
} }
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> { async createUser({
email,
password,
role,
...additional
}: CreateUserPayload): Promise<DB["users"]> {
if (!this.enabled) { if (!this.enabled) {
throw new Error("Cannot create user, auth not enabled"); throw new Error("Cannot create user, auth not enabled");
} }
if (role) {
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
throw new Error(`Role "${role}" not found`);
}
}
const strategy = "password" as const; const strategy = "password" as const;
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
@@ -183,6 +210,7 @@ export class AppAuth extends Module<AppAuthSchema> {
mutator.__unstable_toggleSystemEntityCreation(false); mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({ const { data: created } = await mutator.insertOne({
...(additional as any), ...(additional as any),
role: role || this.config.default_role_register || undefined,
email, email,
strategy, strategy,
strategy_value, strategy_value,
+37 -22
View File
@@ -13,6 +13,7 @@ import {
InvalidSchemaError, InvalidSchemaError,
transformObject, transformObject,
mcpTool, mcpTool,
$console,
} from "bknd/utils"; } from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
@@ -198,7 +199,12 @@ export class AuthController extends Controller {
for (const [name, strategy] of Object.entries(strategies)) { for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue; if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); hono.route(
`/${name}`,
strategy.getController(this.auth.authenticator, {
allow_register: this.auth.config.allow_register,
}),
);
this.registerStrategyActions(strategy, hono); this.registerStrategyActions(strategy, hono);
} }
@@ -210,7 +216,7 @@ export class AuthController extends Controller {
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]); const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
const getUser = async (params: { id?: string | number; email?: string }) => { const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined = undefined; let user: DB["users"] | undefined;
if (params.id) { if (params.id) {
const { data } = await this.userRepo.findId(params.id); const { data } = await this.userRepo.findId(params.id);
user = data; user = data;
@@ -225,26 +231,33 @@ export class AuthController extends Controller {
}; };
const roles = Object.keys(this.auth.config.roles ?? {}); const roles = Object.keys(this.auth.config.roles ?? {});
mcp.tool( try {
"auth_user_create", const actions = this.auth.authenticator.strategy("password").getActions();
{ if (actions.create) {
description: "Create a new user", const schema = actions.create.schema;
inputSchema: s.object({ mcp.tool(
email: s.string({ format: "email" }), "auth_user_create",
password: s.string({ minLength: 8 }), {
role: s description: "Create a new user",
.string({ inputSchema: s.object({
enum: roles.length > 0 ? roles : undefined, ...schema.properties,
}) role: s
.optional(), .string({
}), enum: roles.length > 0 ? roles : undefined,
}, })
async (params, c) => { .optional(),
await c.context.ctx().helper.granted(c, AuthPermissions.createUser); }),
},
async (params, 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));
}, },
); );
}
} catch (e) {
$console.warn("error creating auth_user_create tool", e);
}
mcp.tool( mcp.tool(
"auth_user_token", "auth_user_token",
@@ -297,7 +310,9 @@ export class AuthController extends Controller {
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword); 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, {
allow_register: this.auth.config.allow_register,
});
const res = await controller.request( const res = await controller.request(
new Request("https://localhost/login", { new Request("https://localhost/login", {
+1
View File
@@ -51,6 +51,7 @@ export const authConfigSchema = $object(
basepath: s.string({ default: "/api/auth" }), basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }), entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(), allow_register: s.boolean({ default: true }).optional(),
default_role_register: s.string().optional(),
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
strategies: $record( strategies: $record(
@@ -74,6 +74,7 @@ export const jwtConfig = s.strictObject(
export const authenticatorConfig = s.object({ export const authenticatorConfig = s.object({
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
default_role_register: s.string().optional(),
}); });
type AuthConfig = s.Static<typeof authenticatorConfig>; type AuthConfig = s.Static<typeof authenticatorConfig>;
@@ -164,9 +165,13 @@ export class Authenticator<
if (!("strategy_value" in profile)) { if (!("strategy_value" in profile)) {
throw new InvalidConditionsException("Profile must have a strategy value"); throw new InvalidConditionsException("Profile must have a strategy value");
} }
if ("role" in profile) {
throw new InvalidConditionsException("Role cannot be provided during registration");
}
const user = await this.userPool.create(strategy.getName(), { const user = await this.userPool.create(strategy.getName(), {
...profile, ...profile,
role: this.config.default_role_register,
strategy_value: profile.strategy_value, strategy_value: profile.strategy_value,
}); });
@@ -10,6 +10,7 @@ const schema = s
.object({ .object({
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }), hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
rounds: s.number({ minimum: 1, maximum: 10 }).optional(), rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
minLength: s.number({ default: 8, minimum: 1 }).optional(),
}) })
.strict(); .strict();
@@ -37,7 +38,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
format: "email", format: "email",
}), }),
password: s.string({ password: s.string({
minLength: 8, // @todo: this should be configurable minLength: this.config.minLength,
}), }),
}); });
} }
@@ -65,19 +66,28 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
return await bcryptCompare(compare, actual); return await bcryptCompare(compare, actual);
} }
return false; return actual === compare;
} }
verify(password: string) { verify(password: string) {
return async (user: User) => { return async (user: User) => {
const compare = await this.compare(user?.strategy_value!, password); if (!user || !user.strategy_value) {
throw new InvalidCredentialsException();
}
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
$console.debug("PasswordStrategy: Invalid password", password);
throw new InvalidCredentialsException();
}
const compare = await this.compare(user.strategy_value, password);
if (compare !== true) { if (compare !== true) {
throw new InvalidCredentialsException(); throw new InvalidCredentialsException();
} }
}; };
} }
getController(authenticator: Authenticator): Hono<any> { getController(authenticator: Authenticator, opts: { allow_register?: boolean }): Hono<any> {
const hono = new Hono(); const hono = new Hono();
const redirectQuerySchema = s.object({ const redirectQuerySchema = s.object({
redirect: s.string().optional(), redirect: s.string().optional(),
@@ -110,41 +120,43 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
}, },
); );
hono.post( if (opts.allow_register) {
"/register", hono.post(
describeRoute({ "/register",
summary: "Register a new user with email and password", describeRoute({
tags: ["auth"], summary: "Register a new user with email and password",
}), tags: ["auth"],
jsc("query", redirectQuerySchema), }),
async (c) => { jsc("query", redirectQuerySchema),
try { async (c) => {
const { redirect } = c.req.valid("query"); try {
const { password, email, ...body } = parse( const { redirect } = c.req.valid("query");
payloadSchema, const { password, email, ...body } = parse(
await authenticator.getBody(c), payloadSchema,
{ await authenticator.getBody(c),
onError: (errors) => { {
$console.error("Invalid register payload", [...errors]); onError: (errors) => {
new InvalidCredentialsException(); $console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
},
}, },
}, );
);
const profile = { const profile = {
...body, ...body,
email, email,
strategy_value: await this.hash(password), strategy_value: await this.hash(password),
}; };
return await authenticator.resolveRegister(c, this, profile, async () => void 0, { return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect, redirect,
}); });
} catch (e) { } catch (e) {
return authenticator.respondWithError(c, e as any); return authenticator.respondWithError(c, e as any);
} }
}, },
); );
}
return hono; return hono;
} }
@@ -36,7 +36,7 @@ export abstract class AuthStrategy<Schema extends s.Schema = s.Schema> {
protected abstract getSchema(): Schema; protected abstract getSchema(): Schema;
abstract getController(auth: Authenticator): Hono; abstract getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono;
getType(): string { getType(): string {
return this.type; return this.type;
@@ -284,7 +284,7 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
} }
} }
getController(auth: Authenticator): Hono<any> { getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono<any> {
const hono = new Hono(); const hono = new Hono();
const secret = "secret"; const secret = "secret";
const cookie_name = "_challenge"; const cookie_name = "_challenge";
@@ -379,6 +379,10 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
return c.notFound(); return c.notFound();
} }
if (action === "register" && !opts.allow_register) {
return c.notFound();
}
const url = new URL(c.req.url); const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, ""); const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/callback"; const redirect_uri = url.origin + path + "/callback";
+33 -32
View File
@@ -21,46 +21,47 @@ export const sync: CliCommand = (program) => {
console.info(""); console.info("");
if (stmts.length === 0) { if (stmts.length === 0) {
console.info(c.yellow("No changes to sync")); console.info(c.yellow("No changes to sync"));
process.exit(0); } else {
} // @todo: currently assuming parameters aren't used
// @todo: currently assuming parameters aren't used const sql = stmts.map((d) => d.sql).join(";\n") + ";";
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.force) { if (options.force) {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql)); console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop }); await schema.sync({ force: true, drop: options.drop });
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`); console.info(`${c.green("Database synced")}`);
} else {
if (options.seed) { if (options.out) {
console.info(c.dim("\nExecuting seed...")); const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
const seed = app.options?.seed; await writeFile(options.out, output);
if (seed) { console.info(`SQL written to ${c.cyan(options.out)}`);
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else { } else {
console.info(c.yellow("No seed function provided")); console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
} }
} }
} else { }
if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} else {
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info( if (options.seed) {
c.yellow( console.info(c.dim("\nExecuting seed..."));
"\nNo statements have been executed. Use --force to perform database syncing operations", const seed = app.options?.seed;
), if (seed) {
); await seed({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
} }
} }
process.exit(0); process.exit(0);
}); });
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ export function isDebug(): boolean {
try { try {
// @ts-expect-error - this is a global variable in dev // @ts-expect-error - this is a global variable in dev
return is_toggled(__isDev); return is_toggled(__isDev);
} catch (e) { } catch (_e) {
return false; return false;
} }
} }
+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";
+1
View File
@@ -32,6 +32,7 @@ export function getFlashMessage(
): { type: FlashMessageType; message: string } | undefined { ): { type: FlashMessageType; message: string } | undefined {
const flash = getCookieValue(flash_key); const flash = getCookieValue(flash_key);
if (flash && clear) { if (flash && clear) {
// biome-ignore lint/suspicious/noDocumentCookie: .
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
} }
return flash ? JSON.parse(flash) : undefined; return flash ? JSON.parse(flash) : undefined;
+2 -2
View File
@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
export function omitKeys<T extends object, K extends keyof T>( export function omitKeys<T extends object, K extends keyof T>(
obj: T, obj: T,
keys_: readonly K[], keys_: readonly K[] | K[] | string[],
): Omit<T, Extract<K, keyof T>> { ): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_); const keys = new Set(keys_ as readonly K[]);
const result = {} as Omit<T, Extract<K, keyof T>>; const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) { if (!keys.has(key as K)) {
+35
View File
@@ -1,3 +1,4 @@
import type { MaybePromise } from "core/types";
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter"; import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
/** /**
@@ -77,3 +78,37 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
return true; 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;
}
}
export async function $waitUntil(
message: string,
condition: () => MaybePromise<boolean>,
delay = 100,
maxAttempts = 10,
) {
let attempts = 0;
while (attempts < maxAttempts) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, delay));
attempts++;
}
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
}
+10 -1
View File
@@ -96,6 +96,9 @@ export class DataController extends Controller {
// read entity schema // read entity schema
hono.get( hono.get(
"/schema.json", "/schema.json",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
@@ -124,6 +127,9 @@ export class DataController extends Controller {
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
@@ -161,7 +167,7 @@ export class DataController extends Controller {
hono.get( hono.get(
"/types", "/types",
permission(SystemPermissions.schemaRead, { permission(SystemPermissions.schemaRead, {
context: (c) => ({ module: "data" }), context: (_c) => ({ module: "data" }),
}), }),
describeRoute({ describeRoute({
summary: "Retrieve data typescript definitions", summary: "Retrieve data typescript definitions",
@@ -182,6 +188,9 @@ export class DataController extends Controller {
*/ */
hono.get( hono.get(
"/info/:entity", "/info/:entity",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
-3
View File
@@ -6,15 +6,12 @@ import {
type CompiledQuery, type CompiledQuery,
type DatabaseIntrospector, type DatabaseIntrospector,
type Dialect, type Dialect,
type Expression,
type Kysely, type Kysely,
type KyselyPlugin, type KyselyPlugin,
type OnModifyForeignAction, type OnModifyForeignAction,
type QueryResult, type QueryResult,
type RawBuilder,
type SelectQueryBuilder, type SelectQueryBuilder,
type SelectQueryNode, type SelectQueryNode,
type Simplify,
sql, sql,
} from "kysely"; } from "kysely";
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
@@ -14,27 +14,31 @@ export function connectionTestSuite(
{ {
makeConnection, makeConnection,
rawDialectDetails, rawDialectDetails,
disableConsoleLog: _disableConsoleLog = true,
}: { }: {
makeConnection: () => MaybePromise<{ makeConnection: () => MaybePromise<{
connection: Connection; connection: Connection;
dispose: () => MaybePromise<void>; dispose: () => MaybePromise<void>;
}>; }>;
rawDialectDetails: string[]; rawDialectDetails: string[];
disableConsoleLog?: boolean;
}, },
) { ) {
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner; const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
beforeAll(() => disableConsoleLog()); if (_disableConsoleLog) {
afterAll(() => enableConsoleLog()); beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
}
describe("base", () => { let ctx: Awaited<ReturnType<typeof makeConnection>>;
let ctx: Awaited<ReturnType<typeof makeConnection>>; beforeEach(async () => {
beforeEach(async () => { ctx = await makeConnection();
ctx = await makeConnection(); });
}); afterEach(async () => {
afterEach(async () => { await ctx.dispose();
await ctx.dispose(); });
});
describe("base", async () => {
test("pings", async () => { test("pings", async () => {
const res = await ctx.connection.ping(); const res = await ctx.connection.ping();
expect(res).toBe(true); expect(res).toBe(true);
@@ -98,52 +102,54 @@ export function connectionTestSuite(
}); });
describe("schema", async () => { describe("schema", async () => {
const { connection, dispose } = await makeConnection(); const makeSchema = async () => {
afterAll(async () => { const fields = [
await dispose(); {
}); type: "integer",
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
const fields = [ let b = ctx.connection.kysely.schema.createTable("test");
{ for (const field of fields) {
type: "integer", // @ts-expect-error
name: "id", b = b.addColumn(...ctx.connection.getFieldSchema(field));
primary: true, }
}, await b.execute();
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
let b = connection.kysely.schema.createTable("test"); // add index
for (const field of fields) { await ctx.connection.kysely.schema
// @ts-expect-error .createIndex("test_index")
b = b.addColumn(...connection.getFieldSchema(field)); .on("test")
} .columns(["id"])
await b.execute(); .execute();
};
// add index
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
test("executes query", async () => { test("executes query", async () => {
await connection.kysely await makeSchema();
await ctx.connection.kysely
.insertInto("test") .insertInto("test")
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) }) .values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
.execute(); .execute();
const expected = { id: 1, text: "test", json: { a: 1 } }; const expected = { id: 1, text: "test", json: { a: 1 } };
const qb = connection.kysely.selectFrom("test").selectAll(); const qb = ctx.connection.kysely.selectFrom("test").selectAll();
const res = await connection.executeQuery(qb); const res = await ctx.connection.executeQuery(qb);
expect(res.rows).toEqual([expected]); expect(res.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true); expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
{ {
const res = await connection.executeQueries(qb, qb); const res = await ctx.connection.executeQueries(qb, qb);
expect(res.length).toBe(2); expect(res.length).toBe(2);
res.map((r) => { res.map((r) => {
expect(r.rows).toEqual([expected]); expect(r.rows).toEqual([expected]);
@@ -155,15 +161,21 @@ export function connectionTestSuite(
}); });
test("introspects", async () => { test("introspects", async () => {
const tables = await connection.getIntrospector().getTables({ await makeSchema();
const tables = await ctx.connection.getIntrospector().getTables({
withInternalKyselyTables: false, withInternalKyselyTables: false,
}); });
const clean = tables.map((t) => ({ const clean = tables.map((t) => ({
...t, ...t,
columns: t.columns.map((c) => ({ columns: t.columns
...c, .map((c) => ({
dataType: undefined, ...c,
})), // ignore data type
dataType: undefined,
// ignore default value if "id"
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
}))
.sort((a, b) => a.name.localeCompare(b.name)),
})); }));
expect(clean).toEqual([ expect(clean).toEqual([
@@ -176,14 +188,8 @@ export function connectionTestSuite(
dataType: undefined, dataType: undefined,
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: false, hasDefaultValue: undefined,
}, comment: undefined,
{
name: "text",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
}, },
{ {
name: "json", name: "json",
@@ -191,25 +197,34 @@ export function connectionTestSuite(
isNullable: true, isNullable: true,
isAutoIncrementing: false, isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined,
},
{
name: "text",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
],
},
]);
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
}, },
], ],
}, },
]); ]);
}); });
expect(await connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
},
],
},
]);
}); });
describe("integration", async () => { describe("integration", async () => {
@@ -0,0 +1,33 @@
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
import type { Pool } from "pg";
export type PostgresDialectConfig = Omit<KyselyPostgresDialectConfig, "pool"> & {
pool: Pool;
};
export class PgPostgresConnection extends PostgresConnection<Pool> {
override name = "pg";
constructor(config: PostgresDialectConfig) {
const kysely = new Kysely({
dialect: customIntrospector(PostgresDialect, PostgresIntrospector, {
excludeTables: [],
}).create(config),
plugins,
});
super(kysely);
this.client = config.pool;
}
override async close(): Promise<void> {
await this.client.end();
}
}
export function pg(config: PostgresDialectConfig): PgPostgresConnection {
return new PgPostgresConnection(config);
}
@@ -5,7 +5,7 @@ import {
type SchemaResponse, type SchemaResponse,
type ConnQuery, type ConnQuery,
type ConnQueryResults, type ConnQueryResults,
} from "bknd"; } from "../Connection";
import { import {
ParseJSONResultsPlugin, ParseJSONResultsPlugin,
type ColumnDataType, type ColumnDataType,
@@ -20,7 +20,7 @@ export type QB = SelectQueryBuilder<any, any, any>;
export const plugins = [new ParseJSONResultsPlugin()]; export const plugins = [new ParseJSONResultsPlugin()];
export abstract class PostgresConnection extends Connection { export abstract class PostgresConnection<Client = unknown> extends Connection<Client> {
protected override readonly supported = { protected override readonly supported = {
batching: true, batching: true,
softscans: true, softscans: true,
@@ -68,7 +68,7 @@ export abstract class PostgresConnection extends Connection {
type, type,
(col: ColumnDefinitionBuilder) => { (col: ColumnDefinitionBuilder) => {
if (spec.primary) { if (spec.primary) {
return col.primaryKey(); return col.primaryKey().notNull();
} }
if (spec.references) { if (spec.references) {
return col return col
@@ -76,7 +76,7 @@ export abstract class PostgresConnection extends Connection {
.onDelete(spec.onDelete ?? "set null") .onDelete(spec.onDelete ?? "set null")
.onUpdate(spec.onUpdate ?? "no action"); .onUpdate(spec.onUpdate ?? "no action");
} }
return spec.nullable ? col : col.notNull(); return col;
}, },
]; ];
} }
@@ -1,5 +1,5 @@
import { type SchemaMetadata, sql } from "kysely"; import { type SchemaMetadata, sql } from "kysely";
import { BaseIntrospector } from "bknd"; import { BaseIntrospector } from "../BaseIntrospector";
type PostgresSchemaSpec = { type PostgresSchemaSpec = {
name: string; name: string;
@@ -102,26 +102,27 @@ export class PostgresIntrospector extends BaseIntrospector {
return tables.map((table) => ({ return tables.map((table) => ({
name: table.name, name: table.name,
isView: table.type === "VIEW", isView: table.type === "VIEW",
columns: table.columns.map((col) => { columns: table.columns.map((col) => ({
return { name: col.name,
name: col.name, dataType: col.type,
dataType: col.type, isNullable: !col.notnull,
isNullable: !col.notnull, isAutoIncrementing: col.dflt?.toLowerCase().includes("nextval") ?? false,
// @todo: check default value on 'nextval' see https://www.postgresql.org/docs/17/datatype-numeric.html#DATATYPE-SERIAL hasDefaultValue: col.dflt != null,
isAutoIncrementing: true, // just for now comment: undefined,
hasDefaultValue: col.dflt != null,
comment: undefined,
};
}),
indices: table.indices.map((index) => ({
name: index.name,
table: table.name,
isUnique: index.sql?.match(/unique/i) != null,
columns: index.columns.map((col) => ({
name: col.name,
order: col.seqno,
})),
})), })),
indices: table.indices
// filter out db-managed primary key index
.filter((index) => index.name !== `${table.name}_pkey`)
.map((index) => ({
name: index.name,
table: table.name,
isUnique: index.sql?.match(/unique/i) != null,
columns: index.columns.map((col) => ({
name: col.name,
// seqno starts at 1
order: col.seqno - 1,
})),
})),
})); }));
} }
} }
@@ -0,0 +1,31 @@
import { Kysely } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js";
export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectConfig["postgres"]> {
override name = "postgres-js";
constructor(config: PostgresJSDialectConfig) {
const kysely = new Kysely({
dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, {
excludeTables: [],
}).create(config),
plugins,
});
super(kysely);
this.client = config.postgres;
}
override async close(): Promise<void> {
await this.client.end();
}
}
export function postgresJs(
config: PostgresJSDialectConfig,
): PostgresJsConnection {
return new PostgresJsConnection(config);
}
@@ -1,4 +1,4 @@
import { customIntrospector, type DbFunctions } from "bknd"; import { customIntrospector, type DbFunctions } from "../Connection";
import { Kysely, type Dialect, type KyselyPlugin } from "kysely"; import { Kysely, type Dialect, type KyselyPlugin } from "kysely";
import { plugins, PostgresConnection } from "./PostgresConnection"; import { plugins, PostgresConnection } from "./PostgresConnection";
import { PostgresIntrospector } from "./PostgresIntrospector"; import { PostgresIntrospector } from "./PostgresIntrospector";
@@ -6,7 +6,7 @@ import { PostgresIntrospector } from "./PostgresIntrospector";
export type Constructor<T> = new (...args: any[]) => T; export type Constructor<T> = new (...args: any[]) => T;
export type CustomPostgresConnection = { export type CustomPostgresConnection = {
supports?: PostgresConnection["supported"]; supports?: Partial<PostgresConnection["supported"]>;
fn?: Partial<DbFunctions>; fn?: Partial<DbFunctions>;
plugins?: KyselyPlugin[]; plugins?: KyselyPlugin[];
excludeTables?: string[]; excludeTables?: string[];
@@ -13,31 +13,43 @@ import { customIntrospector } from "../Connection";
import { SqliteIntrospector } from "./SqliteIntrospector"; import { SqliteIntrospector } from "./SqliteIntrospector";
import type { Field } from "data/fields/Field"; import type { Field } from "data/fields/Field";
// @todo: add pragmas
export type SqliteConnectionConfig< export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>, CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = { > = {
excludeTables?: string[]; excludeTables?: string[];
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
additionalPlugins?: KyselyPlugin[]; additionalPlugins?: KyselyPlugin[];
customFn?: Partial<DbFunctions>; customFn?: Partial<DbFunctions>;
}; } & (
| {
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
}
| {
kysely: Kysely<any>;
}
);
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> { export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
override name = "sqlite"; override name = "sqlite";
constructor(config: SqliteConnectionConfig) { constructor(config: SqliteConnectionConfig) {
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config; const { excludeTables, additionalPlugins } = config;
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])]; const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
const kysely = new Kysely({ let kysely: Kysely<any>;
dialect: customIntrospector(dialect, SqliteIntrospector, { if ("dialect" in config) {
excludeTables, kysely = new Kysely({
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
excludeTables,
plugins,
}).create(...(config.dialectArgs ?? [])),
plugins, plugins,
}).create(...dialectArgs), });
plugins, } else if ("kysely" in config) {
}); kysely = config.kysely;
} else {
throw new Error("Either dialect or kysely must be provided");
}
super( super(
kysely, kysely,
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
dataType: col.type, dataType: col.type,
isNullable: !col.notnull, isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol, isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.dflt_value != null, hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null,
comment: undefined, comment: undefined,
}; };
}) ?? [], }) ?? [],
@@ -0,0 +1,15 @@
import { describe } from "bun:test";
import { SQLocalConnection } from "./SQLocalConnection";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { SQLocalKysely } from "sqlocal/kysely";
describe("SQLocalConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => ({
connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
@@ -0,0 +1,50 @@
import { Kysely, ParseJSONResultsPlugin } from "kysely";
import { SqliteConnection } from "../SqliteConnection";
import { SqliteIntrospector } from "../SqliteIntrospector";
import type { DB } from "bknd";
import type { SQLocalKysely } from "sqlocal/kysely";
const plugins = [new ParseJSONResultsPlugin()];
export class SQLocalConnection extends SqliteConnection<SQLocalKysely> {
private connected: boolean = false;
constructor(client: SQLocalKysely) {
// @ts-expect-error - config is protected
client.config.onConnect = () => {
// we need to listen for the connection, it will be awaited in init()
this.connected = true;
};
super({
kysely: new Kysely<any>({
dialect: {
...client.dialect,
createIntrospector: (db: Kysely<DB>) => {
return new SqliteIntrospector(db as any, {
plugins,
});
},
},
plugins,
}) as any,
});
this.client = client;
}
override async init() {
if (this.initialized) return;
let tries = 0;
while (!this.connected && tries < 100) {
tries++;
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (!this.connected) {
throw new Error("Failed to connect to SQLite database");
}
this.initialized = true;
}
}
export function sqlocal(instance: InstanceType<typeof SQLocalKysely>): SQLocalConnection {
return new SQLocalConnection(instance);
}
+20 -3
View File
@@ -103,6 +103,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.with = options.with; validated.with = options.with;
} }
// add explicit joins. Implicit joins are added in `where` builder
if (options.join && options.join.length > 0) { if (options.join && options.join.length > 0) {
for (const entry of options.join) { for (const entry of options.join) {
const related = this.em.relationOf(entity.name, entry); const related = this.em.relationOf(entity.name, entry);
@@ -127,12 +128,28 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => { const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
if (field.includes(".")) { if (field.includes(".")) {
const [alias, prop] = field.split(".") as [string, string]; const [alias, prop] = field.split(".") as [string, string];
if (!aliases.includes(alias)) { // check aliases first (added joins)
if (aliases.includes(alias)) {
this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
}
// check if alias (entity) exists
if (!this.em.hasEntity(alias)) {
return true; return true;
} }
// check related fields for auto join
const related = this.em.relationOf(entity.name, alias);
if (related) {
const other = related.other(entity);
if (other.entity.getField(prop)) {
// if related field is found, add join to validated options
validated.join?.push(alias);
this.checkIndex(alias, prop, "where");
return false;
}
}
this.checkIndex(alias, prop, "where"); return true;
return !this.em.entity(alias).getField(prop);
} }
this.checkIndex(entity.name, field, "where"); this.checkIndex(entity.name, field, "where");
@@ -39,6 +39,7 @@ export class WithBuilder {
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", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any, ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
alias: other.reference,
}); });
} }
+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;
+1 -1
View File
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
super(Object.values(__entities), new DummyConnection(), relations, indices); super(Object.values(__entities), new DummyConnection(), relations, indices);
} }
withConnection(connection: Connection): EntityManager<Schema<Entities>> { withConnection(connection: Connection): EntityManager<Schemas<Entities>> {
return new EntityManager(this.entities, connection, this.relations.all, this.indices); return new EntityManager(this.entities, connection, this.relations.all, this.indices);
} }
} }
+5 -1
View File
@@ -1,8 +1,9 @@
import { test, describe, expect } from "bun:test"; import { test, describe, expect, beforeAll, afterAll } 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 { PrimaryFieldType } from "modules";
import type { Generated } from "kysely"; import type { Generated } from "kysely";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const parse = (v: unknown, o: ParseOptions = {}) => const parse = (v: unknown, o: ParseOptions = {}) =>
$parse(q.repoQuery, v, { $parse(q.repoQuery, v, {
@@ -15,6 +16,9 @@ const decode = (input: any, output: any) => {
expect(parse(input)).toEqual(output); expect(parse(input)).toEqual(output);
}; };
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("server/query", () => { describe("server/query", () => {
test("limit & offset", () => { test("limit & offset", () => {
//expect(() => parse({ limit: false })).toThrow(); //expect(() => parse({ limit: false })).toThrow();
+25
View File
@@ -132,6 +132,8 @@ export type * from "data/entities/Entity";
export type { EntityManager } from "data/entities/EntityManager"; export type { EntityManager } from "data/entities/EntityManager";
export type { SchemaManager } from "data/schema/SchemaManager"; export type { SchemaManager } from "data/schema/SchemaManager";
export type * from "data/entities"; export type * from "data/entities";
// data connection
export { export {
BaseIntrospector, BaseIntrospector,
Connection, Connection,
@@ -144,9 +146,32 @@ export {
type ConnQuery, type ConnQuery,
type ConnQueryResults, type ConnQueryResults,
} from "data/connection"; } from "data/connection";
// data sqlite
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection"; export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector"; export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection"; export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
// data sqlocal
export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection";
// data postgres
export {
pg,
PgPostgresConnection,
} from "data/connection/postgres/PgPostgresConnection";
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
export {
postgresJs,
PostgresJsConnection,
} from "data/connection/postgres/PostgresJsConnection";
export {
createCustomPostgresConnection,
type CustomPostgresConnection,
} from "data/connection/postgres/custom";
// data prototype
export { export {
text, text,
number, number,
+17 -4
View File
@@ -71,11 +71,12 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
} }
protected uploadFile<T extends FileUploadedEventData>( protected uploadFile<T extends FileUploadedEventData>(
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>, body: BodyInit,
opts?: { opts?: {
filename?: string; filename?: string;
path?: TInput; path?: TInput;
_init?: Omit<RequestInit, "body">; _init?: Omit<RequestInit, "body">;
query?: Record<string, any>;
}, },
): FetchPromise<ResponseObject<T>> { ): FetchPromise<ResponseObject<T>> {
const headers = { const headers = {
@@ -102,14 +103,22 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
headers, headers,
}; };
if (opts?.path) { if (opts?.path) {
return this.post(opts.path, body, init); return this.request<T>(opts.path, opts?.query, {
...init,
body,
method: "POST",
});
} }
if (!name || name.length === 0) { if (!name || name.length === 0) {
throw new Error("Invalid filename"); throw new Error("Invalid filename");
} }
return this.post<T>(opts?.path ?? ["upload", name], body, init); return this.request<T>(opts?.path ?? ["upload", name], opts?.query, {
...init,
body,
method: "POST",
});
} }
async upload<T extends FileUploadedEventData>( async upload<T extends FileUploadedEventData>(
@@ -119,6 +128,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
_init?: Omit<RequestInit, "body">; _init?: Omit<RequestInit, "body">;
path?: TInput; path?: TInput;
fetcher?: ApiFetcher; fetcher?: ApiFetcher;
query?: Record<string, any>;
} = {}, } = {},
) { ) {
if (item instanceof Request || typeof item === "string") { if (item instanceof Request || typeof item === "string") {
@@ -144,7 +154,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
}); });
} }
return this.uploadFile<T>(item, opts); return this.uploadFile<T>(item as BodyInit, opts);
} }
async uploadToEntity( async uploadToEntity(
@@ -155,11 +165,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
opts?: { opts?: {
_init?: Omit<RequestInit, "body">; _init?: Omit<RequestInit, "body">;
fetcher?: typeof fetch; fetcher?: typeof fetch;
overwrite?: boolean;
}, },
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> { ): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
const query = opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : undefined;
return this.upload(item, { return this.upload(item, {
...opts, ...opts,
path: ["entity", entity, id, field], path: ["entity", entity, id, field],
query,
}); });
} }
@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
export async function adapterTestSuite( export async function adapterTestSuite(
testRunner: TestRunner, testRunner: TestRunner,
adapter: StorageAdapter, _adapter: StorageAdapter | (() => StorageAdapter),
file: File | BunFile, file: File | BunFile,
opts?: { opts?: {
retries?: number; retries?: number;
@@ -25,7 +25,12 @@ export async function adapterTestSuite(
const _filename = randomString(10); const _filename = randomString(10);
const filename = `${_filename}.png`; const filename = `${_filename}.png`;
const getAdapter = (
typeof _adapter === "function" ? _adapter : () => _adapter
) as () => StorageAdapter;
await test("puts an object", async () => { await test("puts an object", async () => {
const adapter = getAdapter();
objects = (await adapter.listObjects()).length; objects = (await adapter.listObjects()).length;
const result = await adapter.putObject(filename, file as unknown as File); const result = await adapter.putObject(filename, file as unknown as File);
expect(result).toBeDefined(); expect(result).toBeDefined();
@@ -38,6 +43,7 @@ export async function adapterTestSuite(
}); });
await test("lists objects", async () => { await test("lists objects", async () => {
const adapter = getAdapter();
const length = await retry( const length = await retry(
() => adapter.listObjects().then((res) => res.length), () => adapter.listObjects().then((res) => res.length),
(length) => length > objects, (length) => length > objects,
@@ -49,10 +55,12 @@ export async function adapterTestSuite(
}); });
await test("file exists", async () => { await test("file exists", async () => {
const adapter = getAdapter();
expect(await adapter.objectExists(filename)).toBe(true); expect(await adapter.objectExists(filename)).toBe(true);
}); });
await test("gets an object", async () => { await test("gets an object", async () => {
const adapter = getAdapter();
const res = await adapter.getObject(filename, new Headers()); const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.headers.get("Accept-Ranges")).toBe("bytes"); expect(res.headers.get("Accept-Ranges")).toBe("bytes");
@@ -62,6 +70,7 @@ export async function adapterTestSuite(
if (options.testRange) { if (options.testRange) {
await test("handles range request - partial content", async () => { await test("handles range request - partial content", async () => {
const headers = new Headers({ Range: "bytes=0-99" }); const headers = new Headers({ Range: "bytes=0-99" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content expect(res.status).toBe(206); // Partial Content
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -70,6 +79,7 @@ export async function adapterTestSuite(
await test("handles range request - suffix range", async () => { await test("handles range request - suffix range", async () => {
const headers = new Headers({ Range: "bytes=-100" }); const headers = new Headers({ Range: "bytes=-100" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content expect(res.status).toBe(206); // Partial Content
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -77,6 +87,7 @@ export async function adapterTestSuite(
await test("handles invalid range request", async () => { await test("handles invalid range request", async () => {
const headers = new Headers({ Range: "bytes=invalid" }); const headers = new Headers({ Range: "bytes=invalid" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(416); // Range Not Satisfiable expect(res.status).toBe(416); // Range Not Satisfiable
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -84,6 +95,7 @@ export async function adapterTestSuite(
} }
await test("gets object meta", async () => { await test("gets object meta", async () => {
const adapter = getAdapter();
expect(await adapter.getObjectMeta(filename)).toEqual({ expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png type: file.type, // image/png
size: file.size, size: file.size,
@@ -91,6 +103,7 @@ export async function adapterTestSuite(
}); });
await test("deletes an object", async () => { await test("deletes an object", async () => {
const adapter = getAdapter();
expect(await adapter.deleteObject(filename)).toBeUndefined(); expect(await adapter.deleteObject(filename)).toBeUndefined();
if (opts?.skipExistsAfterDelete !== true) { if (opts?.skipExistsAfterDelete !== true) {
+1 -1
View File
@@ -87,7 +87,7 @@ export async function makeModeConfig<
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
const isProd = config.isProduction ?? _isProd(); const isProd = config.isProduction ?? _isProd();
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
const syncSchemaOptions = const syncSchemaOptions =
typeof config.syncSchema === "object" typeof config.syncSchema === "object"
+1 -1
View File
@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
verbosity?: Verbosity; verbosity?: Verbosity;
}; };
const debug_modules = env("modules_debug"); const debug_modules = env("modules_debug", false);
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {} abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
export class ModuleManagerConfigUpdateEvent< export class ModuleManagerConfigUpdateEvent<
+2
View File
@@ -33,3 +33,5 @@ export const schemaRead = new Permission(
); );
export const build = new Permission("system.build"); export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp"); export const mcp = new Permission("system.mcp");
export const info = new Permission("system.info");
export const openapi = new Permission("system.openapi");
+5 -4
View File
@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { TApiUser } from "Api"; import type { TApiUser } from "Api";
import type { AppTheme } from "ui/client/use-theme";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -20,7 +21,7 @@ export type AdminBkndWindowContext = {
logout_route: string; logout_route: string;
admin_basepath: string; admin_basepath: string;
logo_return_path?: string; logo_return_path?: string;
theme?: "dark" | "light" | "system"; theme?: AppTheme;
}; };
// @todo: add migration to remove admin path from config // @todo: add migration to remove admin path from config
@@ -31,7 +32,7 @@ export type AdminControllerOptions = {
html?: string; html?: string;
forceDev?: boolean | { mainPath: string }; forceDev?: boolean | { mainPath: string };
debugRerenders?: boolean; debugRerenders?: boolean;
theme?: "dark" | "light" | "system"; theme?: AppTheme;
logoReturnPath?: string; logoReturnPath?: string;
manifest?: Manifest; manifest?: Manifest;
}; };
@@ -122,7 +123,7 @@ export class AdminController extends Controller {
const obj: AdminBkndWindowContext = { const obj: AdminBkndWindowContext = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: authRoutes.logout, logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath, admin_basepath: this.options.adminBasepath.replace(/\/+$/, ""),
theme: this.options.theme, theme: this.options.theme,
logo_return_path: this.options.logoReturnPath, logo_return_path: this.options.logoReturnPath,
}; };
@@ -304,7 +305,7 @@ const wrapperStyle = css`
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: rgb(9,9,11); color: rgb(9,9,11);
background-color: rgb(250,250,250); background-color: rgb(250,250,250);
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
color: rgb(250,250,250); color: rgb(250,250,250);
background-color: rgb(30,31,34); background-color: rgb(30,31,34);
+18 -5
View File
@@ -1,5 +1,3 @@
/// <reference types="@cloudflare/workers-types" />
import type { App } from "App"; import type { App } from "App";
import { import {
datetimeStringLocal, datetimeStringLocal,
@@ -125,7 +123,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void { private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares; const { permission } = this.middlewares;
// don't add auth again, it's already added in getController // don't add auth again, it's already added in getController
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */ const hono = this.create();
if (!this.app.isReadOnly()) { if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager; const manager = this.app.modules as DbModuleManager;
@@ -317,6 +315,11 @@ export class SystemController extends Controller {
summary: "Get the config for a module", summary: "Get the config for a module",
tags: ["system"], tags: ["system"],
}), }),
permission(SystemPermissions.configRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
mcpTool("system_config", { mcpTool("system_config", {
annotations: { annotations: {
readOnlyHint: true, readOnlyHint: true,
@@ -354,7 +357,7 @@ export class SystemController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; const { permission, auth } = this.middlewares;
const hono = this.create().use(auth()); const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {}));
this.registerConfigController(hono); this.registerConfigController(hono);
@@ -429,6 +432,9 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/permissions", "/permissions",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "auth" }),
}),
describeRoute({ describeRoute({
summary: "Get the permissions", summary: "Get the permissions",
tags: ["system"], tags: ["system"],
@@ -441,6 +447,7 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/build", "/build",
permission(SystemPermissions.build, {}),
describeRoute({ describeRoute({
summary: "Build the app", summary: "Build the app",
tags: ["system"], tags: ["system"],
@@ -471,6 +478,7 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/info", "/info",
permission(SystemPermissions.info, {}),
mcpTool("system_info"), mcpTool("system_info"),
describeRoute({ describeRoute({
summary: "Get the server info", summary: "Get the server info",
@@ -504,6 +512,7 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/openapi.json", "/openapi.json",
permission(SystemPermissions.openapi, {}),
openAPISpecs(this.ctx.server, { openAPISpecs(this.ctx.server, {
info: { info: {
title: "bknd API", title: "bknd API",
@@ -511,7 +520,11 @@ export class SystemController extends Controller {
}, },
}), }),
); );
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); hono.get(
"/swagger",
permission(SystemPermissions.openapi, {}),
swaggerUI({ url: "/api/system/openapi.json" }),
);
return hono; return hono;
} }
@@ -0,0 +1,683 @@
import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test";
import { emailOTP } from "./email-otp.plugin";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("otp plugin", () => {
test("should not work if auth is not enabled", async () => {
const app = createApp({
options: {
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
});
test("should require email driver if sendEmail is true", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP()],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
{
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
}
});
test("should prevent mutations of the OTP entity", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
drivers: {
email: {
send: async () => {},
},
},
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const payload = {
email: "test@test.com",
code: "123456",
action: "login",
created_at: new Date(),
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24),
used_at: null,
};
expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow();
expect(
await app
.getApi()
.data.createOne("users_otp", payload)
.then((r) => r.ok),
).toBe(false);
});
test("should generate a token", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ showActualErrors: true })],
drivers: {
email: {
send: async (to) => {
expect(to).toBe("test@test.com");
called();
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("login");
expect(data.data.expires_at).toBeDefined();
{
const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" });
expect(data?.code).toBeDefined();
expect(data?.code?.length).toBe(6);
expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe(
true,
);
expect(data?.email).toBe("test@test.com");
}
expect(called).toHaveBeenCalled();
});
test("should login with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should register with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("register");
expect(data.data.expires_at).toBeDefined();
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should not send email if sendEmail is false", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
drivers: {
email: {
send: async () => {
called();
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
expect(called).not.toHaveBeenCalled();
});
test("should reject invalid codes", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// First send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Try to use an invalid code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: "999999" }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
});
test("should reject code reuse", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (_to, _subject, body) => {
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Use the code successfully
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
}
// Try to use the same code again
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should reject expired codes", async () => {
// Set a fixed system time
const baseTime = Date.now();
setSystemTime(new Date(baseTime));
try {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
ttl: 1, // 1 second TTL
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
const sendRes = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(sendRes.status).toBe(201);
// Get the code from the database
const { data: otpData } = await app.em
.fork()
.repo("users_otp")
.findOne({ email: "test@test.com" });
expect(otpData?.code).toBeDefined();
// Advance system time by more than 1 second to expire the code
setSystemTime(new Date(baseTime + 1100));
// Try to use the expired code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: otpData?.code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
} finally {
// Reset system time
setSystemTime();
}
});
test("should reject codes with different actions", async () => {
let loginCode = "";
let registerCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a login code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the login code
const { data: loginOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } });
loginCode = loginOtp?.code || "";
// Send a register code
await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the register code
const { data: registerOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } });
registerCode = registerOtp?.code || "";
// Try to use login code for register
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: loginCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// Try to use register code for login
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: registerCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should invalidate previous codes when sending new code", async () => {
let firstCode = "";
let secondCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const em = app.em.fork();
// Send first code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the first code
const { data: firstOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
firstCode = firstOtp?.code || "";
expect(firstCode).toBeDefined();
// Send second code (should invalidate the first)
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the second code
const { data: secondOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
secondCode = secondOtp?.code || "";
expect(secondCode).toBeDefined();
expect(secondCode).not.toBe(firstCode);
// Try to use the first code (should fail as it's been invalidated)
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: firstCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// The second code should work
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: secondCode }),
});
expect(res.status).toBe(200);
}
});
});
+388
View File
@@ -0,0 +1,388 @@
import {
datetime,
em,
entity,
enumm,
Exception,
text,
type App,
type AppPlugin,
type DB,
type FieldSchema,
type MaybePromise,
type EntityConfig,
DatabaseEvents,
} from "bknd";
import {
invariant,
s,
jsc,
HttpStatus,
threwAsync,
randomString,
$console,
pickKeys,
} from "bknd/utils";
import { Hono } from "hono";
export type EmailOTPPluginOptions = {
/**
* Customize code generation. If not provided, a random 6-digit code will be generated.
*/
generateCode?: (user: Pick<DB["users"], "email">) => string;
/**
* The base path for the API endpoints.
* @default "/api/auth/otp"
*/
apiBasePath?: string;
/**
* The TTL for the OTP tokens in seconds.
* @default 600 (10 minutes)
*/
ttl?: number;
/**
* The name of the OTP entity.
* @default "users_otp"
*/
entity?: string;
/**
* The config for the OTP entity.
*/
entityConfig?: EntityConfig;
/**
* Customize email content. If not provided, a default email will be sent.
*/
generateEmail?: (
otp: EmailOTPFieldSchema,
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
/**
* Enable debug mode for error messages.
* @default false
*/
showActualErrors?: boolean;
/**
* Allow direct mutations (create/update) of OTP codes outside of this plugin,
* e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows.
* @default false
*/
allowExternalMutations?: boolean;
/**
* Whether to send the email with the OTP code.
* @default true
*/
sendEmail?: boolean;
};
const otpFields = {
action: enumm({
enum: ["login", "register"],
}),
code: text().required(),
email: text().required(),
created_at: datetime(),
expires_at: datetime().required(),
used_at: datetime(),
};
export type EmailOTPFieldSchema = FieldSchema<typeof otpFields>;
class OTPError extends Exception {
override name = "OTPError";
override code = HttpStatus.BAD_REQUEST;
}
export function emailOTP({
generateCode: _generateCode,
apiBasePath = "/api/auth/otp",
ttl = 600,
entity: entityName = "users_otp",
entityConfig,
generateEmail: _generateEmail,
showActualErrors = false,
allowExternalMutations = false,
sendEmail = true,
}: EmailOTPPluginOptions = {}): AppPlugin {
return (app: App) => {
return {
name: "email-otp",
schema: () =>
em(
{
[entityName]: entity(
entityName,
otpFields,
{
name: "Users OTP",
sort_dir: "desc",
primary_format: app.module.data.config.default_primary_format,
...entityConfig,
},
"generated",
) as any,
},
({ index }, schema) => {
const otp = schema[entityName]!;
index(otp).on(["email", "expires_at", "code"]);
},
),
onBuilt: async () => {
const auth = app.module.auth;
invariant(auth && auth.enabled === true, "Auth is not enabled");
invariant(!sendEmail || app.drivers?.email, "Email driver is not registered");
const generateCode =
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
const generateEmail =
_generateEmail ??
((otp: EmailOTPFieldSchema) => ({
subject: "OTP Code",
body: `Your OTP code is: ${otp.code}`,
}));
const em = app.em.fork();
const hono = new Hono()
.post(
"/login",
jsc(
"json",
s.strictObject({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
const user = await findUser(app, email);
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"login",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
user,
"login",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.post(
"/register",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code, ...rest } = c.req.valid("json");
const { redirect } = c.req.valid("query");
// throw if user exists
if (!(await threwAsync(findUser(app, email)))) {
throw new Exception("User already exists", HttpStatus.BAD_REQUEST);
}
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"register",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const user = await app.createUser({
...rest,
email,
password: randomString(32, true),
});
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
{ email },
"register",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.onError((err) => {
if (showActualErrors || err instanceof OTPError) {
throw err;
}
throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST);
});
app.server.route(apiBasePath, hono);
if (allowExternalMutations !== true) {
registerListeners(app, entityName);
}
},
};
};
}
async function findUser(app: App, email: string) {
const user_entity = app.module.auth.config.entity_name as "users";
const { data: user } = await app.em.repo(user_entity).findOne({ email });
if (!user) {
throw new Exception("User not found", HttpStatus.BAD_REQUEST);
}
return user;
}
async function invalidateAndGenerateCode(
app: App,
opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
action: EmailOTPFieldSchema["action"],
) {
const { generateCode, ttl, entity: entityName } = opts;
const newCode = generateCode?.(user);
if (!newCode) {
throw new OTPError("Failed to generate code");
}
await invalidateAllUserCodes(app, entityName, user.email, ttl);
const { data: otpData } = await app.em
.fork()
.mutator(entityName)
.insertOne({
code: newCode,
email: user.email,
action,
created_at: new Date(),
expires_at: new Date(Date.now() + ttl * 1000),
});
$console.log("[OTP Code]", newCode);
return otpData;
}
async function sendCode(
app: App,
otpData: EmailOTPFieldSchema,
opts: Required<Pick<EmailOTPPluginOptions, "generateEmail">>,
) {
const { generateEmail } = opts;
const { subject, body } = await generateEmail(otpData);
await app.drivers?.email?.send(otpData.email, subject, body);
}
async function getValidatedCode(
app: App,
entityName: string,
email: string,
code: string,
action: EmailOTPFieldSchema["action"],
) {
invariant(email, "[OTP Plugin]: Email is required");
invariant(code, "[OTP Plugin]: Code is required");
const em = app.em.fork();
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
if (!otpData) {
throw new OTPError("Invalid code");
}
if (otpData.expires_at < new Date()) {
throw new OTPError("Code expired");
}
if (otpData.used_at) {
throw new OTPError("Code already used");
}
return otpData;
}
async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) {
invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0");
invariant(email, "[OTP Plugin]: Email is required");
const em = app.em.fork();
await em
.mutator(entityName)
.updateWhere(
{ expires_at: new Date(Date.now() - 1000) },
{ email, used_at: { $isnull: true } },
);
}
function registerListeners(app: App, entityName: string) {
[DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => {
app.emgr.onEvent(
event,
(e: { params: { entity: { name: string } } }) => {
if (e.params.entity.name === entityName) {
throw new OTPError("Mutations of the OTP entity are not allowed");
}
},
{
mode: "sync",
id: "bknd-email-otp",
},
);
});
}
@@ -71,4 +71,54 @@ describe("timestamps plugin", () => {
expect(updatedData.updated_at).toBeDefined(); expect(updatedData.updated_at).toBeDefined();
expect(updatedData.updated_at).toBeInstanceOf(Date); expect(updatedData.updated_at).toBeInstanceOf(Date);
}); });
test("index strategy", async () => {
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"] })],
},
});
await app.build();
expect(app.em.indices.length).toBe(0);
{
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"], indexStrategy: "composite" })],
},
});
await app.build();
expect(app.em.indices.length).toBe(1);
}
{
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"], indexStrategy: "individual" })],
},
});
await app.build();
expect(app.em.indices.length).toBe(2);
}
});
}); });
+27 -9
View File
@@ -4,6 +4,7 @@ import { $console } from "bknd/utils";
export type TimestampsPluginOptions = { export type TimestampsPluginOptions = {
entities: string[]; entities: string[];
setUpdatedOnCreate?: boolean; setUpdatedOnCreate?: boolean;
indexStrategy?: "composite" | "individual";
}; };
/** /**
@@ -19,6 +20,7 @@ export type TimestampsPluginOptions = {
export function timestamps({ export function timestamps({
entities = [], entities = [],
setUpdatedOnCreate = true, setUpdatedOnCreate = true,
indexStrategy,
}: TimestampsPluginOptions): AppPlugin { }: TimestampsPluginOptions): AppPlugin {
return (app: App) => ({ return (app: App) => ({
name: "timestamps", name: "timestamps",
@@ -29,19 +31,35 @@ export function timestamps({
} }
const appEntities = app.em.entities.map((e) => e.name); const appEntities = app.em.entities.map((e) => e.name);
const actualEntities = entities.filter((e) => appEntities.includes(e));
return em( return em(
Object.fromEntries( Object.fromEntries(
entities actualEntities.map((e) => [
.filter((e) => appEntities.includes(e)) e,
.map((e) => [ entity(e, {
e, created_at: datetime(),
entity(e, { updated_at: datetime(),
created_at: datetime(), }),
updated_at: datetime(), ]),
}),
]),
), ),
(fns, schema) => {
if (indexStrategy) {
for (const entity of actualEntities) {
if (entity in schema) {
switch (indexStrategy) {
case "composite":
fns.index(schema[entity]!).on(["created_at", "updated_at"]);
break;
case "individual":
fns.index(schema[entity]!).on(["created_at"]);
fns.index(schema[entity]!).on(["updated_at"]);
break;
}
}
}
}
},
); );
}, },
onBuilt: async () => { onBuilt: async () => {
+1
View File
@@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
+27 -20
View File
@@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd";
import { useTheme, type AppTheme } from "ui/client/use-theme"; import { useTheme, type AppTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client"; import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { Routes } from "./routes"; import { Routes } from "./routes";
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options"; import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
@@ -16,6 +16,11 @@ export type BkndAdminConfig = {
* @default `/` * @default `/`
*/ */
basepath?: string; basepath?: string;
/**
* Sub-path for the Admin UI within the base path
* @default ``
*/
admin_basepath?: string;
/** /**
* Path to return to when clicking the logo * Path to return to when clicking the logo
* @default `/` * @default `/`
@@ -52,26 +57,30 @@ export type BkndAdminProps = {
children?: ReactNode; children?: ReactNode;
}; };
export default function Admin({ export default function Admin(props: BkndAdminProps) {
baseUrl: baseUrlOverride,
withProvider = false,
config: _config = {},
children,
}: BkndAdminProps) {
const { theme } = useTheme();
const Provider = ({ children }: any) => const Provider = ({ children }: any) =>
withProvider ? ( props.withProvider ? (
<ClientProvider <ClientProvider
baseUrl={baseUrlOverride} baseUrl={props.baseUrl}
{...(typeof withProvider === "object" ? withProvider : {})} {...(typeof props.withProvider === "object" ? props.withProvider : {})}
> >
{children} {children}
</ClientProvider> </ClientProvider>
) : ( ) : (
children children
); );
return (
<Provider>
<AdminInner {...props} />
</Provider>
);
}
function AdminInner(props: BkndAdminProps) {
const { theme } = useTheme();
const config = { const config = {
..._config, ...props.config,
...useBkndWindowContext(), ...useBkndWindowContext(),
}; };
@@ -82,14 +91,12 @@ export default function Admin({
); );
return ( return (
<Provider> <MantineProvider {...createMantineTheme(theme as any)}>
<MantineProvider {...createMantineTheme(theme as any)}> <Notifications position="top-right" />
<Notifications position="top-right" /> <Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}> {props.children}
{children} </Routes>
</Routes> </MantineProvider>
</MantineProvider>
</Provider>
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useApi } from "ui/client"; import { useApi } from "bknd/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
+10 -2
View File
@@ -14,18 +14,20 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = { export type ClientProviderProps = {
children?: ReactNode; children?: ReactNode;
baseUrl?: string; baseUrl?: string;
api?: Api;
} & ApiOptions; } & ApiOptions;
export const ClientProvider = ({ export const ClientProvider = ({
children, children,
host, host,
baseUrl: _baseUrl = host, baseUrl: _baseUrl = host,
api: _api,
...props ...props
}: ClientProviderProps) => { }: ClientProviderProps) => {
const winCtx = useBkndWindowContext(); const winCtx = useBkndWindowContext();
const _ctx = useClientContext(); const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any = undefined; let user: any;
if (winCtx) { if (winCtx) {
user = winCtx.user; user = winCtx.user;
@@ -40,6 +42,7 @@ export const ClientProvider = ({
const apiProps = { user, ...props, host: actualBaseUrl }; const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo( const api = useMemo(
() => () =>
_api ??
new Api({ new Api({
...apiProps, ...apiProps,
verbose: isDebug(), verbose: isDebug(),
@@ -50,7 +53,7 @@ export const ClientProvider = ({
} }
}, },
}), }),
[JSON.stringify(apiProps)], [_api, JSON.stringify(apiProps)],
); );
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState()); const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
@@ -64,9 +67,14 @@ export const ClientProvider = ({
export const useApi = (host?: ApiOptions["host"]): Api => { export const useApi = (host?: ApiOptions["host"]): Api => {
const context = useContext(ClientContext); const context = useContext(ClientContext);
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
console.info("creating new api", { host });
return new Api({ host: host ?? "" }); return new Api({ host: host ?? "" });
} }
if (!context) {
throw new Error("useApi must be used within a ClientProvider");
}
return context.api; return context.api;
}; };
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { useApi } from "ui/client"; import { useApi } from "../ClientProvider";
import { useState } from "react"; import { useState } from "react";
export const useApiQuery = < export const useApiQuery = <
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
import { objectTransform, encodeSearch } from "bknd/utils"; import { objectTransform, encodeSearch } from "bknd/utils";
import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import type { Insertable, Selectable, Updateable, Generated } from "kysely";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "ui/client"; import { type Api, useApi } from "bknd/client";
export class UseEntityApiError<Payload = any> extends Error { export class UseEntityApiError<Payload = any> extends Error {
constructor( constructor(
+1
View File
@@ -4,6 +4,7 @@ export {
type ClientProviderProps, type ClientProviderProps,
useApi, useApi,
useBaseUrl, useBaseUrl,
useClientContext
} from "./ClientProvider"; } from "./ClientProvider";
export * from "./api/use-api"; export * from "./api/use-api";
+3 -2
View File
@@ -1,7 +1,6 @@
import type { AuthState } from "Api"; import type { AuthState } from "Api";
import type { AuthResponse } from "bknd"; import type { AuthResponse } from "bknd";
import { useApi, useInvalidate } from "ui/client"; import { useApi, useInvalidate, useClientContext } from "bknd/client";
import { useClientContext } from "ui/client/ClientProvider";
type LoginData = { type LoginData = {
email: string; email: string;
@@ -19,6 +18,7 @@ type UseAuth = {
logout: () => Promise<void>; logout: () => Promise<void>;
verify: () => Promise<void>; verify: () => Promise<void>;
setToken: (token: string) => void; setToken: (token: string) => void;
local: boolean;
}; };
export const useAuth = (options?: { baseUrl?: string }): UseAuth => { export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
@@ -61,5 +61,6 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
logout, logout,
setToken, setToken,
verify, verify,
local: !!api.options.storage,
}; };
}; };
+12 -6
View File
@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType, protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {}, protected _options: BkndAdminProps["config"] = {},
) { ) {
//console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity); return constructEntity(name, entity);
@@ -70,20 +69,27 @@ export class AppReduced {
get options() { get options() {
return { return {
basepath: "", basepath: "/",
logo_return_path: "/", logo_return_path: "/",
...this._options, ...this._options,
}; };
} }
withBasePath(path: string | string[], absolute = false): string {
const paths = Array.isArray(path) ? path : [path];
return [absolute ? "~" : null, this.options.basepath, this.options.admin_basepath, ...paths]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/")
.replace(/\/$/, "");
}
getSettingsPath(path: string[] = []): string { getSettingsPath(path: string[] = []): string {
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/"); return this.withBasePath(["settings", ...path], true);
return [base, ...path].join("/");
} }
getAbsolutePath(path?: string): string { getAbsolutePath(path?: string): string {
const { basepath } = this.options; return this.withBasePath(path ?? [], true);
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
} }
getAuthConfig() { getAuthConfig() {
+1 -2
View File
@@ -1,6 +1,5 @@
import type React from "react"; import type React from "react";
import { Children } from "react"; import { Children, forwardRef } from "react";
import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
@@ -1,6 +1,6 @@
import { Tooltip } from "@mantine/core"; import { Tooltip } from "@mantine/core";
import clsx from "clsx"; import clsx from "clsx";
import { getBrowser } from "core/utils"; import { getBrowser } from "bknd/utils";
import type { Field } from "data/fields"; import type { Field } from "data/fields";
import { Switch as RadixSwitch } from "radix-ui"; import { Switch as RadixSwitch } from "radix-ui";
import { import {
@@ -16,15 +16,18 @@ import {
setPath, setPath,
} from "./utils"; } from "./utils";
export type NativeFormProps = { export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
hiddenSubmit?: boolean; hiddenSubmit?: boolean;
validateOn?: "change" | "submit"; validateOn?: "change" | "submit";
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null; errorFieldSelector?: (selector: string) => any | null;
reportValidity?: boolean; reportValidity?: boolean;
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void; onSubmit?: (
data: any,
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) => Promise<void> | void;
onSubmitInvalid?: ( onSubmitInvalid?: (
errors: InputError[], errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement> }, ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) => Promise<void> | void; ) => Promise<void> | void;
onError?: (errors: InputError[]) => void; onError?: (errors: InputError[]) => void;
disableSubmitOnError?: boolean; disableSubmitOnError?: boolean;
@@ -33,7 +36,7 @@ export type NativeFormProps = {
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }, ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
) => Promise<void> | void; ) => Promise<void> | void;
clean?: CleanOptions | true; clean?: CleanOptions | true;
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">; };
export type InputError = { export type InputError = {
name: string; name: string;
@@ -188,12 +191,12 @@ export function NativeForm({
const errors = validate({ report: true }); const errors = validate({ report: true });
if (errors.length > 0) { if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e }); onSubmitInvalid?.(errors, { event: e, form });
return; return;
} }
if (onSubmit) { if (onSubmit) {
await onSubmit(getFormValues(), { event: e }); await onSubmit(getFormValues(), { event: e, form });
} else { } else {
form.submit(); form.submit();
} }
+1 -2
View File
@@ -1,5 +1,4 @@
import { useInsertionEffect, useRef } from "react"; import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
/* /*

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