272 Commits

Author SHA1 Message Date
Sebastian a54fe7b239 chore(deps): update dependency phpunit/phpunit to v12.5.33
Build Test / build (pull_request) Successful in 32s
JS Unit Tests / test (pull_request) Successful in 31s
PHP Unit Tests / test (pull_request) Failing after 1m2s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m18s
2026-07-29 03:01:58 +00:00
Sebastian b8afd75b77 chore: remove dead code
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-28 13:22:08 -04:00
Sebastian eb99eb5a2e refactor: unified kernal logic clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 08:54:37 -04:00
Sebastian 65c1b5fb75 refactor(kernel): unify HTTP and CLI application lifecycle
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 00:48:11 -04:00
Sebastian 98826143a8 fix: sub menu spacing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-26 20:32:46 -04:00
Sebastian e47d0ed619 feat: improve menu section selector
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-26 18:20:40 -04:00
Sebastian 071c225b2d fix: remove broken layout test
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-26 16:35:26 -04:00
Sebastian b58f6e9479 fix: empty object
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 21:28:00 -04:00
Sebastian f958cdd6a3 fix: test server deploy script
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 20:41:01 -04:00
Sebastian 4ffbb906bf feat: system configuration example
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 20:28:14 -04:00
Sebastian 076f14831b feat: test server update script
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-25 20:17:18 -04:00
Sebastian 0b6c89bf27 Merge pull request 'chore(deps): update dependency eslint to v10.8.0' (#141) from renovate/eslint-monorepo into main
Reviewed-on: #141
2026-07-25 23:54:35 +00:00
Sebastian c982a9b6a7 Merge pull request 'chore(deps): update dependency sass to v1.102.0' (#142) from renovate/sass-1.x-lockfile into main
Reviewed-on: #142
2026-07-25 23:54:27 +00:00
Sebastian add3380393 chore(deps): update dependency sass to v1.102.0
JS Unit Tests / test (pull_request) Failing after 27s
Build Test / build (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Failing after 1m16s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m48s
2026-07-25 03:03:03 +00:00
Sebastian 300bb98a49 chore(deps): update dependency eslint to v10.8.0
JS Unit Tests / test (pull_request) Failing after 40s
Build Test / build (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Failing after 1m30s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m42s
2026-07-25 03:03:01 +00:00
Sebastian a723051d11 feat: console role management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 21:58:16 -04:00
Sebastian bf14b72a03 Merge pull request 'fix(deps): update dependency pinia to v4' (#140) from renovate/pinia-4.x into main
Reviewed-on: #140
2026-07-25 00:18:01 +00:00
Sebastian a346195c93 fix(deps): update dependency pinia to v4
JS Unit Tests / test (pull_request) Failing after 21s
Build Test / build (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Successful in 52s
PHP Integration Tests / Integration Tests (pull_request) Successful in 58s
2026-07-24 23:41:24 +00:00
Sebastian 34c68c9c2c Merge pull request 'chore(deps): update dependency vue-i18n to v11.4.7' (#133) from renovate/vue-i18n-11.x-lockfile into main
Reviewed-on: #133
2026-07-24 23:40:01 +00:00
Sebastian 48700bf35e Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#132) from renovate/vite-8.x-lockfile into main
Reviewed-on: #132
2026-07-24 23:39:55 +00:00
Sebastian 5aab0a5722 Merge pull request 'chore(deps): update dependency sass to v1.101.7' (#131) from renovate/sass-1.x-lockfile into main
Reviewed-on: #131
2026-07-24 23:39:49 +00:00
Sebastian d723793b5c Merge pull request 'chore(deps): update dependency vuetify to v4.1.6' (#135) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #135
2026-07-24 23:39:42 +00:00
Sebastian 78677c5da9 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#134) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #134
2026-07-24 23:39:34 +00:00
Sebastian 84eb2d3396 Merge pull request 'chore(deps): update dependency eslint-plugin-vue to v10.10.0' (#136) from renovate/eslint-plugin-vue-10.x-lockfile into main
Reviewed-on: #136
2026-07-24 23:39:26 +00:00
Sebastian caeb8b7e67 Merge pull request 'chore(deps): update dependency vue-router to v5.2.0' (#137) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #137
2026-07-24 23:39:18 +00:00
Sebastian d93a8cc441 Merge pull request 'chore(deps): update fontsource monorepo to v5.3.0' (#138) from renovate/fontsource-monorepo into main
Reviewed-on: #138
2026-07-24 23:39:11 +00:00
Sebastian 8fc000a9d1 Merge pull request 'chore(deps): update mongo docker tag to v8' (#139) from renovate/mongo-8.x into main
Reviewed-on: #139
2026-07-24 23:38:59 +00:00
Sebastian 64c490f98d chore(deps): update mongo docker tag to v8
Build Test / build (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Failing after 21s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m45s
PHP Unit Tests / test (pull_request) Successful in 1m31s
2026-07-24 22:37:26 +00:00
Sebastian ca1ec469d5 chore(deps): update fontsource monorepo to v5.3.0
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Failing after 12s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m0s
PHP Unit Tests / test (pull_request) Successful in 1m30s
2026-07-24 22:37:24 +00:00
Sebastian 770edf1bb9 chore(deps): update dependency vue-router to v5.2.0
Build Test / build (pull_request) Successful in 25s
JS Unit Tests / test (pull_request) Failing after 24s
PHP Unit Tests / test (pull_request) Successful in 56s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m53s
2026-07-24 22:37:21 +00:00
Sebastian a4f2edd6c8 chore(deps): update dependency eslint-plugin-vue to v10.10.0
Build Test / build (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Failing after 27s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m6s
PHP Unit Tests / test (pull_request) Successful in 1m44s
2026-07-24 22:37:14 +00:00
Sebastian 8452694f76 chore(deps): update dependency vuetify to v4.1.6
JS Unit Tests / test (pull_request) Failing after 22s
Build Test / build (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 1m19s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m37s
2026-07-24 22:36:57 +00:00
Sebastian c27597e4f9 chore(deps): update dependency vue-tsc to v3.3.8
Build Test / build (pull_request) Successful in 27s
JS Unit Tests / test (pull_request) Failing after 26s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m25s
PHP Unit Tests / test (pull_request) Successful in 2m5s
2026-07-24 22:36:54 +00:00
Sebastian 598f0653e3 chore(deps): update dependency vue-i18n to v11.4.7
JS Unit Tests / test (pull_request) Failing after 26s
Build Test / build (pull_request) Successful in 29s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m15s
PHP Unit Tests / test (pull_request) Successful in 1m42s
2026-07-24 22:36:52 +00:00
Sebastian 9f353797f0 chore(deps): update dependency vite to v8.1.5
Build Test / build (pull_request) Successful in 33s
JS Unit Tests / test (pull_request) Failing after 34s
PHP Unit Tests / test (pull_request) Successful in 1m25s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m20s
2026-07-24 22:36:49 +00:00
Sebastian bd26ccb2df chore(deps): update dependency sass to v1.101.7
JS Unit Tests / test (pull_request) Failing after 34s
Build Test / build (pull_request) Successful in 42s
PHP Unit Tests / test (pull_request) Successful in 2m4s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m5s
2026-07-24 22:36:45 +00:00
Sebastian 35b438389b Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.8' (#129) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #129
2026-07-24 03:27:19 +00:00
Sebastian 512a403a19 Merge pull request 'chore(deps): update dependency prettier to v3.9.6' (#130) from renovate/prettier-3.x-lockfile into main
Reviewed-on: #130
2026-07-24 03:27:13 +00:00
Sebastian dbb0d65d62 chore(deps): update dependency prettier to v3.9.6
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Failing after 11s
PHP Integration Tests / Integration Tests (pull_request) Successful in 56s
PHP Unit Tests / test (pull_request) Successful in 1m25s
2026-07-24 03:16:46 +00:00
Sebastian e88f459a35 chore(deps): update dependency @vitejs/plugin-vue to v6.0.8
JS Unit Tests / test (pull_request) Failing after 36s
Build Test / build (pull_request) Successful in 38s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m29s
PHP Unit Tests / test (pull_request) Successful in 1m52s
2026-07-24 03:16:43 +00:00
Sebastian a6b9c7a30b Merge pull request 'chore(deps): update dependency eslint to v10.7.0' (#124) from renovate/eslint-monorepo into main
Reviewed-on: #124
2026-07-24 03:08:20 +00:00
Sebastian 4e79ff727f Merge pull request 'chore(deps): update dependency dompurify to v3.4.12' (#125) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #125
2026-07-24 03:08:12 +00:00
Sebastian 314e200be9 Merge pull request 'chore(deps): update dependency vuetify to v4.1.5' (#126) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #126
2026-07-24 03:08:05 +00:00
Sebastian 71aaf46cf9 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.65.0' (#127) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #127
2026-07-24 03:07:59 +00:00
Sebastian e4f3ce9e79 Merge pull request 'chore: implement php test suites' (#128) from chore/php83-test-suites into main
Reviewed-on: #128
2026-07-24 03:07:51 +00:00
Sebastian b570150258 chore: implement php test suites
JS Unit Tests / test (pull_request) Failing after 43s
Build Test / build (pull_request) Successful in 44s
PHP Unit Tests / test (pull_request) Successful in 1m9s
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m28s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:20:35 -04:00
Sebastian e81b848a02 feat: console authentication control
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-22 23:44:35 -04:00
Sebastian bb847922b2 chore(deps): update typescript-eslint monorepo to v8.65.0
PHP Unit Tests / test (pull_request) Failing after 11m16s
JS Unit Tests / test (pull_request) Failing after 11m32s
Build Test / build (pull_request) Failing after 11m42s
2026-07-21 03:02:24 +00:00
Sebastian 009cff4747 chore(deps): update dependency eslint to v10.7.0
PHP Unit Tests / test (pull_request) Failing after 11m46s
JS Unit Tests / test (pull_request) Failing after 11m48s
Build Test / build (pull_request) Failing after 11m57s
2026-07-21 03:02:18 +00:00
Sebastian 0072308c67 chore(deps): update dependency vuetify to v4.1.5
PHP Unit Tests / test (pull_request) Failing after 12m5s
JS Unit Tests / test (pull_request) Failing after 12m14s
Build Test / build (pull_request) Failing after 12m35s
2026-07-21 03:02:16 +00:00
Sebastian 9f92e20383 chore(deps): update dependency dompurify to v3.4.12
PHP Unit Tests / test (pull_request) Failing after 12m41s
JS Unit Tests / test (pull_request) Failing after 12m51s
Build Test / build (pull_request) Failing after 13m10s
2026-07-21 03:02:10 +00:00
Sebastian 44e4e91e34 feat: user management console commands
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-20 06:58:44 -04:00
Sebastian 05dd82a0c5 refactor: move module commands to subfolder
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 22:39:28 -04:00
Sebastian 934fb24217 feat: tenant management console commands
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 22:38:43 -04:00
Sebastian 2c640125e8 feat: system user
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-19 06:56:38 -04:00
Sebastian 49e9291214 fix: authenticate via code
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-18 21:28:14 -04:00
Sebastian 584edce5d6 fix: convert request parameters to variables
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-18 21:27:12 -04:00
Sebastian eda7e0e8cc fix(shared): hydrate enum members and coerce keys in collections
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:52:07 -04:00
Sebastian f9f824a2f0 fix(shared): deserialize into null-initialized typed properties
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:51:47 -04:00
Sebastian 27b973f3db fix(shared): remove optional-before-required constructor parameter
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:51:09 -04:00
Sebastian 28bc360106 refactor: chrono entity classes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 15:43:23 -04:00
Sebastian 7e81e130c9 fix: pass asset request to server if does not exist on system
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-17 13:24:27 -04:00
Sebastian 532e099146 feat: make default theme graphite with system mode detection
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-10 18:01:20 -04:00
Sebastian 4e3ee9fb95 feat: vallarx
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-10 00:10:00 -04:00
Sebastian 9652fae814 fix(core): support fixed-height module layouts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 21:20:30 -04:00
Sebastian 65d60008da fix: layout loading settings
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:47:30 -04:00
Sebastian 4ea9dc8908 Merge pull request 'chore(deps): update dependency prettier to v3.9.5' (#115) from renovate/prettier-3.x-lockfile into main
Reviewed-on: #115
2026-07-10 00:29:05 +00:00
Sebastian 161f2a6d07 Merge pull request 'chore(deps): update dependency sass to v1.101.0' (#116) from renovate/sass-1.x-lockfile into main
Reviewed-on: #116
2026-07-10 00:28:56 +00:00
Sebastian 0f7c5dc8b7 Merge pull request 'chore(deps): update dependency vite to v8.1.4' (#117) from renovate/vite-8.x-lockfile into main
Reviewed-on: #117
2026-07-10 00:28:48 +00:00
Sebastian 3cc54103dd Merge pull request 'chore(deps): update dependency vue-router to v5.1.0' (#118) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #118
2026-07-10 00:28:40 +00:00
Sebastian 03953191f2 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.7' (#119) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #119
2026-07-10 00:28:32 +00:00
Sebastian f90fb9bda9 Merge pull request 'chore(deps): update dependency vuetify to v4.1.4' (#120) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #120
2026-07-10 00:28:24 +00:00
Sebastian 58285e81d2 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.63.0' (#121) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #121
2026-07-10 00:27:58 +00:00
Sebastian cda8dc2bab Merge pull request 'chore(deps): update dependency sass-loader to v17' (#122) from renovate/sass-loader-17.x into main
Reviewed-on: #122
2026-07-10 00:27:47 +00:00
Sebastian 2a32db0629 Merge pull request 'chore(deps): update dependency eslint to v10.6.0' (#114) from renovate/eslint-monorepo into main
Reviewed-on: #114
2026-07-10 00:27:38 +00:00
Sebastian 971fda604e Merge pull request 'chore(deps): update dependency @types/node to v25.9.5' (#113) from renovate/node-25.x-lockfile into main
Reviewed-on: #113
2026-07-10 00:27:30 +00:00
Sebastian 7afa1241a3 chore(deps): update dependency sass-loader to v17
PHP Unit Tests / test (pull_request) Failing after 11m11s
JS Unit Tests / test (pull_request) Failing after 11m15s
Build Test / build (pull_request) Failing after 11m29s
2026-07-10 00:26:26 +00:00
Sebastian 686b6a2937 chore(deps): update typescript-eslint monorepo to v8.63.0
PHP Unit Tests / test (pull_request) Failing after 11m48s
JS Unit Tests / test (pull_request) Failing after 11m53s
Build Test / build (pull_request) Failing after 11m55s
2026-07-10 00:26:24 +00:00
Sebastian d86b6f7030 chore(deps): update dependency vuetify to v4.1.4
PHP Unit Tests / test (pull_request) Failing after 12m7s
JS Unit Tests / test (pull_request) Failing after 12m21s
Build Test / build (pull_request) Failing after 12m24s
2026-07-10 00:26:14 +00:00
Sebastian f859660ab0 chore(deps): update dependency vue-tsc to v3.3.7
PHP Unit Tests / test (pull_request) Failing after 12m45s
JS Unit Tests / test (pull_request) Failing after 12m51s
Build Test / build (pull_request) Failing after 13m2s
2026-07-10 00:26:09 +00:00
Sebastian c853fd3639 chore(deps): update dependency vue-router to v5.1.0
PHP Unit Tests / test (pull_request) Failing after 13m6s
JS Unit Tests / test (pull_request) Failing after 13m9s
Build Test / build (pull_request) Failing after 13m32s
2026-07-10 00:26:06 +00:00
Sebastian 392a745c09 chore(deps): update dependency vite to v8.1.4
PHP Unit Tests / test (pull_request) Failing after 13m39s
JS Unit Tests / test (pull_request) Failing after 13m53s
Build Test / build (pull_request) Failing after 14m19s
2026-07-10 00:26:02 +00:00
Sebastian 9749d793a8 chore(deps): update dependency sass to v1.101.0
PHP Unit Tests / test (pull_request) Failing after 14m21s
JS Unit Tests / test (pull_request) Failing after 14m23s
Build Test / build (pull_request) Failing after 14m26s
2026-07-10 00:25:54 +00:00
Sebastian ba42cf6e1e chore(deps): update dependency prettier to v3.9.5
Build Test / build (pull_request) Failing after 10m43s
PHP Unit Tests / test (pull_request) Failing after 14m33s
JS Unit Tests / test (pull_request) Failing after 14m35s
2026-07-10 00:25:53 +00:00
Sebastian 702d1dbc58 chore(deps): update dependency eslint to v10.6.0
JS Unit Tests / test (pull_request) Failing after 10m47s
Build Test / build (pull_request) Failing after 10m49s
PHP Unit Tests / test (pull_request) Failing after 15m45s
2026-07-10 00:25:51 +00:00
Sebastian f9670ec53d chore(deps): update dependency @types/node to v25.9.5
JS Unit Tests / test (pull_request) Failing after 10m53s
Build Test / build (pull_request) Failing after 10m55s
PHP Unit Tests / test (pull_request) Failing after 15m51s
2026-07-10 00:25:48 +00:00
Sebastian 9cbda1013b Merge pull request 'chore(deps): update dependency phpunit/phpunit to v11.5.56' (#111) from renovate/phpunit-phpunit-11.x-lockfile into main
Reviewed-on: #111
2026-07-10 00:23:53 +00:00
Sebastian 3a73756e9b Merge pull request 'chore(deps): update vitest monorepo to v4.1.10' (#112) from renovate/vitest-monorepo into main
Reviewed-on: #112
2026-07-10 00:23:20 +00:00
Sebastian ffed9a7b5e chore(deps): update vitest monorepo to v4.1.10
PHP Unit Tests / test (pull_request) Failing after 14m17s
JS Unit Tests / test (pull_request) Failing after 14m19s
Build Test / build (pull_request) Failing after 14m21s
2026-07-10 00:22:21 +00:00
Sebastian 6013df548e chore(deps): update dependency phpunit/phpunit to v11.5.56
PHP Unit Tests / test (pull_request) Failing after 14m27s
JS Unit Tests / test (pull_request) Failing after 14m29s
Build Test / build (pull_request) Failing after 14m31s
2026-07-10 00:22:13 +00:00
Sebastian 72e89fa724 fix(renovate): install in runner temp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:04:12 -04:00
Sebastian 7c14d49ee6 Merge pull request 'chore(deps): update dependency symfony/console to v7.4.14' (#108) from renovate/symfony into main
Reviewed-on: #108
2026-07-09 23:51:18 +00:00
Sebastian 0b06b47f1f Merge pull request 'chore(deps): update dependency vite-plugin-static-copy to v4.1.1' (#109) from renovate/vite-plugin-static-copy-4.x-lockfile into main
Reviewed-on: #109
2026-07-09 23:51:05 +00:00
Sebastian 6d015ffce8 Merge pull request 'chore(deps): update vitest monorepo to v4.1.9' (#110) from renovate/vitest-monorepo into main
Reviewed-on: #110
2026-07-09 23:50:52 +00:00
Sebastian 29d4c9130f refactor: documents interfaces
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 19:31:09 -04:00
Sebastian 93d32d2a9f fix: theming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 22:49:51 -04:00
Sebastian 5a729d851e refactor: cleanup translations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 17:57:43 -04:00
Sebastian d92a0331a6 refactor: console messages
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 17:04:34 -04:00
Sebastian 0cc255a087 feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 16:56:58 -04:00
Sebastian 6931dc9ff7 fix: expired token issue
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-05 11:36:20 -04:00
Sebastian 8a3610c3b2 chore(deps): update vitest monorepo to v4.1.9
PHP Unit Tests / test (pull_request) Successful in 44s
JS Unit Tests / test (pull_request) Failing after 14m43s
Build Test / build (pull_request) Failing after 15m3s
2026-07-05 03:02:31 +00:00
Sebastian 0f05494cf3 chore(deps): update dependency vite-plugin-static-copy to v4.1.1
Build Test / build (pull_request) Successful in 13s
PHP Unit Tests / test (pull_request) Failing after 15m5s
JS Unit Tests / test (pull_request) Failing after 15m9s
2026-07-05 03:02:25 +00:00
Sebastian 502be95d7d feat: implement event end calculator
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-04 19:02:36 -04:00
Sebastian ef3d19db9a feat: implement now epoch
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-04 19:02:07 -04:00
Sebastian 58a9c4351d refactor: use epoch timestamp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-04 18:21:10 -04:00
Sebastian fe78426a5d feat: add deserilazation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 21:33:28 -04:00
Sebastian 06c1cb272f chore(deps): update dependency symfony/console to v7.4.14
Build Test / build (pull_request) Failing after 10m14s
PHP Unit Tests / test (pull_request) Failing after 14m56s
JS Unit Tests / test (pull_request) Failing after 14m58s
2026-07-02 03:02:40 +00:00
Sebastian 8f61c4d2d5 refactor: chrono objects
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-28 17:51:23 -04:00
Sebastian 319fcb6485 Merge pull request 'chore(deps): update dependency prettier to v3.8.4' (#107) from renovate/prettier-3.x-lockfile into main
Reviewed-on: #107
2026-06-28 14:42:46 +00:00
Sebastian 18478f9985 Merge pull request 'chore(deps): update dependency phpseclib/phpseclib to v3.0.55' (#106) from renovate/phpseclib-phpseclib-3.x-lockfile into main
Reviewed-on: #106
2026-06-28 14:42:36 +00:00
Sebastian 139b4df844 refactor: use seen instead of read
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:47:52 -04:00
Sebastian a7ad86860d refactor: use unified service provider desing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:47:24 -04:00
Sebastian 6ca83a7dd7 refactor: use unified service provider desing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-25 23:47:17 -04:00
Sebastian 6a3e1f309e chore(deps): update dependency prettier to v3.8.4
PHP Unit Tests / test (pull_request) Failing after 13m54s
JS Unit Tests / test (pull_request) Failing after 13m56s
Build Test / build (pull_request) Failing after 14m6s
2026-06-25 03:02:46 +00:00
Sebastian 4d4999602c chore(deps): update dependency phpseclib/phpseclib to v3.0.55
PHP Unit Tests / test (pull_request) Failing after 14m35s
JS Unit Tests / test (pull_request) Failing after 14m37s
Build Test / build (pull_request) Failing after 14m45s
2026-06-25 03:02:45 +00:00
Sebastian 87d6af93b7 Merge pull request 'chore(deps): update dependency dompurify to v3.4.11' (#104) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #104
2026-06-24 15:23:15 +00:00
Sebastian 2b44d395f0 Merge pull request 'chore(deps): update dependency eslint-plugin-vue to v10.9.2' (#105) from renovate/eslint-plugin-vue-10.x-lockfile into main
Reviewed-on: #105
2026-06-24 15:23:06 +00:00
Sebastian 8ca85e894d chore(deps): update dependency eslint-plugin-vue to v10.9.2
PHP Unit Tests / test (pull_request) Failing after 14m11s
JS Unit Tests / test (pull_request) Failing after 14m15s
Build Test / build (pull_request) Failing after 14m31s
2026-06-24 03:03:06 +00:00
Sebastian cc8482f309 chore(deps): update dependency dompurify to v3.4.11
Build Test / build (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Failing after 14m42s
JS Unit Tests / test (pull_request) Failing after 14m44s
2026-06-24 03:03:02 +00:00
Sebastian 14f25c9a44 refactor: people entity properties objects
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-21 21:47:51 -04:00
Sebastian b6c3551d36 refactor: priority to rank
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-21 21:46:30 -04:00
Sebastian a6e2a447e5 refactor: people interfaces to unified design
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 19:04:39 -04:00
Sebastian 802849a60d refactor: docs
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-20 19:04:05 -04:00
Sebastian a3443b1e54 fix: size property constant
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 18:03:18 -04:00
Sebastian cededf82c3 Merge pull request 'refactor: object property names' (#103) from refactor/object-property-names into main
Reviewed-on: #103
2026-06-16 17:21:55 +00:00
Sebastian 388e3fb8b8 refactor: object property names
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 13s
PHP Unit Tests / test (pull_request) Successful in 35s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 13:19:40 -04:00
Sebastian bc54ba5811 Merge pull request 'refactor: message object properties' (#102) from refactor/message-object-properties into main
Reviewed-on: #102
2026-06-16 17:17:02 +00:00
Sebastian 869f2e4fb4 refactor: message object properties
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-16 13:12:30 -04:00
Sebastian 6dadd13acf Merge pull request 'chore(deps): update dependency @vue/test-utils to v2.4.11' (#100) from renovate/vue-test-utils-2.x-lockfile into main
Renovate / renovate (push) Failing after 1m24s
Reviewed-on: #100
2026-06-06 04:08:49 +00:00
Sebastian 2d1ee97afe Merge pull request 'chore(deps): update dependency dompurify to v3.4.8' (#101) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #101
2026-06-06 04:08:34 +00:00
Sebastian 8432f5a823 chore(deps): update dependency dompurify to v3.4.8
JS Unit Tests / test (pull_request) Successful in 32s
Build Test / build (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 1m0s
2026-06-05 03:05:06 +00:00
Sebastian 30cfd06594 chore(deps): update dependency @vue/test-utils to v2.4.11
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 58s
2026-06-05 03:05:04 +00:00
Sebastian 175981b5b2 Merge pull request 'chore(deps): update dependency dompurify to v3.4.5' (#96) from renovate/dompurify-3.x-lockfile into main
Renovate / renovate (push) Failing after 1m13s
Reviewed-on: #96
2026-05-29 03:06:09 +00:00
Sebastian 0e8c91e82d Merge pull request 'chore(deps): update dependency vite to v8.0.14' (#97) from renovate/vite-8.x-lockfile into main
Reviewed-on: #97
2026-05-29 03:05:59 +00:00
Sebastian 2fc9cbe033 Merge pull request 'chore(deps): update vitest monorepo to v4.1.7' (#98) from renovate/vitest-monorepo into main
Reviewed-on: #98
2026-05-29 03:05:49 +00:00
Sebastian 9deac1682e Merge pull request 'feat: mail entity download' (#99) from feat/mail-entity-download into main
Reviewed-on: #99
2026-05-29 03:05:38 +00:00
Sebastian 812a6680a8 feat: mail entity download
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 18s
PHP Unit Tests / test (pull_request) Successful in 40s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-28 23:02:37 -04:00
Sebastian a7f57aecf7 chore(deps): update vitest monorepo to v4.1.7
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 47s
2026-05-26 03:04:32 +00:00
Sebastian 9a234fb758 chore(deps): update dependency vite to v8.0.14
Build Test / build (pull_request) Successful in 31s
JS Unit Tests / test (pull_request) Successful in 30s
PHP Unit Tests / test (pull_request) Successful in 49s
2026-05-24 03:04:36 +00:00
Sebastian 87940f8eaf chore(deps): update dependency dompurify to v3.4.5
Build Test / build (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Successful in 32s
PHP Unit Tests / test (pull_request) Successful in 1m6s
2026-05-24 03:04:30 +00:00
Sebastian 8d716e3c72 Merge pull request 'refactor: list and fetch' (#95) from refactor/list-and-fetch into main
Renovate / renovate (push) Failing after 1m55s
Reviewed-on: #95
2026-05-17 21:58:01 +00:00
Sebastian 4e1d6d65d0 refactor: list and fetch
Build Test / build (pull_request) Successful in 13s
JS Unit Tests / test (pull_request) Successful in 12s
PHP Unit Tests / test (pull_request) Successful in 33s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-17 17:57:44 -04:00
Sebastian a4a87dd82d Merge pull request 'chore(deps): update dependency eslint to v10.4.0' (#92) from renovate/eslint-monorepo into main
Renovate / renovate (push) Successful in 2m55s
Reviewed-on: #92
2026-05-16 04:16:19 +00:00
Sebastian f8fb84dd11 chore(deps): update dependency eslint to v10.4.0
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 1m4s
2026-05-16 03:02:33 +00:00
Sebastian b2a1a17acd Merge pull request 'chore: bump packages' (#91) from chore/bump-packages into main
Renovate / renovate (push) Successful in 2m33s
Reviewed-on: #91
2026-05-15 13:34:57 +00:00
Sebastian cf10ad0a50 chore: bump packages
Build Test / build (pull_request) Successful in 11s
JS Unit Tests / test (pull_request) Successful in 10s
PHP Unit Tests / test (pull_request) Successful in 40s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-15 09:33:59 -04:00
Sebastian 8bb57052cd Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.7' (#90) from renovate/vitejs-plugin-vue-6.x into main
Reviewed-on: #90
2026-05-15 12:56:22 +00:00
Sebastian dd105767d4 chore(deps): update dependency @vitejs/plugin-vue to v6.0.7
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Successful in 1m2s
2026-05-15 12:55:34 +00:00
Sebastian a2e59ba731 Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.9' (#84) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #84
2026-05-15 03:26:42 +00:00
Sebastian 8175146eda Merge pull request 'chore(deps): update vitest monorepo to v4.1.6' (#86) from renovate/vitest-monorepo into main
Reviewed-on: #86
2026-05-15 03:26:33 +00:00
Sebastian 70ab6d537b Merge pull request 'chore(deps): update dependency @types/node to v25.8.0' (#89) from renovate/node-25.x into main
Reviewed-on: #89
2026-05-15 03:26:24 +00:00
Sebastian a8e073e793 Merge pull request 'fix(deps): update dependency vuetify to v4.0.7' (#88) from renovate/vuetify-4.x into main
Reviewed-on: #88
2026-05-15 03:26:16 +00:00
Sebastian 16ec429347 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.59.3' (#85) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #85
2026-05-15 03:26:09 +00:00
Sebastian 206d22ab1e Merge pull request 'chore(deps): update dependency vite to v8.0.13' (#83) from renovate/vite-8.x into main
Reviewed-on: #83
2026-05-15 03:25:59 +00:00
Sebastian 778cd47221 Merge pull request 'chore(deps): update dependency symfony/console to v7.4.11' (#82) from renovate/symfony into main
Reviewed-on: #82
2026-05-15 03:23:16 +00:00
Sebastian 08028b280b chore(deps): update dependency @types/node to v25.8.0
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 40s
2026-05-15 03:22:43 +00:00
Sebastian d6bbfb78ed fix(deps): update dependency vuetify to v4.0.7
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 12s
PHP Unit Tests / test (pull_request) Successful in 51s
2026-05-15 03:22:32 +00:00
Sebastian d800ccb249 chore(deps): update vitest monorepo to v4.1.6
Build Test / build (pull_request) Successful in 22s
JS Unit Tests / test (pull_request) Successful in 18s
PHP Unit Tests / test (pull_request) Successful in 57s
2026-05-15 03:22:24 +00:00
Sebastian 9fb3a481c2 chore(deps): update typescript-eslint monorepo to v8.59.3
Build Test / build (pull_request) Successful in 21s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 1m0s
2026-05-15 03:22:18 +00:00
Sebastian 67f261e9ab chore(deps): update dependency vue-tsc to v3.2.9
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 56s
2026-05-15 03:22:05 +00:00
Sebastian 24c9ddd010 chore(deps): update dependency vite to v8.0.13
JS Unit Tests / test (pull_request) Successful in 21s
Build Test / build (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 54s
2026-05-15 03:22:02 +00:00
Sebastian f29694c9d6 chore(deps): update dependency symfony/console to v7.4.11
Build Test / build (pull_request) Successful in 26s
JS Unit Tests / test (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 1m12s
2026-05-15 03:21:57 +00:00
Sebastian 06825f9d25 Merge pull request 'chore(deps): update dependency dompurify to v3.4.3' (#80) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #80
2026-05-15 03:12:18 +00:00
Sebastian 7fd98a1465 Merge pull request 'chore(deps): update dependency sass-loader to v16.0.8' (#81) from renovate/sass-loader-16.x into main
Reviewed-on: #81
2026-05-15 03:10:37 +00:00
Sebastian 1cfa18c8cf chore(deps): update dependency sass-loader to v16.0.8
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 38s
2026-05-15 03:09:48 +00:00
Sebastian 3da9fe6211 chore(deps): update dependency dompurify to v3.4.3
Build Test / build (pull_request) Successful in 21s
JS Unit Tests / test (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 49s
2026-05-15 03:09:45 +00:00
Sebastian 858e9fddf0 Merge pull request 'refactor: mail interfaces' (#79) from refactor/mail-interfaces into main
Renovate / renovate (push) Has started running
Reviewed-on: #79
2026-05-15 02:56:07 +00:00
Sebastian f3c882454d refactor: mail interfaces
Build Test / build (pull_request) Successful in 12s
JS Unit Tests / test (pull_request) Successful in 11s
PHP Unit Tests / test (pull_request) Successful in 39s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-14 22:54:51 -04:00
Sebastian d6005246dc Merge pull request 'refactor: service interfaces' (#78) from refactor/service-interfaces into main
Renovate / renovate (push) Waiting to run
Reviewed-on: #78
2026-05-09 21:00:33 +00:00
Sebastian b8cda71c17 refactor: service interfaces
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 14s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-09 17:00:00 -04:00
Sebastian 4e7260ad86 Merge pull request 'refactor: entity move and delete' (#77) from refactor/entity-move-and-delete into main
Renovate / renovate (push) Failing after 1m56s
Reviewed-on: #77
2026-05-08 03:58:54 +00:00
Sebastian 69d6c7fd1a refactor: entity move and delete
Build Test / build (pull_request) Successful in 18s
JS Unit Tests / test (pull_request) Successful in 16s
PHP Unit Tests / test (pull_request) Successful in 41s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-07 23:58:36 -04:00
Sebastian 6d71bb49d5 Merge pull request 'chore: downgrade console and mongodb' (#76) from chore/downgrade-mongodb into main
Renovate / renovate (push) Failing after 1m46s
Reviewed-on: #76
2026-05-07 03:35:06 +00:00
Sebastian 19e49379f4 chore: downgrade console and mongodb
Build Test / build (pull_request) Successful in 24s
JS Unit Tests / test (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Successful in 46s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 23:34:51 -04:00
Sebastian cce65c3e62 Merge pull request 'chore(deps): update dependency typescript to v6' (#73) from renovate/typescript-6.x into main
Reviewed-on: #73
2026-05-07 03:17:33 +00:00
Sebastian 757d69b803 Merge pull request 'chore(deps): update dependency mongodb/mongodb to v2.3.0' (#69) from renovate/mongodb-mongodb-2.x-lockfile into main
Reviewed-on: #69
2026-05-07 03:17:23 +00:00
Sebastian 745c2947c3 Merge pull request 'chore(deps): update eslint monorepo to v10 (major)' (#74) from renovate/major-eslint-monorepo into main
Reviewed-on: #74
2026-05-07 03:17:13 +00:00
Sebastian 2b05cd39ef chore(deps): update eslint monorepo to v10
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 36s
2026-05-07 03:15:20 +00:00
Sebastian bb10f48ded chore(deps): update dependency typescript to v6
Build Test / build (pull_request) Successful in 30s
JS Unit Tests / test (pull_request) Successful in 14s
PHP Unit Tests / test (pull_request) Successful in 1m16s
2026-05-07 03:15:17 +00:00
Sebastian aec0abaca6 chore(deps): update dependency mongodb/mongodb to v2.3.0
JS Unit Tests / test (pull_request) Successful in 31s
Build Test / build (pull_request) Successful in 35s
PHP Unit Tests / test (pull_request) Successful in 1m11s
2026-05-07 03:15:10 +00:00
Sebastian e0ec4acdd4 Merge pull request 'chore(deps): update dependency jsdom to v29' (#71) from renovate/jsdom-29.x into main
Reviewed-on: #71
2026-05-07 03:11:07 +00:00
Sebastian b0d8317f51 Merge pull request 'chore(deps): update dependency sass to v1.99.0' (#70) from renovate/sass-1.x into main
Reviewed-on: #70
2026-05-07 03:10:55 +00:00
Sebastian 3e859bac10 Merge pull request 'chore(deps): update dependency phpseclib/phpseclib to v3.0.52' (#58) from renovate/phpseclib-phpseclib-3.x-lockfile into main
Reviewed-on: #58
2026-05-07 03:10:28 +00:00
Sebastian ccac97b19f chore(deps): update dependency jsdom to v29
JS Unit Tests / test (pull_request) Successful in 19s
Build Test / build (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 48s
2026-05-07 03:08:47 +00:00
Sebastian 224d2a9eeb chore(deps): update dependency sass to v1.99.0
Build Test / build (pull_request) Successful in 25s
JS Unit Tests / test (pull_request) Successful in 22s
PHP Unit Tests / test (pull_request) Successful in 55s
2026-05-07 03:08:44 +00:00
Sebastian f98734938c chore(deps): update dependency phpseclib/phpseclib to v3.0.52
JS Unit Tests / test (pull_request) Successful in 22s
Build Test / build (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 1m33s
2026-05-07 03:08:35 +00:00
Sebastian a98ea3b1da Merge pull request 'chore(deps): update dependency eslint-plugin-vue to v10.9.1' (#68) from renovate/eslint-plugin-vue-10.x into main
Reviewed-on: #68
2026-05-07 03:05:07 +00:00
Sebastian 6ce536cdf0 Merge pull request 'chore(deps): update dependency @vue/tsconfig to v0.9.1' (#67) from renovate/vue-tsconfig-0.x into main
Reviewed-on: #67
2026-05-07 03:02:21 +00:00
Sebastian 9ed19ab406 Merge pull request 'chore(deps): update typescript-eslint monorepo to v8.59.2' (#62) from renovate/typescript-eslint-monorepo into main
Reviewed-on: #62
2026-05-07 03:01:53 +00:00
Sebastian 47c6e6317a Merge pull request 'fix(deps): update dependency @fontsource/roboto to v5.2.10' (#63) from renovate/fontsource-monorepo into main
Reviewed-on: #63
2026-05-07 03:01:43 +00:00
Sebastian 749248f13c Merge pull request 'chore(deps): update dependency @types/node to v25.6.0' (#66) from renovate/node-25.x into main
Reviewed-on: #66
2026-05-07 03:01:34 +00:00
Sebastian 6d6e523812 Merge pull request 'fix(deps): update dependency vue-router to v5.0.6' (#64) from renovate/vue-router-5.x into main
Reviewed-on: #64
2026-05-07 03:01:23 +00:00
Sebastian a11feffd7f Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.8' (#61) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #61
2026-05-07 03:01:01 +00:00
Sebastian 7d4a68db53 Merge pull request 'chore(deps): update dependency symfony/console to v8.0.9' (#60) from renovate/symfony into main
Reviewed-on: #60
2026-05-07 03:00:52 +00:00
Sebastian d56155d83f Merge pull request 'chore(deps): update dependency prettier to v3.8.3' (#59) from renovate/prettier-3.x into main
Reviewed-on: #59
2026-05-07 03:00:43 +00:00
Sebastian f9bebaf5c5 Merge pull request 'chore(deps): update dependency dompurify to v3.4.2' (#57) from renovate/dompurify-3.x-lockfile into main
Reviewed-on: #57
2026-05-07 03:00:30 +00:00
Sebastian 8666ff60ec chore(deps): update dependency eslint-plugin-vue to v10.9.1
JS Unit Tests / test (pull_request) Successful in 25s
Build Test / build (pull_request) Successful in 29s
PHP Unit Tests / test (pull_request) Successful in 1m40s
2026-05-07 02:58:25 +00:00
Sebastian 41d823b9c3 chore(deps): update dependency @vue/tsconfig to v0.9.1
Build Test / build (pull_request) Successful in 24s
JS Unit Tests / test (pull_request) Successful in 11s
PHP Unit Tests / test (pull_request) Successful in 1m23s
2026-05-07 02:58:22 +00:00
Sebastian 32f3d0e4c6 chore(deps): update dependency @types/node to v25.6.0
JS Unit Tests / test (pull_request) Successful in 19s
Build Test / build (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 1m10s
2026-05-07 02:58:20 +00:00
Sebastian 967c88ea45 fix(deps): update dependency vue-router to v5.0.6
JS Unit Tests / test (pull_request) Successful in 20s
Build Test / build (pull_request) Successful in 27s
PHP Unit Tests / test (pull_request) Successful in 1m34s
2026-05-07 02:58:14 +00:00
Sebastian 8dc879c015 fix(deps): update dependency @fontsource/roboto to v5.2.10
Build Test / build (pull_request) Successful in 23s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 1m18s
2026-05-07 02:58:10 +00:00
Sebastian 1001e8b641 chore(deps): update typescript-eslint monorepo to v8.59.2
Build Test / build (pull_request) Successful in 28s
JS Unit Tests / test (pull_request) Successful in 17s
PHP Unit Tests / test (pull_request) Successful in 2m17s
2026-05-07 02:58:07 +00:00
Sebastian 6577a1647e chore(deps): update dependency vue-tsc to v3.2.8
JS Unit Tests / test (pull_request) Successful in 27s
Build Test / build (pull_request) Successful in 32s
PHP Unit Tests / test (pull_request) Successful in 1m20s
2026-05-07 02:57:52 +00:00
Sebastian 26aa11e965 chore(deps): update dependency symfony/console to v8.0.9
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 14s
PHP Unit Tests / test (pull_request) Successful in 1m27s
2026-05-07 02:57:49 +00:00
Sebastian cb8966fbdf chore(deps): update dependency prettier to v3.8.3
Build Test / build (pull_request) Successful in 36s
JS Unit Tests / test (pull_request) Successful in 38s
PHP Unit Tests / test (pull_request) Successful in 1m37s
2026-05-07 02:57:38 +00:00
Sebastian 302b80e8aa chore(deps): update dependency dompurify to v3.4.2
Build Test / build (pull_request) Successful in 21s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 1m46s
2026-05-07 02:57:31 +00:00
Sebastian d6bd36c25e Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.6' (#55) from renovate/vitejs-plugin-vue-6.x into main
Renovate / renovate (push) Has started running
Reviewed-on: #55
2026-05-07 02:40:02 +00:00
Sebastian 4e23c4654c Merge pull request 'chore(deps): update dependency @vue/test-utils to v2.4.10' (#56) from renovate/vue-test-utils-2.x-lockfile into main
Reviewed-on: #56
2026-05-07 02:39:47 +00:00
Sebastian 39403699c9 chore(deps): update dependency @vue/test-utils to v2.4.10
Build Test / build (pull_request) Successful in 37s
JS Unit Tests / test (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 1m14s
2026-05-07 02:38:10 +00:00
Sebastian 13f9967b7c chore(deps): update dependency @vitejs/plugin-vue to v6.0.6
Build Test / build (pull_request) Successful in 24s
JS Unit Tests / test (pull_request) Successful in 31s
PHP Unit Tests / test (pull_request) Successful in 1m5s
2026-05-07 02:38:08 +00:00
Sebastian e8ac1685a3 Merge pull request 'fix: vite static copy' (#54) from fix/vite-static-copy into main
Reviewed-on: #54
2026-05-06 20:40:07 +00:00
Sebastian 525018e5eb fix: vite static copy
Build Test / build (pull_request) Successful in 13s
JS Unit Tests / test (pull_request) Successful in 12s
PHP Unit Tests / test (pull_request) Successful in 41s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 16:39:40 -04:00
Sebastian 55f936c684 Merge pull request 'chore: renovate update' (#53) from chore/renovate-update into main
Reviewed-on: #53
2026-05-06 17:02:26 +00:00
Sebastian b105cc18b5 chore: renovate update
Build Test / build (pull_request) Successful in 12s
JS Unit Tests / test (pull_request) Successful in 11s
PHP Unit Tests / test (pull_request) Successful in 38s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 13:02:01 -04:00
Sebastian 747f0e63f7 Merge pull request 'chore(deps): update dependency vite to v8' (#47) from renovate/vite-8.x into main
Reviewed-on: #47
2026-05-06 16:34:42 +00:00
Sebastian 8a48c3f17f chore(deps): update dependency vite to v8
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 47s
2026-05-06 16:34:03 +00:00
Sebastian 0d5ed4de1e Merge pull request 'chore(deps): update dependency vite-plugin-static-copy to v4' (#48) from renovate/vite-plugin-static-copy-4.x into main
Reviewed-on: #48
2026-05-06 16:30:16 +00:00
Sebastian 5af979cefe Merge pull request 'fix(deps): update dependency symfony/console to v8' (#49) from renovate/major-symfony into main
Reviewed-on: #49
2026-05-06 16:30:05 +00:00
Sebastian fe2a8fbfa3 Merge pull request 'fix(deps): update dependency vuetify to v4' (#50) from renovate/vuetify-4.x into main
Reviewed-on: #50
2026-05-06 16:29:49 +00:00
Sebastian ea09a821ab Merge pull request 'refactor: mail collections delete' (#52) from refactor/mail-collections into main
Reviewed-on: #52
2026-05-06 16:28:44 +00:00
Sebastian 44584fc306 refactor: mail collections delete
JS Unit Tests / test (pull_request) Successful in 14s
Build Test / build (pull_request) Successful in 17s
PHP Unit Tests / test (pull_request) Successful in 42s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 12:28:18 -04:00
Sebastian 452048de64 Merge pull request 'fix: plain logging' (#51) from fix/plain-logging into main
Reviewed-on: #51
2026-05-06 16:25:43 +00:00
Sebastian a7a94f93d8 fix: plain logging
Build Test / build (pull_request) Successful in 16s
JS Unit Tests / test (pull_request) Successful in 15s
PHP Unit Tests / test (pull_request) Successful in 44s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 12:24:53 -04:00
Sebastian 2b89e50b77 fix(deps): update dependency vuetify to v4
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 52s
2026-04-25 20:06:42 +00:00
Sebastian 4d758df68e fix(deps): update dependency symfony/console to v8
JS Unit Tests / test (pull_request) Successful in 18s
Build Test / build (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 51s
2026-04-25 20:06:37 +00:00
Sebastian 2cd85c3ffd chore(deps): update dependency vite-plugin-static-copy to v4
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 27s
PHP Unit Tests / test (pull_request) Successful in 1m33s
2026-04-25 20:06:21 +00:00
Sebastian bc28e9c307 Merge pull request 'fix: minor issues' (#43) from fix/minor-issues-0425 into main
Renovate / renovate (push) Failing after 1h12m55s
Reviewed-on: #43
2026-04-25 19:55:05 +00:00
Sebastian f5dcc39848 Merge pull request 'chore: update npm packages' (#45) from chore/node-package-update into main
Reviewed-on: #45
2026-04-25 19:54:52 +00:00
Sebastian e1cc559975 Merge pull request 'chore: update composer packages' (#44) from chore/composer-package-update into main
Reviewed-on: #44
2026-04-25 19:54:40 +00:00
Sebastian 147bef6649 chore: update npm packages
JS Unit Tests / test (pull_request) Successful in 26s
Build Test / build (pull_request) Successful in 29s
PHP Unit Tests / test (pull_request) Successful in 1m2s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-25 15:53:09 -04:00
Sebastian 1da4452b5d chore: update composer packages
Build Test / build (pull_request) Successful in 25s
JS Unit Tests / test (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 1m8s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-25 15:50:08 -04:00
Sebastian 88c72542f2 fix: minor issues
JS Unit Tests / test (pull_request) Successful in 26s
Build Test / build (pull_request) Successful in 28s
PHP Unit Tests / test (pull_request) Successful in 53s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-25 15:46:51 -04:00
Sebastian a05313e3a1 Merge pull request 'feat: entity move' (#42) from feat/entity-move into main
Renovate / renovate (push) Successful in 1m31s
Reviewed-on: #42
2026-03-28 00:41:55 +00:00
Sebastian dfba1d43be feat: entity move
JS Unit Tests / test (pull_request) Successful in 20s
Build Test / build (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-27 20:37:26 -04:00
Sebastian 6d27b64355 Merge pull request 'refactor: improvemets' (#41) from refactor/improvements into main
Renovate / renovate (push) Successful in 1m57s
Reviewed-on: #41
2026-03-24 23:15:11 +00:00
Sebastian 5254b859d2 refactor: improvemets
Build Test / build (pull_request) Successful in 52s
JS Unit Tests / test (pull_request) Successful in 52s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-24 19:13:19 -04:00
Sebastian bcb3431aaa Merge pull request 'feat: improve mail resource interface' (#40) from feat/improve-mail-resource-interface into main
Renovate / renovate (push) Failing after 1m21s
Reviewed-on: #40
2026-03-07 03:56:11 +00:00
Sebastian e770661859 Merge pull request 'feat: add prefixed route' (#39) from feat/dav-implementation into main
Reviewed-on: #39
2026-03-07 03:56:00 +00:00
Sebastian 1d706de663 feat: improve mail resource interface
JS Unit Tests / test (pull_request) Successful in 17s
Build Test / build (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:50:08 -05:00
Sebastian ce5c5b3746 feat: add prefixed route
JS Unit Tests / test (pull_request) Successful in 19s
Build Test / build (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:48:16 -05:00
Sebastian a533d0dd89 Merge pull request 'fix: user settings' (#38) from fix/user-settings into main
Renovate / renovate (push) Successful in 1m23s
Reviewed-on: #38
2026-03-05 01:35:34 +00:00
Sebastian 3a2739fdd8 fix: user settings
JS Unit Tests / test (pull_request) Successful in 15s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-04 20:35:00 -05:00
Sebastian 24e046792b Merge pull request 'refactor: documents' (#37) from recator/documents into main
Reviewed-on: #37
2026-03-04 03:14:19 +00:00
Sebastian 1f3e87535b refactor: documents
JS Unit Tests / test (pull_request) Successful in 21s
Build Test / build (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 46s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-03 22:13:36 -05:00
Sebastian 85e89dca87 Merge pull request 'refactor: people provider' (#36) from chore/recator-people-provider into main
Renovate / renovate (push) Failing after 1m18s
Reviewed-on: #36
2026-02-25 05:21:52 +00:00
Sebastian e48ee82530 refactor: people provider
JS Unit Tests / test (pull_request) Successful in 16s
Build Test / build (pull_request) Successful in 18s
PHP Unit Tests / test (pull_request) Successful in 56s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-25 00:13:51 -05:00
Sebastian 7799787ffb Merge pull request 'refactor: standardize json types' (#35) from refactor/standardize-types into main
Renovate / renovate (push) Failing after 1m37s
Reviewed-on: #35
2026-02-24 22:41:40 +00:00
Sebastian e996774881 refactor: standardize json types
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 17s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-24 17:40:14 -05:00
Sebastian 2530680c0b Merge pull request 'feat: theming' (#34) from feat/theming into main
Renovate / renovate (push) Failing after 1h14m0s
Reviewed-on: #34
2026-02-23 02:26:03 +00:00
Sebastian b68ac538ce feat: theming
JS Unit Tests / test (pull_request) Successful in 17s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 54s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 21:25:26 -05:00
Sebastian 6975800ce5 Merge pull request 'refactor: drop unused css styles' (#33) from refactor/css-styles into main
Reviewed-on: #33
2026-02-22 22:33:10 +00:00
Sebastian 8be5b0b4ee refactor: drop unused css styles
JS Unit Tests / test (pull_request) Successful in 15s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 17:32:35 -05:00
Sebastian 779249a7e7 Merge pull request 'refactor: module federation' (#32) from refactor/module-federation into main
Reviewed-on: #32
2026-02-22 22:11:56 +00:00
Sebastian 7696c31332 Merge pull request 'refactor: login redirect' (#31) from recator/login-redirect into main
Reviewed-on: #31
2026-02-22 22:11:37 +00:00
Sebastian aa52693dd9 refactor: login redirect
JS Unit Tests / test (pull_request) Successful in 15s
Build Test / build (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 53s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 12:37:27 -05:00
Sebastian bb05f3d20f refactor: module federation
JS Unit Tests / test (pull_request) Successful in 16s
Build Test / build (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 54s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 12:35:56 -05:00
Sebastian 8560aef5e2 Merge pull request 'feat: improve module management' (#30) from feat/improve-module-management into main
Reviewed-on: #30
2026-02-22 05:41:38 +00:00
Sebastian c687bd0795 feat: improve module management
JS Unit Tests / test (pull_request) Successful in 39s
Build Test / build (pull_request) Successful in 44s
PHP Unit Tests / test (pull_request) Successful in 53s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 00:40:38 -05:00
Sebastian f44fd85dcc Merge pull request 'feat/bunch-of-changes' (#29) from feat/bunch-of-changes into main
Renovate / renovate (push) Successful in 1m22s
Reviewed-on: #29
2026-02-21 14:18:57 +00:00
Sebastian d81e894c81 feat: unify kernel entry
JS Unit Tests / test (pull_request) Successful in 38s
Build Test / build (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
Sebastian c310b96a26 fix: message flags
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
Sebastian 8e931f6650 feat: add entity streaming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
Sebastian a576d85d74 improve loggers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
Sebastian c3241862ca Merge pull request 'feat/improve-authentication' (#28) from feat/improve-authentication into main
Renovate / renovate (push) Failing after 1h13m58s
Reviewed-on: #28
2026-02-20 05:04:16 +00:00
Sebastian 99fa707eb3 feat: improve authentication
Build Test / build (pull_request) Successful in 43s
JS Unit Tests / test (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-19 23:03:09 -05:00
Sebastian decda8becc feat: cli tenant support
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-19 00:18:32 -05:00
Sebastian cdbf01111f Merge pull request 'chore: standardize chrono provider' (#27) from chore/standardize-chrono-provider into main
Renovate / renovate (push) Successful in 1m33s
Reviewed-on: #27
2026-02-17 08:15:14 +00:00
Sebastian 18f8f0e1d1 chore: standardize chrono provider
JS Unit Tests / test (pull_request) Successful in 13s
Build Test / build (pull_request) Successful in 17s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-17 03:09:38 -05:00
Sebastian 11014f070d Merge pull request 'chore: revert symfony console version' (#26) from chore/revert-symfony-console into main
Reviewed-on: #26
2026-02-16 00:02:10 +00:00
Sebastian 2b539bc883 chore: revert symfony console version
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 19:01:39 -05:00
Sebastian 3a416f3c0b Merge pull request 'chore: revert phpunit version' (#25) from chore/revert-phpunit-version into main
Reviewed-on: #25
2026-02-15 23:55:41 +00:00
Sebastian 9065ab69f5 chore: revert phpunit version
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 18:55:12 -05:00
444 changed files with 16697 additions and 9641 deletions
@@ -0,0 +1,50 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Set up PHP
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
with:
php-version: '8.5'
tools: composer:v2
extensions: ctype, iconv, mongodb
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Configure server settings
env:
DATABASE_URI: 'mongodb://mongo:27017/?tls=false'
DATABASE_NAME: 'ktrix_ci'
APP_ENVIRONMENT: 'test'
run: php action-server-install/scripts/configure-server.php config/system.php
- name: Run tests
run: composer test:integration
+8 -2
View File
@@ -25,11 +25,17 @@ jobs:
tools: composer:v2 tools: composer:v2
- name: Install Renovate - name: Install Renovate
run: npm install -g renovate run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
- name: Run Renovate - name: Run Renovate
env: env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1 RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: renovate ${{ gitea.repository }} run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+3 -4
View File
@@ -14,11 +14,10 @@ node_modules/
# Backend development # Backend development
/vendor/ /vendor/
/config/system.php
coverage/ coverage/
.phpunit.cache *.cache
.phpunit.coverage .phpunit.coverage
.php-cs-fixer.cache
.phpstan.cache
.phpactor/ .phpactor/
# Editors # Editors
@@ -34,4 +33,4 @@ logs
# Runtime # Runtime
/modules/ /modules/
/storage/ /storage/
/var/ /var/
+6 -95
View File
@@ -3,115 +3,26 @@
/** /**
* Console Entry Point * Console Entry Point
*
* Bootstraps the application container and registers console commands
* from core and modules using lazy loading via Symfony Console.
*/ */
declare(strict_types=1); declare(strict_types=1);
use KTXC\Application; use KTXC\Application;
use KTXC\Kernel;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Command\LazyCommand;
// Check dependencies
if (!is_dir(dirname(__DIR__).'/vendor')) { if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n"); fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1); exit(1);
} }
require_once dirname(__DIR__).'/vendor/autoload.php'; $composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
try { try {
// Bootstrap the application $application = Application::create(dirname(__DIR__), $composerLoader);
$projectRoot = dirname(__DIR__); exit($application->runConsole());
$app = new Application($projectRoot);
// Boot kernel to initialize container and modules
$app->kernel()->boot();
// Get the container
$container = $app->container();
// Create Symfony Console Application
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
// Collect all command classes
$commandClasses = [];
// Collect commands from modules
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$moduleInstance = $module->instance();
// Skip if module instance is not available
if ($moduleInstance === null) {
continue;
}
// Check if module implements console command provider
if ($moduleInstance instanceof ModuleConsoleInterface) {
try {
$commands = $moduleInstance->registerCI();
foreach ($commands as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$commandClasses[] = $commandClass;
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
}
// Register commands using lazy loading
foreach ($commandClasses as $commandClass) {
try {
// Use reflection to read #[AsCommand] attribute without instantiation
$reflection = new \ReflectionClass($commandClass);
$attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class);
if (empty($attributes)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
continue;
}
// Get attribute instance
/** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */
$commandAttr = $attributes[0]->newInstance();
// Create lazy command wrapper that defers instantiation
$lazyCommand = new LazyCommand(
$commandAttr->name,
[],
$commandAttr->description ?? '',
$commandAttr->hidden ?? false,
fn() => $container->get($commandClass) // Only instantiate when executed
);
$console->add($lazyCommand);
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
// Run the console application
$exitCode = $console->run();
exit($exitCode);
} catch (\Throwable $e) { } catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n"); fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (isset($app) && $app->debug()) { if (($application ?? null)?->debug()) {
fwrite(STDERR, $e->getTraceAsString() . "\n"); fwrite(STDERR, $e->getTraceAsString()."\n");
} }
exit(1); exit(1);
} }
+6 -5
View File
@@ -5,16 +5,16 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.3",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"mongodb/mongodb": "^2.1", "mongodb/mongodb": "^2.1",
"php-di/php-di": "*", "php-di/php-di": "*",
"phpseclib/phpseclib": "^3.0", "phpseclib/phpseclib": "^3.0",
"symfony/console": "^8.0" "symfony/console": "^7.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.0" "phpunit/phpunit": "^12.5.31"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@@ -39,7 +39,8 @@
], ],
"post-update-cmd": [ "post-update-cmd": [
], ],
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox", "test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text" "test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
} }
} }
Generated
+289 -395
View File
File diff suppressed because it is too large Load Diff
-40
View File
@@ -1,40 +0,0 @@
<?php
return [
// Application Configuration
'name' => 'Ktrix',
'environment' => 'dev',
'debug' => true,
// Database Configuration
'database' => [
// MongoDB connection URI (include credentials if needed)
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
'database' => 'ktrix',
// optional driver options
'options' => [],
'driverOptions' => [],
],
/**
* Cache Configuration
*
* Set the cache store classes for different cache types.
* Uncomment and adjust the class names as needed.
*
* Available Cache Stores:
* - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc.
* - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc.
* - Blob Cache: Large binary objects storage.
*
* Predefined cache types:
* file - File-based cache store
* redis - Redis-based cache store
* memcached - Memcached-based cache store
*/
//'cache.ephemeral' => 'file',
//'cache.persistent' => 'file',
//'cache.blob' => 'file',
// Security Configuration
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
];
+78
View File
@@ -0,0 +1,78 @@
<?php
return [
// Application Configuration
'name' => 'Application Name',
'environment' => 'dev',
'debug' => true,
// Database Configuration
'database' => [
// MongoDB connection URI (include credentials if needed)
'uri' => 'mongodb://username:password@database-host:27017/?authSource=database&tls=true',
'database' => 'database',
// optional driver options
'options' => [],
'driverOptions' => [],
],
/**
* Cache Configuration
*
* Set the cache store classes for different cache types.
* Uncomment and adjust the class names as needed.
*
* Available Cache Stores:
* - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc.
* - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc.
* - Blob Cache: Large binary objects storage.
*
* Predefined cache types:
* file - File-based cache store
* redis - Redis-based cache store
* memcached - Memcached-based cache store
*/
//'cache.ephemeral' => 'file',
//'cache.persistent' => 'file',
//'cache.blob' => 'file',
// Logging Configuration
'log' => [
// Driver: 'file' | 'systemd' | 'syslog' | 'null'
// file - writes JSONL to a local file (see 'path' / 'channel' below)
// systemd - writes to stderr with <priority> prefix; journald/systemd parses this natively
// syslog - writes via PHP syslog() (see 'ident' / 'facility' below)
// null - discards all log messages (useful in tests / CI)
'driver' => 'file',
// Minimum PSR-3 log level to record.
// Messages below this severity are silently discarded.
// From most to least severe: emergency, alert, critical, error, warning, notice, info, debug
'level' => 'warning',
// Per-tenant log files.
// When true, messages are written to:
// {path}/tenant/{tenantIdentifier}/{channel}.jsonl
// once a tenant session is active. Pre-tenant messages (boot phase,
// unknown domain rejections, etc.) fall through to the global log file.
'per_tenant' => false,
// ── file driver options ────────────────────────────────────────────────
// Absolute path to the log directory. null = <project_root>/var/logs
'path' => null,
// Log channel — used as the filename without extension.
// 'app' → var/logs/app.jsonl (or var/logs/tenant/{id}/app.jsonl when per_tenant = true)
'channel' => 'app',
// ── syslog driver options ──────────────────────────────────────────────
// Identity tag passed to openlog(); visible in syslog and journalctl.
'ident' => 'application',
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
'facility' => LOG_USER,
],
// Security Configuration
// Generate a unique value for each deployment, for example: openssl rand -hex 32
'security.salt' => 'replace-with-a-random-secret',
];
+24
View File
@@ -0,0 +1,24 @@
{
"header": {
"searchPlaceholder": "Hier suchen.."
},
"systemMenu": {
"administration": "Administration",
"applications": "Anwendungen",
"personalSettings": "Persönliche Einstellungen",
"toggleAdmin": "Admin",
"toggleApps": "Apps",
"toggleSettings": "Einstellungen"
},
"notifications": {
"markAllRead": "Alle als gelesen markieren",
"title": "Benachrichtigungen",
"viewAll": "Alle anzeigen"
},
"userMenu": {
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"logout": "Abmelden",
"settings": "Einstellungen"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"systemMenu": {
"adminSettings": "System",
"apps": "Applications",
"personalSettings": "Settings",
"userSettings": "Settings"
},
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"logout": "Logout",
"settings": "Settings",
"systemMode": "System Mode"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"systemMode": "System Mode"
}
}
+102 -164
View File
@@ -1,216 +1,154 @@
<?php <?php
declare(strict_types=1);
namespace KTXC; namespace KTXC;
use Composer\Autoload\ClassLoader;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Runtime\Console\ConsoleRuntime;
use KTXC\Runtime\Http\HttpRuntime;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
/** final class Application
* Application class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Application
{ {
private static $composerLoader = null; private readonly ProjectPaths $paths;
private readonly array $config;
private Kernel $kernel; private readonly Kernel $kernel;
private array $config; private readonly HttpRuntime $http;
private string $rootDir; private readonly ConsoleRuntime $console;
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null) public function __construct(
{ string $projectDir,
$this->rootDir = $this->resolveProjectRoot($rootDir); ?ClassLoader $composerLoader = null,
?string $environment = null,
// Load configuration ?bool $debug = null,
) {
$this->paths = ProjectPaths::resolve($projectDir);
$this->config = $this->loadConfig(); $this->config = $this->loadConfig();
// Determine environment and debug mode $environment ??= $this->config['environment'] ?? 'prod';
$environment = $environment ?? $this->config['environment'] ?? 'prod'; $debug ??= (bool) ($this->config['debug'] ?? false);
$debug = $debug ?? $this->config['debug'] ?? false;
$this->kernel = new Kernel(
// Create kernel with configuration new KernelOptions(
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir); $this->paths,
$environment,
$debug,
),
$this->config,
);
(new ModuleAutoloader(
$this->moduleDir(),
$composerLoader,
))->register();
$this->http = new HttpRuntime($this->kernel, $debug);
$this->console = new ConsoleRuntime($this->kernel);
} }
/** public static function create(
* Run the application - handle incoming request and send response string $projectDir,
*/ ?ClassLoader $composerLoader = null,
public function run(): void ?string $environment = null,
?bool $debug = null,
): self {
return new self($projectDir, $composerLoader, $environment, $debug);
}
public function runHttp(): void
{ {
try { try {
$request = Request::createFromGlobals(); $this->http->run();
$response = $this->handle($request); } finally {
$response->send(); $this->kernel->shutdown();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
} }
} }
/** public function runHttpRequest(Request $request): Response
* Handle a request
*/
public function handle(Request $request): Response
{ {
return $this->kernel->handle($request); return $this->http->run($request, send: false);
} }
/** public function runConsole(): int
* Terminate the application - process deferred events
*/
public function terminate(): void
{ {
$this->kernel->processEvents(); try {
return $this->console->run();
} finally {
$this->kernel->shutdown();
}
} }
/** public function shutdown(): void
* Get the kernel instance {
*/ $this->kernel->shutdown();
public function kernel(): Kernel }
public function kernel(): KernelInterface
{ {
return $this->kernel; return $this->kernel;
} }
/**
* Get the container instance
*/
public function container(): ContainerInterface public function container(): ContainerInterface
{ {
return $this->kernel->container(); return $this->kernel->container();
} }
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string public function environment(): string
{ {
return $this->kernel->environment(); return $this->kernel->environment();
} }
/**
* Check if debug mode is enabled
*/
public function debug(): bool public function debug(): bool
{ {
return $this->kernel->debug(); return $this->kernel->debug();
} }
/** public function projectDir(): string
* Load configuration from config directory
*/
protected function loadConfig(): array
{ {
$configFile = $this->rootDir . '/config/system.php'; return $this->paths->project;
}
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile); public function moduleDir(): string
{
return $this->paths->modules();
}
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
$value = $this->config;
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
private function loadConfig(): array
{
$path = $this->paths->configuration() . '/system.php';
if (!is_file($path)) {
return []; return [];
} }
$config = include $configFile; $config = require $path;
if (!is_array($config)) { if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array'); throw new \RuntimeException("Configuration file must return an array: {$path}");
} }
return $config; return $config;
} }
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
} }
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionContext
{
public function __construct(
public RuntimeType $runtime,
public string $executionId,
public string $correlationId,
public ?string $causationId = null,
public ?string $requestId = null,
public ?string $operationName = null,
public array $traceMetadata = [],
) {
}
public static function fromDescriptor(ExecutionDescriptor $descriptor): self
{
return new self(
$descriptor->runtime,
$descriptor->executionId,
$descriptor->correlationId,
operationName: $descriptor->operationName,
);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionDescriptor
{
public function __construct(
public RuntimeType $runtime,
public string $executionId,
public string $correlationId,
public ?string $operationName = null,
) {
}
public static function http(?string $operationName = null): self
{
$id = self::id();
return new self(RuntimeType::HTTP, $id, $id, $operationName);
}
public static function cli(?string $operationName = null): self
{
$id = self::id();
return new self(RuntimeType::CLI, $id, $id, $operationName);
}
private static function id(): string
{
return bin2hex(random_bytes(16));
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionOutcome
{
private function __construct(
public bool $successful,
public mixed $result = null,
public ?\Throwable $error = null,
) {
}
public static function success(mixed $result = null): self
{
return new self(true, $result);
}
public static function failure(\Throwable $error, mixed $result = null): self
{
return new self(false, $result, $error);
}
public static function incomplete(): self
{
return new self(false);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
use KTXC\KernelInterface;
final readonly class ExecutionRunner implements ExecutionRunnerInterface
{
public function __construct(
private KernelInterface $kernel,
) {
}
public function execute(
ExecutionDescriptor $descriptor,
callable $execution,
?callable $failure = null,
): mixed {
$scope = $this->kernel->beginExecution($descriptor);
$outcome = ExecutionOutcome::incomplete();
try {
$result = $execution($scope);
$outcome = ExecutionOutcome::success($result);
return $result;
} catch (\Throwable $error) {
if ($failure === null) {
$outcome = ExecutionOutcome::failure($error);
throw $error;
}
try {
$result = $failure($error, $scope);
$outcome = ExecutionOutcome::failure($error, $result);
return $result;
} catch (\Throwable $failureError) {
$outcome = ExecutionOutcome::failure($failureError);
throw $failureError;
}
} finally {
$this->kernel->terminateExecution($scope, $outcome);
}
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
interface ExecutionRunnerInterface
{
/**
* @template TResult
*
* @param callable(ExecutionScope): TResult $execution
* @param null|callable(\Throwable, ExecutionScope): TResult $failure
*
* @return TResult
*/
public function execute(
ExecutionDescriptor $descriptor,
callable $execution,
?callable $failure = null,
): mixed;
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
final class ExecutionScope
{
private bool $terminated = false;
public function __construct(
public readonly ExecutionDescriptor $descriptor,
public readonly ExecutionContext $context,
private readonly TenantContext $tenantContext,
private readonly IdentityContext $identityContext,
) {
$this->tenantContext->clear();
$this->identityContext->clear();
}
public function markTerminated(): void
{
if ($this->terminated) {
throw new \LogicException('The execution scope has already been terminated.');
}
$this->terminated = true;
}
public function terminated(): bool
{
return $this->terminated;
}
public function dispose(): void
{
$this->identityContext->clear();
$this->tenantContext->clear();
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
enum RuntimeType: string
{
case HTTP = 'http';
case CLI = 'cli';
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class TerminationReport
{
/**
* @param list<\Throwable> $failures
*/
public function __construct(
public int $deferredProcessed = 0,
public int $deferredRemaining = 0,
public array $failures = [],
public bool $deadlineExceeded = false,
public bool $limitExceeded = false,
) {
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class KernelOptions
{
public function __construct(
public ProjectPaths $paths,
public string $environment = 'prod',
public bool $debug = false,
) {
if ($this->environment === '') {
throw new \InvalidArgumentException('Kernel environment cannot be empty.');
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class ProjectPaths
{
private function __construct(
public string $project,
) {
}
public static function resolve(string $start): self
{
$directory = is_file($start) ? dirname($start) : $start;
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
while ($directory !== dirname($directory)) {
if (is_file($directory . '/composer.json')) {
return new self(realpath($directory) ?: $directory);
}
$directory = dirname($directory);
}
throw new \InvalidArgumentException("Unable to resolve project root from {$start}.");
}
public function configuration(): string
{
return $this->project . '/config';
}
public function modules(): string
{
return $this->project . '/modules';
}
public function cache(string $environment): string
{
return $this->project . '/var/cache/' . $environment;
}
public function logs(): string
{
return $this->project . '/var/log';
}
public function runtime(): string
{
return $this->project . '/var';
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Event;
use KTXF\Event\EventListenerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'events:debug',
description: 'Display the frozen event listener registry',
)]
final class EventsDebugCommand extends Command
{
public function __construct(
private readonly EventListenerRegistry $registry,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$rows = [];
foreach ($this->registry->definitions() as $listener) {
$rows[] = [
$listener->module,
$listener->event,
$listener->service . '::' . $listener->method,
$listener->delivery->value,
$listener->priority,
$listener->failurePolicy->value,
];
}
$io->table(
['Module', 'Event', 'Listener', 'Delivery', 'Priority', 'Failure'],
$rows,
);
return Command::SUCCESS;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace KTXC\Console; namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -54,8 +54,7 @@ class ModuleDisableCommand extends Command
} }
// Find the module // Find the module
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false); $module = $this->moduleManager->fetch($handle);
$module = $modules[$handle] ?? null;
if (!$module) { if (!$module) {
$io->error("Module '{$handle}' not found or not installed."); $io->error("Module '{$handle}' not found or not installed.");
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace KTXC\Console; namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -48,8 +48,7 @@ class ModuleEnableCommand extends Command
try { try {
// Find the module // Find the module
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false); $module = $this->moduleManager->fetch($handle);
$module = $modules[$handle] ?? null;
if (!$module) { if (!$module) {
$io->error("Module '{$handle}' not found or not installed."); $io->error("Module '{$handle}' not found or not installed.");
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Module Install Command
*
* Installs a module from the filesystem.
*/
#[AsCommand(
name: 'module:install',
description: 'Install a module',
)]
class ModuleInstallCommand extends Command
{
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('handle', InputArgument::REQUIRED, 'Module handle to install')
->setHelp('This command installs a module from the filesystem.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = $input->getArgument('handle');
$io->title('Install Module');
try {
// Prevent installing core module
if ($handle === 'core') {
$io->error('Cannot install the core module.');
return Command::FAILURE;
}
// Check if the module is already installed
$module = $this->moduleManager->fetch($handle);
if ($module) {
$io->warning("Module '{$handle}' is already installed.");
return Command::SUCCESS;
}
// Install the module
$io->text("Installing module '{$handle}'...");
$this->moduleManager->install($handle);
$this->logger->info('Module installed via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->success("Module '{$handle}' installed successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to install module: ' . $e->getMessage());
$this->logger->error('Module install failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace KTXC\Console; namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -45,10 +45,7 @@ class ModuleListCommand extends Command
$io->title('Installed Modules'); $io->title('Installed Modules');
try { try {
$modules = $this->moduleManager->list( $modules = $this->moduleManager->list();
installedOnly: true,
enabledOnly: !$showAll
);
if (count($modules) === 0) { if (count($modules) === 0) {
$io->warning('No modules found.'); $io->warning('No modules found.');
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Module Uninstall Command
*
* Uninstalls an installed module.
*/
#[AsCommand(
name: 'module:uninstall',
description: 'Uninstall a module',
)]
class ModuleUninstallCommand extends Command
{
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('handle', InputArgument::REQUIRED, 'Module handle to uninstall')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command uninstalls an installed module.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = $input->getArgument('handle');
$force = $input->getOption('force');
$io->title('Uninstall Module');
try {
// Prevent uninstalling core module
if ($handle === 'core') {
$io->error('Cannot uninstall the core module.');
return Command::FAILURE;
}
// Find the module
$module = $this->moduleManager->fetch($handle);
if (!$module) {
$io->error("Module '{$handle}' not found or not installed.");
return Command::FAILURE;
}
// Confirm unless --force is passed
if (!$force && !$io->confirm("Are you sure you want to uninstall module '{$handle}'?", false)) {
$io->text('Uninstall cancelled.');
return Command::SUCCESS;
}
// Uninstall the module
$io->text("Uninstalling module '{$handle}'...");
$this->moduleManager->uninstall($handle);
$this->logger->info('Module uninstalled via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->success("Module '{$handle}' uninstalled successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to uninstall module: ' . $e->getMessage());
$this->logger->error('Module uninstall failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Module;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Module Upgrade Command
*
* Upgrades an installed module to its latest version.
*/
#[AsCommand(
name: 'module:upgrade',
description: 'Upgrade a module',
)]
class ModuleUpgradeCommand extends Command
{
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('handle', InputArgument::OPTIONAL, 'Module handle to upgrade (omit to upgrade all)')
->addOption('all', 'a', InputOption::VALUE_NONE, 'Upgrade all modules that need upgrading')
->setHelp('This command upgrades an installed module. Use --all to upgrade all modules that need upgrading.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = $input->getArgument('handle');
$all = $input->getOption('all');
$io->title('Upgrade Module');
try {
if ($all || !$handle) {
return $this->upgradeAll($io);
}
return $this->upgradeOne($io, $handle);
} catch (\Throwable $e) {
$io->error('Failed to upgrade module: ' . $e->getMessage());
$this->logger->error('Module upgrade failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
private function upgradeOne(SymfonyStyle $io, string $handle): int
{
// Find the module
$module = $this->moduleManager->fetch($handle);
if (!$module) {
$io->error("Module '{$handle}' not found or not installed.");
return Command::FAILURE;
}
if (!$module->needsUpgrade()) {
$io->success("Module '{$handle}' is already up to date.");
return Command::SUCCESS;
}
$io->text("Upgrading module '{$handle}'...");
$this->moduleManager->upgrade($handle);
$this->logger->info('Module upgraded via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->success("Module '{$handle}' upgraded successfully!");
return Command::SUCCESS;
}
private function upgradeAll(SymfonyStyle $io): int
{
$modules = $this->moduleManager->list();
$pending = [];
foreach ($modules as $module) {
if ($module->needsUpgrade()) {
$pending[] = $module->handle();
}
}
if (count($pending) === 0) {
$io->success('All modules are up to date.');
return Command::SUCCESS;
}
$io->text(sprintf('Found %d module(s) to upgrade: %s', count($pending), implode(', ', $pending)));
$failed = [];
foreach ($pending as $handle) {
try {
$io->text("Upgrading module '{$handle}'...");
$this->moduleManager->upgrade($handle);
$this->logger->info('Module upgraded via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->text("<fg=green>✓ '{$handle}' upgraded.</>");
} catch (\Throwable $e) {
$failed[] = $handle;
$io->text("<fg=red>✗ '{$handle}' failed: {$e->getMessage()}</>");
$this->logger->error('Module upgrade failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
}
}
if (count($failed) > 0) {
$io->error(sprintf('Failed to upgrade %d module(s): %s', count($failed), implode(', ', $failed)));
return Command::FAILURE;
}
$io->success(sprintf('Successfully upgraded %d module(s).', count($pending)));
return Command::SUCCESS;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Assign Command
*
* Assigns a role to a user account.
*/
#[AsCommand(
name: 'role:assign',
description: 'Assign a role to a user',
)]
class RoleAssignCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to assign')
->setHelp('This command assigns a role to a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Assign Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (in_array($rid, $roles, true)) {
$io->text("User '{$identity}' already has role '{$rid}'.");
return Command::SUCCESS;
}
$roles[] = $rid;
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => array_values($roles)])) {
$io->error("Failed to assign role '{$rid}' to '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role assigned via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' assigned to '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to assign role: ' . $e->getMessage());
$this->logger->error('Role assign failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Create Command
*
* Creates a new role within a tenant.
*/
#[AsCommand(
name: 'role:create',
description: 'Create a new role in a tenant',
)]
class RoleCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('label', InputArgument::REQUIRED, 'Display label for the role')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Role description', '')
->addOption('permission', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) to grant (repeatable, e.g. --permission "*")', [])
->addOption('rid', null, InputOption::VALUE_REQUIRED, 'Explicit role id (defaults to a generated UUID)')
->addOption('system', null, InputOption::VALUE_NONE, 'Mark the role as a protected system role (cannot be updated or deleted)')
->setHelp('This command creates a new role in a tenant. Permissions are free-form strings matched by the runtime permission checker, including wildcard suffixes like "user_manager.role.*" or the full wildcard "*".')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$label = $input->getArgument('label');
$rid = $input->getOption('rid');
$io->title('Create Role');
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
if ($rid !== null && $this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roleData = [
'label' => $label,
'description' => $input->getOption('description') ?? '',
'permissions' => $input->getOption('permission'),
'system' => $input->getOption('system'),
];
if ($rid !== null) {
$roleData['rid'] = $rid;
}
$role = $this->rolesStore->createRole($tenant, $roleData);
$this->logger->info('Role created via console', [
'tenant' => $tenant,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->success("Role '{$label}' created successfully!");
$io->definitionList(
['Rid' => $role['rid'] ?? ''],
['Label' => $role['label'] ?? ''],
['System' => !empty($role['system']) ? 'yes' : 'no'],
['Permissions' => implode(', ', (array)($role['permissions'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create role: ' . $e->getMessage());
$this->logger->error('Role create failed', [
'tenant' => $tenant,
'label' => $label,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Delete Command
*
* Deletes a role from a tenant.
*/
#[AsCommand(
name: 'role:delete',
description: 'Delete a role from a tenant',
)]
class RoleDeleteCommand extends Command
{
public function __construct(
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('rid', InputArgument::REQUIRED, 'Role id')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a role from a tenant. System roles cannot be deleted, and roles still assigned to users cannot be deleted.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$rid = $input->getArgument('rid');
$force = $input->getOption('force');
$io->title('Delete Role');
try {
$role = $this->rolesStore->fetchByRid($tenant, $rid);
if (!$role) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if ($role['system'] ?? false) {
$io->error("Role '{$rid}' is a system role and cannot be deleted.");
return Command::FAILURE;
}
$userCount = $this->rolesStore->countUsersInRole($tenant, $rid);
if ($userCount > 0) {
$io->error("Role '{$rid}' is assigned to {$userCount} user(s) and cannot be deleted.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete role '{$rid}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->rolesStore->deleteRole($tenant, $rid)) {
$io->error("Failed to delete role '{$rid}'.");
return Command::FAILURE;
}
$this->logger->info('Role deleted via console', [
'tenant' => $tenant,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete role: ' . $e->getMessage());
$this->logger->error('Role delete failed', [
'tenant' => $tenant,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role List Command
*
* Lists all roles in a tenant.
*/
#[AsCommand(
name: 'role:list',
description: 'List roles in a tenant',
)]
class RoleListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all roles in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Roles in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$roles = $this->rolesStore->listRoles($tenant);
if (empty($roles)) {
$io->text('No roles found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($roles as $role) {
$rows[] = [
$role['rid'] ?? '',
$role['label'] ?? '',
!empty($role['system']) ? 'yes' : 'no',
implode(', ', (array)($role['permissions'] ?? [])),
];
}
$io->table(['Rid', 'Label', 'System', 'Permissions'], $rows);
$io->text(sprintf('Total: %d role(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list roles: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Revoke Command
*
* Revokes a role from a user account.
*/
#[AsCommand(
name: 'role:revoke',
description: 'Revoke a role from a user',
)]
class RoleRevokeCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to revoke')
->setHelp('This command revokes a role from a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Revoke Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (!in_array($rid, $roles, true)) {
$io->text("User '{$identity}' does not have role '{$rid}'.");
return Command::SUCCESS;
}
$roles = array_values(array_filter($roles, fn($r) => $r !== $rid));
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => $roles])) {
$io->error("Failed to revoke role '{$rid}' from '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role revoked via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' revoked from '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to revoke role: ' . $e->getMessage());
$this->logger->error('Role revoke failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Auth Enable Command
*
* Enables an authentication provider for a tenant's login flow.
*/
#[AsCommand(
name: 'tenant:auth:enable',
description: 'Enable an authentication provider for a tenant',
)]
class TenantAuthEnableCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly ProviderManager $providerManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('provider', InputArgument::REQUIRED, 'Authentication provider id (e.g. password, oidc, totp)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label shown for this method during login')
->setHelp('This command enables an authentication provider for a tenant. The provider\'s module must already be installed and enabled.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenantIdentifier = $input->getArgument('tenant');
$providerId = $input->getArgument('provider');
$io->title('Enable Tenant Authentication Provider');
try {
$tenant = $this->tenantService->fetchById($tenantIdentifier);
if (!$tenant) {
$io->error("Tenant '{$tenantIdentifier}' not found.");
return Command::FAILURE;
}
if (!$this->providerManager->resolve('authentication', $providerId)) {
$io->error("Authentication provider '{$providerId}' is not registered. Is its module installed and enabled?");
return Command::FAILURE;
}
$authentication = $tenant->getConfiguration()->authentication();
$providers = $authentication->providers();
$providers[$providerId] = array_filter([
'enabled' => true,
'label' => $input->getOption('label'),
], fn($value) => $value !== null);
$authentication->jsonDeserialize(['providers' => $providers]);
$this->tenantService->deposit($tenant);
$this->logger->info('Tenant authentication provider enabled via console', [
'tenant' => $tenantIdentifier,
'provider' => $providerId,
'command' => $this->getName(),
]);
$io->success("Authentication provider '{$providerId}' enabled for tenant '{$tenantIdentifier}'.");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to enable authentication provider: ' . $e->getMessage());
$this->logger->error('Tenant auth provider enable failed', [
'tenant' => $tenantIdentifier,
'provider' => $providerId,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use KTXF\Utile\UUID;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Create Command
*
* Creates a new tenant.
*/
#[AsCommand(
name: 'tenant:create',
description: 'Create a new tenant',
)]
class TenantCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore,
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('domain', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Domain(s) served by this tenant')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the tenant')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Tenant description')
->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Explicit tenant identifier (defaults to a generated UUID)')
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the tenant in a disabled state')
->addOption('admin-identity', null, InputOption::VALUE_REQUIRED, 'Identity for the bootstrap admin user', 'admin')
->addOption('no-admin-user', null, InputOption::VALUE_NONE, 'Do not create a bootstrap admin user (the admin role is still seeded)')
->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided. An "admin" role with full permissions is seeded automatically, along with a bootstrap admin user unless --no-admin-user is passed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $input->getOption('identifier') ?? UUID::v4();
$domains = array_values(array_unique(array_filter(array_map('trim', $input->getArgument('domain')))));
$io->title('Create Tenant');
try {
if (empty($domains)) {
$io->error('At least one non-empty domain is required.');
return Command::FAILURE;
}
// Ensure identifier is unique
if ($this->tenantService->fetchById($identifier)) {
$io->error("Tenant '{$identifier}' already exists.");
return Command::FAILURE;
}
// Ensure domains are not claimed by another tenant
foreach ($domains as $domain) {
$existing = $this->tenantService->fetchByDomain($domain);
if ($existing) {
$io->error("Domain '{$domain}' is already assigned to tenant '{$existing->getIdentifier()}'.");
return Command::FAILURE;
}
}
$tenant = new TenantObject();
$tenant->setIdentifier($identifier);
$tenant->setEnabled(!$input->getOption('disabled'));
$tenant->setLabel($input->getOption('label') ?? $domains[0]);
$tenant->setDescription($input->getOption('description') ?? '');
$tenant->setDomains(new DomainCollection($domains));
$tenant->setConfiguration(new TenantConfiguration());
$tenant = $this->tenantService->deposit($tenant);
if (!$tenant) {
$io->error('Failed to create tenant.');
return Command::FAILURE;
}
$this->logger->info('Tenant created via console', [
'identifier' => $identifier,
'command' => $this->getName(),
]);
$io->success("Tenant '{$identifier}' created successfully!");
$io->definitionList(
['Id' => $tenant->getId()],
['Identifier' => $tenant->getIdentifier()],
['Label' => $tenant->getLabel()],
['Enabled' => $tenant->getEnabled() ? 'yes' : 'no'],
['Domains' => implode(', ', $domains)],
);
$role = $this->rolesStore->createRole($identifier, [
'rid' => 'admin',
'label' => 'Administrator',
'description' => 'Full access to all tenant features',
'permissions' => ['*'],
'system' => true,
]);
$this->logger->info('Default admin role seeded via console', [
'tenant' => $identifier,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->text("Default role 'admin' seeded with full permissions.");
if (!$input->getOption('no-admin-user')) {
$adminIdentity = $input->getOption('admin-identity');
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
} else {
$this->userStore->createUser($identifier, [
'identity' => $adminIdentity,
'label' => 'Administrator',
'enabled' => true,
'roles' => ['admin'],
'profile' => [],
'settings' => [],
'provider' => null,
'provider_subject' => null,
'provider_managed_fields' => [],
]);
$this->logger->info('Bootstrap admin user created via console', [
'tenant' => $identifier,
'identity' => $adminIdentity,
'command' => $this->getName(),
]);
$io->text("Admin user '{$adminIdentity}' created with the 'admin' role.");
$io->note("Set a credential for this account using the auth provider module you have installed, e.g.: php bin/console user:password {$identifier} {$adminIdentity}");
}
}
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create tenant: ' . $e->getMessage());
$this->logger->error('Tenant create failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Delete Command
*
* Deletes an existing tenant.
*/
#[AsCommand(
name: 'tenant:delete',
description: 'Delete a tenant',
)]
class TenantDeleteCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('identifier', InputArgument::REQUIRED, 'Tenant identifier to delete')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a tenant. Deletion must be confirmed by typing the tenant\'s primary domain. User accounts and module data belonging to the tenant are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $input->getArgument('identifier');
$force = $input->getOption('force');
$io->title('Delete Tenant');
try {
$tenant = $this->tenantService->fetchById($identifier);
if (!$tenant) {
$io->error("Tenant '{$identifier}' not found.");
return Command::FAILURE;
}
if (!$force) {
// Confirm by typing the tenant's primary domain (identifier for
// legacy tenants without domains)
$domains = $tenant->getDomains()?->getArrayCopy() ?? [];
$confirmation = $domains[0] ?? $identifier;
$io->definitionList(
['Identifier' => $tenant->getIdentifier()],
['Label' => $tenant->getLabel()],
['Domains' => implode(', ', $domains)],
);
$answer = $io->ask("Type '{$confirmation}' to confirm deletion");
if ($answer !== $confirmation) {
$io->error('Confirmation did not match. Delete cancelled.');
return Command::FAILURE;
}
}
$this->tenantService->destroy($tenant);
$this->logger->info('Tenant deleted via console', [
'identifier' => $identifier,
'command' => $this->getName(),
]);
$io->success("Tenant '{$identifier}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete tenant: ' . $e->getMessage());
$this->logger->error('Tenant delete failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant List Command
*
* Lists all tenants.
*/
#[AsCommand(
name: 'tenant:list',
description: 'List all tenants',
)]
class TenantListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('This command lists all tenants.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Tenants');
try {
$tenants = $this->tenantService->list();
if (empty($tenants)) {
$io->text('No tenants found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($tenants as $tenant) {
$domains = $tenant->getDomains();
$rows[] = [
$tenant->getIdentifier(),
$tenant->getLabel(),
$tenant->getEnabled() ? 'yes' : 'no',
$domains ? implode(', ', $domains->getArrayCopy()) : '',
];
}
$io->table(['Identifier', 'Label', 'Enabled', 'Domains'], $rows);
$io->text(sprintf('Total: %d tenant(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list tenants: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Create Command
*
* Creates a new user account within a tenant.
*/
#[AsCommand(
name: 'user:create',
description: 'Create a new user account in a tenant',
)]
class UserCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the user')
->addOption('role', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Role id(s) to assign', [])
->addOption('uid', null, InputOption::VALUE_REQUIRED, 'Explicit user id (defaults to a generated UUID)')
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the account in a disabled state')
->setHelp('This command creates a new user account in a tenant. Authentication credentials are managed separately by authentication provider modules.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$roles = $input->getOption('role');
$io->title('Create User');
try {
// Ensure the tenant exists
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
// Ensure identity is unique within the tenant
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
$io->error("User '{$identity}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
// Ensure assigned roles exist
foreach ($roles as $role) {
if (!$this->rolesStore->fetchByRid($tenant, $role)) {
$io->error("Role '{$role}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
}
$userData = [
'identity' => $identity,
'label' => $input->getOption('label') ?? $identity,
'enabled' => !$input->getOption('disabled'),
'roles' => $roles,
'profile' => [],
'settings' => [],
'provider' => null,
'provider_subject' => null,
'provider_managed_fields' => [],
];
if ($input->getOption('uid')) {
$userData['uid'] = $input->getOption('uid');
}
$user = $this->userStore->createUser($tenant, $userData);
$this->logger->info('User created via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'] ?? null,
'command' => $this->getName(),
]);
$io->success("User '{$identity}' created successfully!");
$io->definitionList(
['Uid' => $user['uid'] ?? ''],
['Identity' => $user['identity'] ?? ''],
['Label' => $user['label'] ?? ''],
['Enabled' => !empty($user['enabled']) ? 'yes' : 'no'],
['Roles' => implode(', ', (array)($user['roles'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create user: ' . $e->getMessage());
$this->logger->error('User create failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Delete Command
*
* Deletes a user account from a tenant.
*/
#[AsCommand(
name: 'user:delete',
description: 'Delete a user account from a tenant',
)]
class UserDeleteCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a user account from a tenant. Credentials stored by authentication provider modules are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$force = $input->getOption('force');
$io->title('Delete User');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete user '{$identity}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
$io->error("Failed to delete user '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('User deleted via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'],
'command' => $this->getName(),
]);
$io->success("User '{$identity}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete user: ' . $e->getMessage());
$this->logger->error('User delete failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User List Command
*
* Lists all user accounts in a tenant.
*/
#[AsCommand(
name: 'user:list',
description: 'List user accounts in a tenant',
)]
class UserListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all user accounts in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Users in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$users = $this->userStore->listUsers($tenant);
if (empty($users)) {
$io->text('No users found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($users as $user) {
$rows[] = [
$user['uid'] ?? '',
$user['identity'] ?? '',
$user['label'] ?? '',
!empty($user['enabled']) ? 'yes' : 'no',
implode(', ', (array)($user['roles'] ?? [])),
];
}
$io->table(['Uid', 'Identity', 'Label', 'Enabled', 'Roles'], $rows);
$io->text(sprintf('Total: %d user(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list users: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
final class IdentityContext implements IdentityContextInterface
{
private ?User $identity = null;
public function initialize(User $identity): void
{
if ($this->identity !== null) {
throw new \LogicException('The execution identity has already been initialized.');
}
$this->identity = $identity;
}
public function clear(): void
{
$this->identity = null;
}
public function present(): bool
{
return $this->identity !== null;
}
public function identity(): ?User
{
return $this->identity;
}
public function identifier(): ?string
{
return $this->identity?->getId();
}
public function requireIdentifier(): string
{
return $this->identifier()
?? throw new \LogicException('This operation requires an identity context.');
}
public function label(): ?string
{
return $this->identity?->getLabel();
}
public function mailAddress(): ?string
{
return $this->identity?->getIdentity();
}
public function nameFirst(): ?string
{
return null;
}
public function nameLast(): ?string
{
return null;
}
public function permissions(): array
{
return $this->identity?->getPermissions() ?? [];
}
public function roles(): array
{
return $this->identity?->getRoles() ?? [];
}
public function hasPermission(string $permission): bool
{
$permissions = $this->permissions();
if (in_array($permission, $permissions, true) || in_array('*', $permissions, true)) {
return true;
}
foreach ($permissions as $userPermission) {
if (str_ends_with($userPermission, '.*')) {
$prefix = substr($userPermission, 0, -2);
if (str_starts_with($permission, $prefix . '.')) {
return true;
}
}
}
return false;
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles(), true);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
interface IdentityContextInterface
{
public function present(): bool;
public function identity(): ?User;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function mailAddress(): ?string;
public function nameFirst(): ?string;
public function nameLast(): ?string;
public function permissions(): array;
public function roles(): array;
public function hasPermission(string $permission): bool;
public function hasRole(string $role): bool;
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
final class TenantContext implements TenantContextInterface
{
private ?TenantObject $tenant = null;
private ?string $domain = null;
public function __construct(
private readonly TenantService $tenantService,
) {
}
public function resolveDomain(string $domain): bool
{
$this->clear();
$tenant = $this->tenantService->fetchByDomain($domain);
if ($tenant === null) {
return false;
}
$this->domain = $domain;
$this->tenant = $tenant;
return true;
}
public function resolveIdentifier(string $identifier): bool
{
$this->clear();
$tenant = $this->tenantService->fetchById($identifier);
if ($tenant === null) {
return false;
}
$this->domain = $identifier;
$this->tenant = $tenant;
return true;
}
public function clear(): void
{
$this->tenant = null;
$this->domain = null;
}
public function present(): bool
{
return $this->tenant !== null;
}
public function configured(): bool
{
return $this->present();
}
public function enabled(): bool
{
return $this->tenant?->getEnabled() ?? false;
}
public function domain(): ?string
{
return $this->domain;
}
public function identifier(): ?string
{
return $this->tenant?->getIdentifier();
}
public function requireIdentifier(): string
{
return $this->identifier()
?? throw new \LogicException('This operation requires a tenant context.');
}
public function label(): ?string
{
return $this->tenant?->getLabel();
}
public function configuration(): ?TenantConfiguration
{
return $this->tenant?->getConfiguration();
}
public function settings(): array
{
return $this->tenant?->getSettings() ?? [];
}
public function identityProviders(): array
{
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
}
public function identityProviderConfig(string $providerId): ?array
{
return $this->identityProviders()[$providerId] ?? null;
}
public function isIdentityProviderEnabled(string $providerId): bool
{
$config = $this->identityProviderConfig($providerId);
return $config !== null && ($config['enabled'] ?? false);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
interface TenantContextInterface
{
public function present(): bool;
public function configured(): bool;
public function enabled(): bool;
public function domain(): ?string;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function configuration(): ?TenantConfiguration;
public function settings(): array;
public function identityProviders(): array;
public function identityProviderConfig(string $providerId): ?array;
public function isIdentityProviderEnabled(string $providerId): bool;
}
@@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract
return $this->clearTokenCookies($httpResponse); return $this->clearTokenCookies($httpResponse);
} }
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']); $httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed', 'expires_in' => 900]);
if ($response->tokens && isset($response->tokens['access'])) { if ($response->tokens && isset($response->tokens['access'])) {
$httpResponse->headers->setCookie( $httpResponse->headers->setCookie(
@@ -242,6 +242,15 @@ class AuthenticationController extends ControllerAbstract
return $httpResponse; return $httpResponse;
} }
/**
* Session health check
*/
#[AuthenticatedRoute('/auth/ping', name: 'auth.ping', methods: ['GET'])]
public function ping(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
}
/** /**
* Logout current device * Logout current device
*/ */
@@ -281,14 +290,16 @@ class AuthenticationController extends ControllerAbstract
*/ */
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
{ {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); $data = $response->toArray();
// Set token cookies if present // Set token cookies and expose expires_in if present
if ($response->hasTokens()) { if ($response->hasTokens()) {
$data['expires_in'] = 900;
$httpResponse = new JsonResponse($data, $response->httpStatus);
return $this->setTokenCookies($httpResponse, $response->tokens, true); return $this->setTokenCookies($httpResponse, $response->tokens, true);
} }
return $httpResponse; return new JsonResponse($data, $response->httpStatus);
} }
/** /**
+9 -9
View File
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute; use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService; use KTXC\Service\SecurityService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract class DefaultController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SecurityService $securityService, private readonly SecurityService $securityService,
private readonly SessionIdentity $identity, private readonly IdentityContextInterface $identityContext,
#[Inject('rootDir')] private readonly string $rootDir, #[Inject('rootDir')] private readonly string $rootDir,
) {} ) {}
@@ -25,11 +25,11 @@ class DefaultController extends ControllerAbstract
public function home(Request $request): Response public function home(Request $request): Response
{ {
// If an authenticated identity is available, serve the private app // If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) { if ($this->identityContext->identifier()) {
return new FileResponse( return new FileResponse(
$this->rootDir . '/public/private.html', $this->rootDir . '/public/private.html',
Response::HTTP_OK, Response::HTTP_OK,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
); );
} }
@@ -38,7 +38,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse( $response = new FileResponse(
$this->rootDir . '/public/public.html', $this->rootDir . '/public/public.html',
Response::HTTP_OK, Response::HTTP_OK,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
); );
// Clear any stale auth cookies since the user is not authenticated // Clear any stale auth cookies since the user is not authenticated
@@ -58,7 +58,7 @@ class DefaultController extends ControllerAbstract
return new FileResponse( return new FileResponse(
$this->rootDir . '/public/public.html', $this->rootDir . '/public/public.html',
Response::HTTP_OK, Response::HTTP_OK,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
); );
} }
@@ -116,11 +116,11 @@ class DefaultController extends ControllerAbstract
public function catchAll(Request $request, string $path = ''): Response public function catchAll(Request $request, string $path = ''): Response
{ {
// If an authenticated identity is available, serve the private app // If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) { if ($this->identityContext->identifier()) {
return new FileResponse( return new FileResponse(
$this->rootDir . '/public/private.html', $this->rootDir . '/public/private.html',
Response::HTTP_OK, Response::HTTP_OK,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
); );
} }
@@ -128,7 +128,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse( $response = new FileResponse(
$this->rootDir . '/public/public.html', $this->rootDir . '/public/public.html',
Response::HTTP_OK, Response::HTTP_OK,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
); );
// Clear any stale auth cookies since the user is not authenticated // Clear any stale auth cookies since the user is not authenticated
+26 -17
View File
@@ -2,34 +2,36 @@
namespace KTXC\Controllers; namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\L10N\LocaleResolver;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use KTXC\Security\Authorization\PermissionChecker; use KTXC\Security\Authorization\PermissionChecker;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract class InitController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenant, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly ModuleManager $moduleManager, private readonly ModuleManager $moduleManager,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly PermissionChecker $permissionChecker, private readonly PermissionChecker $permissionChecker,
private readonly LocaleResolver $localeResolver,
) {} ) {}
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])] #[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
public function index(): JsonResponse { public function index(Request $request): JsonResponse {
$configuration = []; $configuration = [];
// modules - filter by permissions // modules - filter by permissions
$configuration['modules'] = []; $configuration['modules'] = [];
foreach ($this->moduleManager->list() as $module) { foreach ($this->moduleManager->list(true, true) as $module) {
// Check if user has permission to view this module // Check if user has permission to view this module
// Allow access if user has: {module_handle}, {module_handle}.*, or * permission // Allow access if user has: {module_handle}, {module_handle}.*, or * permission
$handle = $module->handle(); $handle = $module->handle();
@@ -43,24 +45,31 @@ class InitController extends ControllerAbstract
} }
} }
// localization
$configuration['l10n'] = [
'locale' => $this->localeResolver->resolve($request),
'fallback' => LocaleResolver::FALLBACK,
'available' => $this->localeResolver->available(),
];
// tenant // tenant
$configuration['tenant'] = [ $configuration['tenant'] = [
'id' => $this->tenant->identifier(), 'id' => $this->tenantContext->identifier(),
'domain' => $this->tenant->domain(), 'domain' => $this->tenantContext->domain(),
'label' => $this->tenant->label(), 'label' => $this->tenantContext->label(),
]; ];
// user // user
$configuration['user'] = [ $configuration['user'] = [
'auth' => [ 'auth' => [
'identifier' => $this->userIdentity->identifier(), 'identifier' => $this->identityContext->identifier(),
'identity' => $this->userIdentity->identity()->getIdentity(), 'identity' => $this->identityContext->identity()->getIdentity(),
'label' => $this->userIdentity->label(), 'label' => $this->identityContext->label(),
'roles' => $this->userIdentity->identity()->getRoles(), 'roles' => $this->identityContext->identity()->getRoles(),
'permissions' => $this->userIdentity->identity()->getPermissions(), 'permissions' => $this->identityContext->identity()->getPermissions(),
], ],
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), 'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
'settings' => $this->userService->fetchSettings(), 'settings' => $this->userService->fetchSettings([], true),
]; ];
return new JsonResponse($configuration); return new JsonResponse($configuration);
+1 -1
View File
@@ -21,7 +21,7 @@ class ModuleController extends ControllerAbstract
)] )]
public function index(): JsonResponse public function index(): JsonResponse
{ {
$modules = $this->moduleManager->list(false); $modules = $this->moduleManager->list();
return new JsonResponse(['modules' => $modules]); return new JsonResponse(['modules' => $modules]);
} }
@@ -0,0 +1,74 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\TenantService;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
/**
* Tenant-scoped settings controller.
*
* Mirrors UserSettingsController but operates on the current tenant record
* rather than the current user. Write access is guarded by the
* `tenant.settings.update` permission so only administrators can mutate
* tenant-wide configuration.
*/
class TenantSettingsController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly TenantService $tenantService,
) {}
/**
* Retrieve all settings for the current tenant.
*
* @return JsonResponse Settings data as key-value pairs
*/
#[AuthenticatedRoute(
'/tenant/settings',
name: 'tenant.settings.read',
methods: ['GET'],
permissions: ['tenant.settings.read'],
)]
public function read(): JsonResponse
{
$settings = $this->tenantService->fetchSettings($this->tenantContext->identifier());
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
/**
* Update one or more settings for the current tenant.
*
* @param array $data Key-value pairs to persist
*
* @example request body:
* {
* "data": {
* "theme_default_mode": "dark",
* "theme_palette": {"light": {"colors": {"primary": "#0284C7"}}},
* "theme_lock": true
* }
* }
*
* @return JsonResponse The updated values that were written
*/
#[AuthenticatedRoute(
'/tenant/settings',
name: 'tenant.settings.update',
methods: ['PUT', 'PATCH'],
permissions: ['tenant.settings.update'],
)]
public function update(array $data): JsonResponse
{
$this->tenantService->storeSettings($this->tenantContext->identifier(), $data);
$updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
}
}
+18 -18
View File
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
class UserAccountsController extends ControllerAbstract class UserAccountsController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
{ {
try { try {
// Check admin permission // Check admin permission
if (!$this->userIdentity->hasPermission('user.admin')) { if (!$this->identityContext->hasPermission('user.admin')) {
return new JsonResponse([ return new JsonResponse([
'status' => 'error', 'status' => 'error',
'data' => ['code' => 403, 'message' => 'Insufficient permissions'] 'data' => ['code' => 403, 'message' => 'Insufficient permissions']
@@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userCreate(array $data): array private function userCreate(array $data): array
{ {
if (!$this->userIdentity->hasPermission('user.create')) { if (!$this->identityContext->hasPermission('user.create')) {
throw new \InvalidArgumentException('Insufficient permissions to create users'); throw new \InvalidArgumentException('Insufficient permissions to create users');
} }
@@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract
]; ];
$this->logger->info('Creating user', [ $this->logger->info('Creating user', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'identity' => $userData['identity'], 'identity' => $userData['identity'],
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->createUser($userData); return $this->userService->createUser($userData);
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userUpdate(array $data): bool private function userUpdate(array $data): bool
{ {
if (!$this->userIdentity->hasPermission('user.update')) { if (!$this->identityContext->hasPermission('user.update')) {
throw new \InvalidArgumentException('Insufficient permissions to update users'); throw new \InvalidArgumentException('Insufficient permissions to update users');
} }
@@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract
} }
$this->logger->info('Updating user', [ $this->logger->info('Updating user', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'uid' => $uid, 'uid' => $uid,
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->updateUser($uid, $updates); return $this->userService->updateUser($uid, $updates);
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userDelete(array $data): bool private function userDelete(array $data): bool
{ {
if (!$this->userIdentity->hasPermission('user.delete')) { if (!$this->identityContext->hasPermission('user.delete')) {
throw new \InvalidArgumentException('Insufficient permissions to delete users'); throw new \InvalidArgumentException('Insufficient permissions to delete users');
} }
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
// Prevent self-deletion // Prevent self-deletion
if ($uid === $this->userIdentity->identifier()) { if ($uid === $this->identityContext->identifier()) {
throw new \InvalidArgumentException('Cannot delete your own account'); throw new \InvalidArgumentException('Cannot delete your own account');
} }
$this->logger->info('Deleting user', [ $this->logger->info('Deleting user', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'uid' => $uid, 'uid' => $uid,
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->deleteUser($uid); return $this->userService->deleteUser($uid);
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userProviderUnlink(array $data): bool private function userProviderUnlink(array $data): bool
{ {
if (!$this->userIdentity->hasPermission('user.admin')) { if (!$this->identityContext->hasPermission('user.admin')) {
throw new \InvalidArgumentException('Insufficient permissions'); throw new \InvalidArgumentException('Insufficient permissions');
} }
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
]; ];
$this->logger->info('Unlinking provider', [ $this->logger->info('Unlinking provider', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'uid' => $uid, 'uid' => $uid,
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->updateUser($uid, $updates); return $this->userService->updateUser($uid, $updates);
@@ -4,16 +4,16 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserProfileController extends ControllerAbstract class UserProfileController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService private readonly UserAccountsService $userService
) {} ) {}
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
)] )]
public function read(): JsonResponse public function read(): JsonResponse
{ {
$userId = $this->userIdentity->identifier(); $userId = $this->identityContext->identifier();
// Get profile with editability metadata // Get profile with editability metadata
$profile = $this->userService->getEditableFields($userId); $profile = $this->userService->getEditableFields($userId);
@@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract
)] )]
public function update(array $data): JsonResponse public function update(array $data): JsonResponse
{ {
$userId = $this->userIdentity->identifier(); $userId = $this->identityContext->identifier();
// storeProfile automatically filters out provider-managed fields // storeProfile automatically filters out provider-managed fields
$this->userService->storeProfile($userId, $data); $this->userService->storeProfile($userId, $data);
+8 -8
View File
@@ -3,8 +3,8 @@
namespace KTXC\Controllers; namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXC\Service\UserRolesService; use KTXC\Service\UserRolesService;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
class UserRolesController extends ControllerAbstract class UserRolesController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserRolesService $roleService, private readonly UserRolesService $roleService,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
{ {
try { try {
// Check role admin permission // Check role admin permission
if (!$this->userIdentity->hasPermission('role.admin')) { if (!$this->identityContext->hasPermission('role.admin')) {
return new JsonResponse([ return new JsonResponse([
'status' => 'error', 'status' => 'error',
'data' => ['code' => 403, 'message' => 'Insufficient permissions'] 'data' => ['code' => 403, 'message' => 'Insufficient permissions']
@@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract
*/ */
private function roleCreate(array $data): array private function roleCreate(array $data): array
{ {
if (!$this->userIdentity->hasPermission('role.manage')) { if (!$this->identityContext->hasPermission('role.manage')) {
throw new \InvalidArgumentException('Insufficient permissions to create roles'); throw new \InvalidArgumentException('Insufficient permissions to create roles');
} }
@@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract
*/ */
private function roleUpdate(array $data): bool private function roleUpdate(array $data): bool
{ {
if (!$this->userIdentity->hasPermission('role.manage')) { if (!$this->identityContext->hasPermission('role.manage')) {
throw new \InvalidArgumentException('Insufficient permissions to update roles'); throw new \InvalidArgumentException('Insufficient permissions to update roles');
} }
@@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract
*/ */
private function roleDelete(array $data): bool private function roleDelete(array $data): bool
{ {
if (!$this->userIdentity->hasPermission('role.manage')) { if (!$this->identityContext->hasPermission('role.manage')) {
throw new \InvalidArgumentException('Insufficient permissions to delete roles'); throw new \InvalidArgumentException('Insufficient permissions to delete roles');
} }
+14 -14
View File
@@ -2,23 +2,24 @@
namespace KTXC\Controllers; namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserSettingsController extends ControllerAbstract class UserSettingsController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService private readonly UserAccountsService $userService
) {} ) {}
/** /**
* Retrieve user settings * Retrieve user settings, with optional filtering
* If no specific settings are requested, all settings are returned * If no specific settings are requested, all settings are returned
* *
* @return JsonResponse Settings data as key-value pairs * @return JsonResponse Settings data as key-value pairs
@@ -29,10 +30,9 @@ class UserSettingsController extends ControllerAbstract
methods: ['GET'], methods: ['GET'],
permissions: ['user.settings.read'] permissions: ['user.settings.read']
)] )]
public function read(): JsonResponse public function read(bool $flatten = false): JsonResponse
{ {
// Fetch all settings (no filter) $settings = $this->userService->fetchSettings(flatten: $flatten);
$settings = $this->userService->fetchSettings();
return new JsonResponse($settings, JsonResponse::HTTP_OK); return new JsonResponse($settings, JsonResponse::HTTP_OK);
} }
@@ -55,17 +55,17 @@ class UserSettingsController extends ControllerAbstract
*/ */
#[AuthenticatedRoute( #[AuthenticatedRoute(
'/user/settings', '/user/settings',
name: 'user.settings.update', name: 'user.settings.write',
methods: ['PUT', 'PATCH'], methods: ['POST', 'PUT', 'PATCH'],
permissions: ['user.settings.update'] permissions: ['user.settings.write']
)] )]
public function update(array $data): JsonResponse public function write(array $data): JsonResponse
{ {
$this->userService->storeSettings($data); $this->userService->storeSettings($data);
// Return updated settings // Return updated settings
$updatedSettings = $this->userService->fetchSettings(array_keys($data)); $settings = $this->userService->fetchSettings(array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK); return new JsonResponse($settings, JsonResponse::HTTP_OK);
} }
} }
@@ -5,7 +5,10 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\Service\SecurityService; use KTXC\Service\SecurityService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
/** /**
* Authentication middleware * Authentication middleware
@@ -19,7 +22,10 @@ class AuthenticationMiddleware implements MiddlewareInterface
{ {
public function __construct( public function __construct(
private readonly SecurityService $securityService, private readonly SecurityService $securityService,
private readonly SessionIdentity $sessionIdentity private readonly IdentityContext $identityContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
) {} ) {}
public function process(Request $request, RequestHandlerInterface $handler): Response public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -29,7 +35,11 @@ class AuthenticationMiddleware implements MiddlewareInterface
// Initialize session identity if authentication succeeded // Initialize session identity if authentication succeeded
if ($identity) { if ($identity) {
$this->sessionIdentity->initialize($identity, true); $this->identityContext->initialize($identity);
$identityId = $this->identityContext->identifier();
$this->ephemeralCache->setUserContext($identityId);
$this->persistentCache->setUserContext($identityId);
$this->blobCache->setUserContext($identityId);
} }
// Continue to next middleware (authentication is optional at this stage) // Continue to next middleware (authentication is optional at this stage)
@@ -6,7 +6,7 @@ use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\Routing\Router; use KTXC\Routing\Router;
use KTXC\Routing\Route; use KTXC\Routing\Route;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\Security\Authorization\PermissionChecker; use KTXC\Security\Authorization\PermissionChecker;
/** /**
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
{ {
public function __construct( public function __construct(
private readonly Router $router, private readonly Router $router,
private readonly SessionIdentity $sessionIdentity, private readonly IdentityContextInterface $identityContext,
private readonly PermissionChecker $permissionChecker private readonly PermissionChecker $permissionChecker
) {} ) {}
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
} }
// Check if route requires authentication // Check if route requires authentication
if ($match->authenticated && $this->sessionIdentity->identity() === null) { if ($match->authenticated && $this->identityContext->identity() === null) {
return new Response( return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED Response::HTTP_UNAUTHORIZED
+14 -4
View File
@@ -4,7 +4,10 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\SessionTenant; use KTXC\Context\TenantContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
/** /**
* Tenant resolution middleware * Tenant resolution middleware
@@ -13,16 +16,23 @@ use KTXC\SessionTenant;
class TenantMiddleware implements MiddlewareInterface class TenantMiddleware implements MiddlewareInterface
{ {
public function __construct( public function __construct(
private readonly SessionTenant $sessionTenant private readonly TenantContext $tenantContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
) {} ) {}
public function process(Request $request, RequestHandlerInterface $handler): Response public function process(Request $request, RequestHandlerInterface $handler): Response
{ {
// Configure tenant from request host // Configure tenant from request host
$this->sessionTenant->configure($request->getHost()); $this->tenantContext->resolveDomain($request->getHost());
$tenantId = $this->tenantContext->identifier();
$this->ephemeralCache->setTenantContext($tenantId);
$this->persistentCache->setTenantContext($tenantId);
$this->blobCache->setTenantContext($tenantId);
// Check if tenant is configured and enabled // Check if tenant is configured and enabled
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) { if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
return new Response( return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED Response::HTTP_UNAUTHORIZED
@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Response;
/**
* StreamedNdJsonResponse streams an HTTP response as Newline Delimited JSON (NDJSON).
*
* Each item yielded by the provided iterable is serialized as a single JSON value
* followed by a newline character (\n). The response is flushed to the client every
* $flushInterval items so consumers can process records incrementally without waiting
* for the full payload.
*
* Content-Type is set to `application/x-ndjson` and `X-Accel-Buffering: no` is added
* by default to disable nginx proxy buffering.
*
* Example usage:
*
* function records(): \Generator {
* yield ['id' => 1, 'name' => 'Alice'];
* yield ['id' => 2, 'name' => 'Bob'];
* }
*
* return new StreamedNdJsonResponse(records());
*/
class StreamedNdJsonResponse extends StreamedResponse
{
/**
* @param iterable<mixed> $items Items to serialize; each becomes one JSON line
* @param int $flushInterval Flush to client after this many items (default 10)
* @param int $status HTTP status code (default 200)
* @param array<string, string|string[]> $headers Additional HTTP headers
* @param int $encodingOptions Flags passed to json_encode()
*/
public function __construct(
iterable $items,
int $flushInterval = 10,
int $status = 200,
array $headers = [],
private readonly int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
) {
parent::__construct(null, $status, $headers);
if (!$this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/x-ndjson');
}
if (!$this->headers->has('X-Accel-Buffering')) {
$this->headers->set('X-Accel-Buffering', 'no');
}
$encodingOptions = $this->encodingOptions;
$this->setCallback(static function () use ($items, $flushInterval, $encodingOptions): void {
$count = 0;
foreach ($items as $item) {
echo json_encode($item, \JSON_THROW_ON_ERROR | $encodingOptions) . "\n";
$count++;
if ($count >= $flushInterval) {
@ob_flush();
flush();
$count = 0;
}
}
// final flush for any remaining buffered items
if ($count > 0) {
@ob_flush();
flush();
}
});
}
}
+178 -169
View File
@@ -9,20 +9,28 @@
namespace KTXC; namespace KTXC;
use KTXC\Http\Request\Request; use KTXC\Application\KernelOptions;
use KTXC\Http\Response\Response; use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Http\Middleware\MiddlewarePipeline; use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Http\Middleware\TenantMiddleware; use KTXC\Application\Execution\ExecutionRunner;
use KTXC\Http\Middleware\FirewallMiddleware; use KTXC\Application\Execution\ExecutionRunnerInterface;
use KTXC\Http\Middleware\AuthenticationMiddleware; use KTXC\Application\Execution\ExecutionScope;
use KTXC\Http\Middleware\RouterMiddleware; use KTXC\Application\Execution\TerminationReport;
use KTXC\Context\IdentityContext;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContext;
use KTXC\Context\TenantContextInterface;
use KTXC\Injection\Builder; use KTXC\Injection\Builder;
use KTXC\Injection\Container; use KTXC\Injection\Container;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use KTXC\Logger\FileLogger; use KTXC\Logger\LoggerFactory;
use KTXF\Event\EventBus; use KTXC\Logger\TenantAwareLogger;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistry;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface; use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface; use KTXF\Cache\BlobCacheInterface;
@@ -30,7 +38,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
use KTXF\Cache\Store\FilePersistentCache; use KTXF\Cache\Store\FilePersistentCache;
use KTXF\Cache\Store\FileBlobCache; use KTXF\Cache\Store\FileBlobCache;
class Kernel class Kernel implements KernelInterface
{ {
public const VERSION = '1.0.0'; public const VERSION = '1.0.0';
public const VERSION_ID = 10000; public const VERSION_ID = 10000;
@@ -44,26 +52,17 @@ class Kernel
protected ?float $startTime = null; protected ?float $startTime = null;
protected ?ContainerInterface $container = null; protected ?ContainerInterface $container = null;
protected ?LoggerInterface $logger = null; protected ?LoggerInterface $logger = null;
protected ?MiddlewarePipeline $pipeline = null; private bool $errorHandlerInstalled = false;
private ?ExecutionScope $activeScope = null;
private ?ExecutionRunnerInterface $runner = null;
private string $projectDir;
private array $config; private array $config;
public function __construct( public function __construct(
protected string $environment = 'prod', private readonly KernelOptions $options,
protected bool $debug = false,
array $config = [], array $config = [],
?string $projectDir = null,
) { ) {
if (!$environment) {
throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this)));
}
$this->config = $config; $this->config = $config;
if ($projectDir !== null) {
$this->projectDir = $projectDir;
}
} }
public function __clone() public function __clone()
@@ -71,15 +70,16 @@ class Kernel
$this->initialized = false; $this->initialized = false;
$this->booted = false; $this->booted = false;
$this->container = null; $this->container = null;
$this->runner = null;
} }
private function initialize(): void private function initialize(): void
{ {
if ($this->debug) { if ($this->debug()) {
$this->startTime = microtime(true); $this->startTime = microtime(true);
} }
if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
if (\function_exists('putenv')) { if (\function_exists('putenv')) {
putenv('SHELL_VERBOSITY=3'); putenv('SHELL_VERBOSITY=3');
} }
@@ -87,10 +87,11 @@ class Kernel
$_SERVER['SHELL_VERBOSITY'] = 3; $_SERVER['SHELL_VERBOSITY'] = 3;
} }
// Create logger with config support // Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
$logDir = $this->config['log.directory'] ?? $this->getLogDir(); $this->logger = LoggerFactory::create(
$logChannel = $this->config['log.channel'] ?? 'app'; $this->config,
$this->logger = new FileLogger($logDir, $logChannel); $this->options->paths->project,
);
$this->initializeErrorHandlers(); $this->initializeErrorHandlers();
@@ -130,23 +131,7 @@ class Kernel
return true; return true;
}); });
// Handle uncaught exceptions $this->errorHandlerInstalled = true;
set_exception_handler(function (\Throwable $exception) {
$this->logger->error('Exception caught: ' . $exception->getMessage(), [
'exception' => $exception,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
]);
if ($this->debug) {
echo '<pre>Uncaught Exception: ' . $exception . '</pre>';
} else {
echo 'An unexpected error occurred. Please try again later.';
}
exit(1);
});
// Handle fatal errors // Handle fatal errors
register_shutdown_function(function () { register_shutdown_function(function () {
@@ -162,11 +147,6 @@ class Kernel
$this->logger->error($message, $error); $this->logger->error($message, $error);
if ($this->debug) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
} }
}); });
} }
@@ -181,14 +161,19 @@ class Kernel
/** @var ModuleManager $moduleManager */ /** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class); $moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot(); $moduleManager->modulesBoot();
$this->container
// Build middleware pipeline ->get(EventListenerRegistry::class)
$this->pipeline = $this->buildMiddlewarePipeline(); ->freeze($this->container);
$this->booted = true; $this->booted = true;
} }
} }
public function executionRunner(): ExecutionRunnerInterface
{
return $this->runner ??= new ExecutionRunner($this);
}
public function reboot(): void public function reboot(): void
{ {
$this->shutdown(); $this->shutdown();
@@ -200,52 +185,108 @@ class Kernel
if (false === $this->initialized) { if (false === $this->initialized) {
return; return;
} }
if ($this->activeScope !== null && !$this->activeScope->terminated()) {
throw new \LogicException('Cannot shut down the kernel while an execution scope is active.');
}
$this->initialized = false; $this->initialized = false;
$this->booted = false; $this->booted = false;
$this->container = null; $this->container = null;
if ($this->errorHandlerInstalled) {
restore_error_handler();
$this->errorHandlerInstalled = false;
}
} }
public function handle(Request $request): Response public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
{ {
if (!$this->booted) { if (!$this->booted) {
$this->boot(); $this->boot();
} }
if ($this->activeScope !== null) {
throw new \LogicException('The kernel already has an active execution scope.');
}
// Use middleware pipeline to handle the request $scope = new ExecutionScope(
return $this->pipeline->handle($request); $descriptor,
\KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor),
$this->container->get(TenantContext::class),
$this->container->get(IdentityContext::class),
);
$this->container
->get(DeferredEventProcessorInterface::class)
->beginExecution($descriptor->executionId);
$this->activeScope = $scope;
return $scope;
} }
/** public function terminateExecution(
* Build the middleware pipeline ExecutionScope $scope,
*/ ExecutionOutcome $outcome,
protected function buildMiddlewarePipeline(): MiddlewarePipeline ): TerminationReport
{ {
$pipeline = new MiddlewarePipeline($this->container); if ($scope->terminated()) {
return new TerminationReport();
// Register middleware in execution order }
$pipeline->pipe(TenantMiddleware::class); if ($this->activeScope !== $scope) {
$pipeline->pipe(FirewallMiddleware::class); throw new \LogicException('Cannot terminate a scope that is not active.');
$pipeline->pipe(AuthenticationMiddleware::class); }
$pipeline->pipe(RouterMiddleware::class);
$processed = 0;
return $pipeline; $remaining = 0;
} $deadlineExceeded = false;
$limitExceeded = false;
$failures = [];
/**
* Process deferred events at the end of the request
*/
public function processEvents(): void
{
try { try {
if ($this->container && $this->container->has(EventBus::class)) { if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
/** @var EventBus $eventBus */ $result = $this->container
$eventBus = $this->container->get(EventBus::class); ->get(DeferredEventProcessorInterface::class)
$eventBus->processDeferred(); ->processDeferred($scope->descriptor->executionId);
$processed = $result->processed;
$remaining = $result->remaining;
$deadlineExceeded = $result->deadlineExceeded;
$limitExceeded = $result->limitExceeded;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
error_log('Event processing error: ' . $e->getMessage()); $failures[] = $e;
try {
$this->container
?->get(DeferredEventProcessorInterface::class)
->discardDeferred($scope->descriptor->executionId);
} catch (\Throwable $discardError) {
$failures[] = $discardError;
}
$this->logger?->error('Deferred event processing failed.', [
'exception' => $e,
'execution_id' => $scope->descriptor->executionId,
'runtime' => $scope->descriptor->runtime->value,
]);
} finally {
if ($this->container !== null) {
foreach ([
EphemeralCacheInterface::class,
PersistentCacheInterface::class,
BlobCacheInterface::class,
] as $cacheType) {
$cache = $this->container->get($cacheType);
$cache->setUserContext(null);
$cache->setTenantContext(null);
}
}
$scope->dispose();
$scope->markTerminated();
$this->activeScope = null;
} }
return new TerminationReport(
deferredProcessed: $processed,
deferredRemaining: $remaining,
failures: $failures,
deadlineExceeded: $deadlineExceeded,
limitExceeded: $limitExceeded,
);
} }
/** /**
@@ -255,30 +296,34 @@ class Kernel
*/ */
protected function parameters(): array protected function parameters(): array
{ {
$projectDir = $this->options->paths->project;
$cacheDir = $this->options->paths->cache($this->environment());
$logsDir = $this->options->paths->logs();
return [ return [
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(), 'kernel.project_dir' => realpath($projectDir) ?: $projectDir,
'kernel.environment' => $this->environment, 'kernel.environment' => $this->environment(),
'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%', 'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', 'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%',
'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%', 'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', 'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%',
'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%', 'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%',
'kernel.debug' => $this->debug, 'kernel.debug' => $this->debug(),
'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(), 'kernel.build_dir' => realpath($cacheDir) ?: $cacheDir,
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(), 'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(), 'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
'kernel.charset' => $this->getCharset(), 'kernel.charset' => 'UTF-8',
]; ];
} }
public function environment(): string public function environment(): string
{ {
return $this->environment; return $this->options->environment;
} }
public function debug(): bool public function debug(): bool
{ {
return $this->debug; return $this->options->debug;
} }
public function container(): ContainerInterface public function container(): ContainerInterface
@@ -292,61 +337,7 @@ class Kernel
public function getStartTime(): float public function getStartTime(): float
{ {
return $this->debug && null !== $this->startTime ? $this->startTime : -\INF; return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
}
/**
* Gets the application root dir (path of the project's composer file).
*/
public function folderRoot(): string
{
if (!isset($this->projectDir)) {
$r = new \ReflectionObject($this);
if (!is_file($dir = $r->getFileName())) {
throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name));
}
$dir = $rootDir = \dirname($dir);
while (!is_file($dir.'/composer.json')) {
if ($dir === \dirname($dir)) {
return $this->projectDir = $rootDir;
}
$dir = \dirname($dir);
}
$this->projectDir = $dir;
}
return $this->projectDir;
}
/**
* Gets the path to the configuration directory.
*/
private function getConfigDir(): string
{
return $this->folderRoot().'/config';
}
public function getCacheDir(): string
{
return $this->folderRoot().'/var/cache/'.$this->environment;
}
public function getBuildDir(): string
{
return $this->getCacheDir();
}
public function getLogDir(): string
{
return $this->folderRoot().'/var/log';
}
public function getCharset(): string
{
return 'UTF-8';
} }
/** /**
@@ -381,9 +372,9 @@ class Kernel
protected function configureContainer(Builder $builder): void protected function configureContainer(Builder $builder): void
{ {
// Service definitions // Service definitions
$projectDir = $this->folderRoot(); $projectDir = $this->options->paths->project;
$moduleDir = $projectDir . '/modules'; $moduleDir = $projectDir . '/modules';
$environment = $this->environment; $environment = $this->environment();
$builder->addDefinitions([ $builder->addDefinitions([
@@ -396,11 +387,29 @@ class Kernel
// Without this alias, PHP-DI will happily autowire a new empty Container when asked // Without this alias, PHP-DI will happily autowire a new empty Container when asked
Container::class => \DI\get(ContainerInterface::class), Container::class => \DI\get(ContainerInterface::class),
// Use the kernel's logger instance TenantContextInterface::class => \DI\get(TenantContext::class),
LoggerInterface::class => \DI\value($this->logger), IdentityContextInterface::class => \DI\get(IdentityContext::class),
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
$logConfig = $this->config['log'] ?? [];
$logDir = $logConfig['path'] ?? ($projectDir . '/var/log');
$channel = $logConfig['channel'] ?? 'app';
$level = $logConfig['level'] ?? 'debug';
$perTenant = (bool) ($logConfig['per_tenant'] ?? false);
return new TenantAwareLogger(
$this->logger,
$c->get(TenantContextInterface::class),
$logDir,
$channel,
$level,
$perTenant,
);
},
// EventBus as singleton for consistent event handling EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
EventBus::class => \DI\create(EventBus::class), DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
@@ -419,13 +428,13 @@ class Kernel
$cache = new $storeClass($projectDir); $cache = new $storeClass($projectDir);
// Set tenant/user context if available // Set tenant/user context if available
if ($c->has(SessionTenant::class)) { if ($c->has(TenantContextInterface::class)) {
$tenant = $c->get(SessionTenant::class); $tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenant->identifier()); $cache->setTenantContext($tenantContext->identifier());
} }
if ($c->has(SessionIdentity::class)) { if ($c->has(IdentityContextInterface::class)) {
$identity = $c->get(SessionIdentity::class); $identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identity->identifier()); $cache->setUserContext($identityContext->identifier());
} }
return $cache; return $cache;
@@ -448,13 +457,13 @@ class Kernel
$cache = new $storeClass($projectDir); $cache = new $storeClass($projectDir);
// Set tenant/user context if available // Set tenant/user context if available
if ($c->has(SessionTenant::class)) { if ($c->has(TenantContextInterface::class)) {
$tenant = $c->get(SessionTenant::class); $tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenant->identifier()); $cache->setTenantContext($tenantContext->identifier());
} }
if ($c->has(SessionIdentity::class)) { if ($c->has(IdentityContextInterface::class)) {
$identity = $c->get(SessionIdentity::class); $identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identity->identifier()); $cache->setUserContext($identityContext->identifier());
} }
return $cache; return $cache;
@@ -477,13 +486,13 @@ class Kernel
$cache = new $storeClass($projectDir); $cache = new $storeClass($projectDir);
// Set tenant/user context if available // Set tenant/user context if available
if ($c->has(SessionTenant::class)) { if ($c->has(TenantContextInterface::class)) {
$tenant = $c->get(SessionTenant::class); $tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenant->identifier()); $cache->setTenantContext($tenantContext->identifier());
} }
if ($c->has(SessionIdentity::class)) { if ($c->has(IdentityContextInterface::class)) {
$identity = $c->get(SessionIdentity::class); $identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identity->identifier()); $cache->setUserContext($identityContext->identifier());
} }
return $cache; return $cache;
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace KTXC;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionRunnerInterface;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use Psr\Container\ContainerInterface;
interface KernelInterface
{
public function boot(): void;
public function executionRunner(): ExecutionRunnerInterface;
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope;
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport;
public function shutdown(): void;
public function container(): ContainerInterface;
public function environment(): string;
public function debug(): bool;
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace KTXC\L10N;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Service\UserAccountsService;
/**
* Resolves the effective UI locale for the current user.
*
* Resolution chain: user setting (core.locale) -> Accept-Language
* negotiation -> fallback. Tenant default slots in between once tenant
* settings expose one (Phase 3).
*/
class LocaleResolver
{
public const FALLBACK = 'en';
public const SETTING_KEY = 'core.locale';
public function __construct(
private readonly UserAccountsService $userService,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
/**
* Locales with a core catalog on disk (public/l10n/*.json), always
* including the fallback. This backs the language picker, so a locale
* is only offered when it has at least core-shell coverage.
*
* @return string[]
*/
public function available(): array
{
$locales = [self::FALLBACK];
foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) {
$locales[] = basename($file, '.json');
}
$locales = array_values(array_unique($locales));
sort($locales);
return $locales;
}
public function resolve(Request $request): string
{
$available = $this->available();
// 1. explicit user preference
$settings = $this->userService->fetchSettings([self::SETTING_KEY]);
$preference = $settings[self::SETTING_KEY] ?? null;
if (is_string($preference) && in_array($preference, $available, true)) {
return $preference;
}
// 2. Accept-Language negotiation. getPreferredLanguage() returns the
// first candidate when nothing matches, so the fallback leads the
// list; it also normalizes to underscore form (pt_BR) while catalogs
// use BCP-47 hyphens.
$candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK]));
$negotiated = $request->getPreferredLanguage($candidates);
if ($negotiated !== null) {
$negotiated = str_replace('_', '-', $negotiated);
if (in_array($negotiated, $available, true)) {
return $negotiated;
}
}
// 3. fallback
return self::FALLBACK;
}
}
+18 -10
View File
@@ -26,7 +26,7 @@ class FileLogger implements LoggerInterface
if (!is_dir($logDir)) { if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true); @mkdir($logDir, 0775, true);
} }
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log'; $this->logFile = rtrim($logDir, '/').'/'.$channel.'.jsonl';
} }
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
@@ -40,11 +40,18 @@ class FileLogger implements LoggerInterface
public function log($level, $message, array $context = []): void public function log($level, $message, array $context = []): void
{ {
$timestamp = $this->formatTimestamp(); // Extract tenant id injected by TenantAwareLogger; default to 'system'.
$interpolated = $this->interpolate((string)$message, $context); $tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
? $context['__tenant']
: 'system';
unset($context['__tenant']);
$timestamp = $this->formatTimestamp();
$interpolated = $this->interpolate((string) $message, $context);
$payload = [ $payload = [
'time' => $timestamp, 'time' => $timestamp,
'level' => strtolower((string)$level), 'level' => strtolower((string) $level),
'tenant' => $tenantId,
'channel' => $this->channel, 'channel' => $this->channel,
'message' => $interpolated, 'message' => $interpolated,
'context' => $this->sanitizeContext($context), 'context' => $this->sanitizeContext($context),
@@ -53,11 +60,12 @@ class FileLogger implements LoggerInterface
if ($json === false) { if ($json === false) {
// Fallback stringify if encoding fails (should be rare) // Fallback stringify if encoding fails (should be rare)
$json = json_encode([ $json = json_encode([
'time' => $timestamp, 'time' => $timestamp,
'level' => strtolower((string)$level), 'level' => strtolower((string) $level),
'channel' => $this->channel, 'tenant' => $tenantId,
'message' => $interpolated, 'channel' => $this->channel,
'context_error' => 'failed to encode context: '.json_last_error_msg(), 'message' => $interpolated,
'context_error' => 'failed to encode context: ' . json_last_error_msg(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}'; ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}';
} }
$this->write($json); $this->write($json);
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* PSR-3 decorator that discards messages below a configured minimum severity.
*
* Severity ordering (lower = more severe):
* emergency(0) > alert(1) > critical(2) > error(3) > warning(4) > notice(5) > info(6) > debug(7)
*
* Example: minLevel = 'warning' passes emergency, alert, critical, error, warning
* and silently discards notice, info, debug.
*/
class LevelFilterLogger implements LoggerInterface
{
private int $minSeverity;
public function __construct(
private readonly LoggerInterface $inner,
string $minLevel = LogLevel::DEBUG,
) {
LogLevelSeverity::validate($minLevel);
$this->minSeverity = LogLevelSeverity::severity($minLevel);
}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
// Messages with severity numerically greater than minSeverity are less severe — discard them.
if (LogLevelSeverity::severity((string) $level) > $this->minSeverity) {
return;
}
$this->inner->log($level, $message, $context);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LogLevel;
/**
* Maps PSR-3 log level strings to integer severity values.
*
* Lower integer = higher severity (matches RFC 5424 / syslog convention):
* emergency = 0, alert = 1, critical = 2, error = 3,
* warning = 4, notice = 5, info = 6, debug = 7
*/
class LogLevelSeverity
{
private const MAP = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
/**
* Returns the integer severity for a PSR-3 level string.
*
* @throws \InvalidArgumentException for unknown level strings
*/
public static function severity(string $level): int
{
$normalized = strtolower($level);
if (!array_key_exists($normalized, self::MAP)) {
throw new \InvalidArgumentException(
sprintf(
'Unknown log level "%s". Valid levels are: %s.',
$level,
implode(', ', array_keys(self::MAP))
)
);
}
return self::MAP[$normalized];
}
/**
* Validates that a level string is a known PSR-3 level.
*
* @throws \InvalidArgumentException for unknown level strings
*/
public static function validate(string $level): void
{
self::severity($level); // throws on unknown
}
/**
* Returns all valid PSR-3 level strings ordered from most to least severe.
*
* @return string[]
*/
public static function levels(): array
{
return array_keys(self::MAP);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Creates a PSR-3 LoggerInterface instance from the application config.
*
* Reads the 'log' key from the system config array and builds:
* 1. A driver-specific inner logger (file, systemd, syslog, null).
* 2. A LevelFilterLogger decorator that discards messages below the
* configured minimum level.
*
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
* DI container definition in Kernel::configureContainer(), because that
* requires access to the TenantContextInterface singleton which lives in the container.
*
* Supported config keys inside $config['log']:
*
* driver string 'file' | 'systemd' | 'syslog' | 'null' default: 'file'
* level string PSR-3 level string (minimum to log) default: 'debug'
* per_tenant bool Route to per-tenant files (DI-level only) default: false
*
* -- file driver --
* path ?string Absolute log directory, null = {root}/var/log
* channel string File basename without extension default: 'app'
*
* -- systemd driver --
* channel string Channel tag embedded in each line default: 'app'
*
* -- syslog driver --
* ident string openlog() identity tag default: 'ktrix'
* facility int openlog() facility constant default: LOG_USER
* channel string Prefix embedded in each syslog message default: 'app'
*/
class LoggerFactory
{
/**
* Build and return a configured, level-filtered PSR-3 logger.
*
* @param array $config The full system config array (reads $config['log']).
* @param string $projectDir Absolute project root path (used for default file path).
*/
public static function create(array $config, string $projectDir): LoggerInterface
{
$logConfig = $config['log'] ?? [];
$driver = $logConfig['driver'] ?? 'file';
$level = $logConfig['level'] ?? 'debug';
$channel = $logConfig['channel'] ?? 'app';
// Validate level early for a clear error message.
LogLevelSeverity::validate($level);
$inner = match ($driver) {
'file' => self::buildFileLogger($logConfig, $projectDir, $channel),
'systemd' => new SystemdLogger($channel),
'syslog' => new SyslogLogger(
$logConfig['ident'] ?? 'ktrix',
$logConfig['facility'] ?? LOG_USER,
$channel,
),
'null' => new NullLogger(),
default => throw new \RuntimeException(
sprintf(
'Unknown log driver "%s". Supported drivers: file, systemd, syslog, null.',
$driver
)
),
};
return new LevelFilterLogger($inner, $level);
}
private static function buildFileLogger(array $logConfig, string $projectDir, string $channel): FileLogger
{
$path = $logConfig['path'] ?? null;
if ($path === null || $path === '') {
$path = $projectDir . '/var/logs';
}
return new FileLogger($path, $channel);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
/**
* Simple file-based PSR-3 logger that writes plain-text lines.
*
* Each entry is formatted as:
* 2026-02-20 12:34:56.123456 message text
*/
class PlainFileLogger implements LoggerInterface
{
private string $logFile;
/**
* @param string $logDir Directory where log files are written
* @param string $channel Logical channel name (used in filename)
*/
public function __construct(string $logDir, string $channel = 'app')
{
$this->logFile = rtrim($logDir, '/') . '/' . $channel . '.log';
$this->ensureWritablePath();
}
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
$this->ensureWritablePath();
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
$timestamp = $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
$line = $timestamp . ' ' . $this->interpolate((string) $message, $context) . PHP_EOL;
if (@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX) === false) {
error_log(sprintf('Failed to write to log file: %s', $this->logFile));
}
}
private function ensureWritablePath(): void
{
$logDir = dirname($this->logFile);
if (!is_dir($logDir)) {
@mkdir($logDir, 0777, true);
}
if (is_dir($logDir)) {
@chmod($logDir, 0777);
}
if (!file_exists($this->logFile)) {
@touch($this->logFile);
}
clearstatcache(true, $this->logFile);
if (file_exists($this->logFile)) {
@chmod($this->logFile, 0666);
}
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (is_array($val) || (is_object($val) && !method_exists($val, '__toString'))) {
continue;
}
$replace['{' . $key . '}'] = (string) $val;
}
return strtr($message, $replace);
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* PSR-3 logger that writes via PHP's built-in syslog() facility.
*
* Each message is prefixed with "[channel] " so entries can be filtered easily
* in /var/log/syslog (or equivalent) or via journalctl -t {ident}.
*/
class SyslogLogger implements LoggerInterface
{
/** Maps PSR-3 levels to PHP syslog priority constants */
private const PRIORITY = [
LogLevel::EMERGENCY => LOG_EMERG,
LogLevel::ALERT => LOG_ALERT,
LogLevel::CRITICAL => LOG_CRIT,
LogLevel::ERROR => LOG_ERR,
LogLevel::WARNING => LOG_WARNING,
LogLevel::NOTICE => LOG_NOTICE,
LogLevel::INFO => LOG_INFO,
LogLevel::DEBUG => LOG_DEBUG,
];
public function __construct(
private readonly string $ident = 'ktrix',
private readonly int $facility = LOG_USER,
private readonly string $channel = 'app',
) {}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
? $context['__tenant']
: 'system';
unset($context['__tenant']);
$level = strtolower((string) $level);
$priority = self::PRIORITY[$level] ?? LOG_DEBUG;
$interpolated = $this->interpolate((string) $message, $context);
$contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context);
$entry = sprintf('[%s] [%s] %s%s', $this->channel, $tenantId, $interpolated, $contextStr);
openlog($this->ident, LOG_NDELAY | LOG_PID, $this->facility);
syslog($priority, $entry);
closelog();
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (!is_array($val) && !is_object($val)) {
$replace['{' . $key . '}'] = (string) $val;
}
}
return strtr($message, $replace);
}
private function encodeContext(array $context): string
{
$clean = [];
foreach ($context as $k => $v) {
if ($v instanceof \Throwable) {
$clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()];
} elseif (is_resource($v)) {
$clean[$k] = 'resource(' . get_resource_type($v) . ')';
} elseif (is_object($v)) {
$clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)];
} else {
$clean[$k] = $v;
}
}
return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* PSR-3 logger that writes to stderr using the journald/systemd SD_JOURNAL_PREFIX
* format: a syslog-priority number wrapped in angle brackets followed by the message.
*
* journald automatically parses the "<N>" prefix and maps it to the corresponding
* log priority, so log entries appear in the journal with the correct severity.
*
* Format: <priority>LEVEL [channel] interpolated-message {"context":"key",...}
*/
class SystemdLogger implements LoggerInterface
{
/** Maps PSR-3 levels to RFC 5424 / syslog priority numbers */
private const PRIORITY = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
/** @var resource */
private $stderr;
public function __construct(private readonly string $channel = 'app')
{
$this->stderr = fopen('php://stderr', 'w');
}
public function __destruct()
{
if (is_resource($this->stderr)) {
fclose($this->stderr);
}
}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
? $context['__tenant']
: 'system';
unset($context['__tenant']);
$level = strtolower((string) $level);
$priority = self::PRIORITY[$level] ?? 7;
$interpolated = $this->interpolate((string) $message, $context);
$contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context);
$line = sprintf(
"<%d>%s [%s] [%s] %s%s\n",
$priority,
strtoupper($level),
$this->channel,
$tenantId,
$interpolated,
$contextStr,
);
fwrite($this->stderr, $line);
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (!is_array($val) && !is_object($val)) {
$replace['{' . $key . '}'] = (string) $val;
}
}
return strtr($message, $replace);
}
private function encodeContext(array $context): string
{
$clean = [];
foreach ($context as $k => $v) {
if ($v instanceof \Throwable) {
$clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()];
} elseif (is_resource($v)) {
$clean[$k] = 'resource(' . get_resource_type($v) . ')';
} elseif (is_object($v)) {
$clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)];
} else {
$clean[$k] = $v;
}
}
return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace KTXC\Logger;
use KTXC\Context\TenantContextInterface;
use Psr\Log\LoggerInterface;
/**
* PSR-3 decorator with two responsibilities:
*
* 1. Tenant-id injection — every log record is enriched with a `tenant` field.
* When a tenant session is active the real tenant identifier is used; otherwise
* the value is "system" (boot phase, CLI, bad-domain rejections, etc.).
* The id is passed to inner loggers via the reserved `__tenant` context key,
* which each concrete logger (FileLogger, SystemdLogger, SyslogLogger) extracts
* and renders as a top-level field, then removes from context before output.
*
* 2. Per-tenant file routing (optional, controlled by $perTenant) — when enabled,
* writes for an active tenant are routed to:
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
* Messages that carry tenant "system" always go to the global logger.
*
* Both behaviours rely on a live TenantContextInterface reference that is populated lazily
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
*/
class TenantAwareLogger implements LoggerInterface
{
/** @var array<string, LoggerInterface> Per-tenant logger cache, keyed by tenant identifier. */
private array $tenantLoggers = [];
/**
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
* @param TenantContextInterface $tenantContext Live reference configured by TenantMiddleware.
* @param string $logDir Base log directory (e.g. /var/www/app/var/log).
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
* @param bool $perTenant When true, route tenant writes to per-tenant files.
*/
public function __construct(
private readonly LoggerInterface $globalLogger,
private readonly TenantContextInterface $tenantContext,
private readonly string $logDir,
private readonly string $channel = 'app',
private readonly string $minLevel = 'debug',
private readonly bool $perTenant = false,
) {
LogLevelSeverity::validate($minLevel);
}
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
// Resolve current tenant id; fall back to 'system' for CLI / boot phase.
$tenantId = 'system';
if ($this->tenantContext->configured()) {
$tenantId = $this->tenantContext->identifier() ?? 'system';
}
// Inject tenant id as a reserved context key that concrete loggers extract.
$context['__tenant'] = $tenantId;
$this->resolveLogger($tenantId)->log($level, $message, $context);
}
/**
* Returns the logger to write to for the given tenant.
* When per-tenant routing is disabled (or tenant is "system"), always returns the global logger.
*/
private function resolveLogger(string $tenantId): LoggerInterface
{
if (!$this->perTenant || $tenantId === 'system') {
return $this->globalLogger;
}
if (!isset($this->tenantLoggers[$tenantId])) {
$tenantLogDir = rtrim($this->logDir, '/') . '/tenant/' . $tenantId;
$this->tenantLoggers[$tenantId] = new LevelFilterLogger(
new FileLogger($tenantLogDir, $this->channel),
$this->minLevel,
);
}
return $this->tenantLoggers[$tenantId];
}
}
+2 -2
View File
@@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject
'enabled' => $this->enabled, 'enabled' => $this->enabled,
'label' => $this->label, 'label' => $this->label,
'description' => $this->description, 'description' => $this->description,
'domains' => $this->domains, 'domains' => $this->domains?->getArrayCopy(),
'configuration' => $this->configuration, 'configuration' => $this->configuration?->jsonSerialize(),
]; ];
} }
+54 -5
View File
@@ -2,6 +2,10 @@
namespace KTXC\Module; namespace KTXC\Module;
use KTXC\Service\FirewallService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface; use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract; use KTXF\Module\ModuleInstanceAbstract;
@@ -13,7 +17,36 @@ use KTXF\Module\ModuleInstanceAbstract;
*/ */
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{ {
public function __construct() {} public function __construct(
private readonly EventListenerRegistry $events,
) {
}
public function boot(): void
{
$this->events->listen(
'core',
SecurityEvent::AUTH_FAILURE,
FirewallService::class,
'handleAuthFailure',
priority: 100,
);
foreach ([
SecurityEvent::AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_DENIED,
SecurityEvent::BRUTE_FORCE_DETECTED,
] as $event) {
$this->events->listen(
'core',
$event,
FirewallService::class,
'logSecurityEvent',
DeliveryMode::Deferred,
);
}
}
public function handle(): string public function handle(): string
{ {
@@ -27,7 +60,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function author(): string public function author(): string
{ {
return 'Ktrix'; return 'Vallarx';
} }
public function description(): string public function description(): string
@@ -99,9 +132,25 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function registerCI(): array public function registerCI(): array
{ {
return [ return [
\KTXC\Console\ModuleListCommand::class, \KTXC\Console\Event\EventsDebugCommand::class,
\KTXC\Console\ModuleEnableCommand::class, \KTXC\Console\Module\ModuleListCommand::class,
\KTXC\Console\ModuleDisableCommand::class, \KTXC\Console\Module\ModuleEnableCommand::class,
\KTXC\Console\Module\ModuleDisableCommand::class,
\KTXC\Console\Module\ModuleInstallCommand::class,
\KTXC\Console\Module\ModuleUninstallCommand::class,
\KTXC\Console\Module\ModuleUpgradeCommand::class,
\KTXC\Console\Tenant\TenantCreateCommand::class,
\KTXC\Console\Tenant\TenantListCommand::class,
\KTXC\Console\Tenant\TenantDeleteCommand::class,
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
\KTXC\Console\User\UserCreateCommand::class,
\KTXC\Console\User\UserListCommand::class,
\KTXC\Console\User\UserDeleteCommand::class,
\KTXC\Console\Role\RoleCreateCommand::class,
\KTXC\Console\Role\RoleListCommand::class,
\KTXC\Console\Role\RoleDeleteCommand::class,
\KTXC\Console\Role\RoleAssignCommand::class,
\KTXC\Console\Role\RoleRevokeCommand::class,
]; ];
} }
+7 -5
View File
@@ -2,7 +2,7 @@
namespace KTXC\Module; namespace KTXC\Module;
use KTXC\Application; use Composer\Autoload\ClassLoader;
/** /**
* Custom autoloader for modules that allows PascalCase namespaces * Custom autoloader for modules that allows PascalCase namespaces
@@ -20,7 +20,10 @@ class ModuleAutoloader
private array $namespaceMap = []; private array $namespaceMap = [];
private bool $scanned = false; private bool $scanned = false;
public function __construct(string $modulesRoot) public function __construct(
string $modulesRoot,
private readonly ?ClassLoader $composerLoader = null,
)
{ {
$this->modulesRoot = rtrim($modulesRoot, '/'); $this->modulesRoot = rtrim($modulesRoot, '/');
} }
@@ -73,10 +76,9 @@ class ModuleAutoloader
} }
// Register module namespaces with Composer ClassLoader // Register module namespaces with Composer ClassLoader
$composerLoader = \KTXC\Application::getComposerLoader(); if ($this->composerLoader !== null) {
if ($composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) { foreach ($this->namespaceMap as $namespace => $folderName) {
$composerLoader->addPsr4( $this->composerLoader->addPsr4(
'KTXM\\' . $namespace . '\\', 'KTXM\\' . $namespace . '\\',
$this->modulesRoot . '/' . $folderName . '/lib/' $this->modulesRoot . '/' . $folderName . '/lib/'
); );
+22 -10
View File
@@ -31,9 +31,9 @@ class ModuleManager
* *
* @param bool $installedOnly If true, only return modules that are in the database * @param bool $installedOnly If true, only return modules that are in the database
* @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly) * @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly)
* @return Module[] * @return ModuleObject[]
*/ */
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection public function list(bool| null $installedOnly = null, bool| null $enabledOnly = null): ModuleCollection
{ {
$modules = New ModuleCollection(); $modules = New ModuleCollection();
@@ -44,12 +44,8 @@ class ModuleManager
} }
// load all modules from store // load all modules from store
$entries = $this->repository->list(); $entries = $this->repository->list($installedOnly, $enabledOnly);
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($enabledOnly && !$entry->getEnabled()) {
continue; // Skip disabled modules if filtering for enabled only
}
// instance module
$handle = $entry->getHandle(); $handle = $entry->getHandle();
if (isset($this->moduleInstances[$entry->getHandle()])) { if (isset($this->moduleInstances[$entry->getHandle()])) {
$modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry); $modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry);
@@ -60,7 +56,7 @@ class ModuleManager
} }
} }
// load all modules from filesystem // load all modules from filesystem
if ($installedOnly === false) { if ($installedOnly !== true) {
$discovered = $this->modulesDiscover(); $discovered = $this->modulesDiscover();
foreach ($discovered as $moduleInstance) { foreach ($discovered as $moduleInstance) {
$handle = $moduleInstance->handle(); $handle = $moduleInstance->handle();
@@ -72,6 +68,21 @@ class ModuleManager
return $modules; return $modules;
} }
public function fetch(string $handle): ?ModuleObject
{
$entry = $this->repository->fetch($handle);
if (!$entry) {
return null;
}
$moduleInstance = $this->moduleInstance($entry->getHandle(), $entry->getNamespace());
if (!$moduleInstance) {
return null;
}
return new ModuleObject($moduleInstance, $entry);
}
public function install(string $handle): void public function install(string $handle): void
{ {
@@ -258,19 +269,20 @@ class ModuleManager
public function modulesBoot(): void public function modulesBoot(): void
{ {
// Only load modules that are enabled in the database // Only load modules that are enabled in the database
$modules = $this->list(); $modules = $this->list(true, true);
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]); $this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
foreach ($modules as $module) { foreach ($modules as $module) {
$handle = $module->handle(); $handle = $module->handle();
try { try {
$module->boot(); $module->boot();
$this->logger->debug('Module booted', ['handle' => $handle]); $this->logger->debug('Module booted', ['handle' => $handle]);
} catch (Exception $e) { } catch (\Throwable $e) {
$this->logger->error('Module boot failed: ' . $handle, [ $this->logger->error('Module boot failed: ' . $handle, [
'exception' => $e, 'exception' => $e,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'code' => $e->getCode(), 'code' => $e->getCode(),
]); ]);
throw $e;
} }
} }
} }
+19 -4
View File
@@ -9,10 +9,7 @@ use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceInterface; use KTXF\Module\ModuleInstanceInterface;
/** /**
* Module is a unified wrapper that combines both the ModuleInterface instance * Module is a unified wrapper that combines the filesystem module instance and the database module entry.
* (from filesystem) and ModuleEntry (from database) into a single object.
*
* This provides a single source of truth for all module information.
*/ */
class ModuleObject implements JsonSerializable class ModuleObject implements JsonSerializable
{ {
@@ -32,6 +29,9 @@ class ModuleObject implements JsonSerializable
return [ return [
'id' => $this->id(), 'id' => $this->id(),
'handle' => $this->handle(), 'handle' => $this->handle(),
'label' => $this->label(),
'description' => $this->description(),
'author' => $this->author(),
'version' => $this->version(), 'version' => $this->version(),
'namespace' => $this->namespace(), 'namespace' => $this->namespace(),
'installed' => $this->installed(), 'installed' => $this->installed(),
@@ -86,6 +86,21 @@ class ModuleObject implements JsonSerializable
return null; return null;
} }
public function label(): string
{
return $this->instance?->label() ?? '';
}
public function description(): string
{
return $this->instance?->description() ?? '';
}
public function author(): string
{
return $this->instance?->author() ?? '';
}
public function version(): string public function version(): string
{ {
// Prefer current version from filesystem // Prefer current version from filesystem
+18 -4
View File
@@ -3,6 +3,7 @@
namespace KTXC\Module\Store; namespace KTXC\Module\Store;
use KTXC\Db\DataStore; use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
class ModuleStore class ModuleStore
{ {
@@ -13,9 +14,18 @@ class ModuleStore
protected readonly DataStore $dataStore protected readonly DataStore $dataStore
) { } ) { }
public function list(): array public function list(bool|null $installed = null, bool|null $enabled = null): array
{ {
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]); $filter = [];
if ($installed !== null) {
$filter['installed'] = $installed;
}
if ($enabled !== null) {
$filter['enabled'] = $enabled;
}
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filter);
$modules = []; $modules = [];
foreach ($cursor as $entry) { foreach ($cursor as $entry) {
$entity = new ModuleEntry(); $entity = new ModuleEntry();
@@ -52,7 +62,11 @@ class ModuleStore
{ {
$id = $entry->getId(); $id = $entry->getId();
if (!$id) { return null; } if (!$id) { return null; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
$data = $entry->jsonSerialize();
unset($data['id']);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $data]);
return $entry; return $entry;
} }
@@ -60,7 +74,7 @@ class ModuleStore
{ {
$id = $entry->getId(); $id = $entry->getId();
if (!$id) { return; } if (!$id) { return; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
} }
} }
+45 -9
View File
@@ -4,9 +4,11 @@ namespace KTXC\Routing;
use DI\Attribute\Inject; use DI\Attribute\Inject;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestInputParameters;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\Injection\Container; use KTXC\Injection\Container;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use KTXF\Routing\Attributes\AnonymousPrefixRoute;
use KTXF\Routing\Attributes\AnonymousRoute; use KTXF\Routing\Attributes\AnonymousRoute;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -19,6 +21,8 @@ class Router
private Container $container; private Container $container;
/** @var array<string,array<string,Route>> */ /** @var array<string,array<string,Route>> */
private array $routes = []; // [method][path] => Route private array $routes = []; // [method][path] => Route
/** @var array<string,array<string,Route>> prefix routes [method][prefix] => Route */
private array $prefixRoutes = [];
private bool $initialized = false; private bool $initialized = false;
private string $cacheFile; private string $cacheFile;
@@ -40,8 +44,9 @@ class Router
// load cached routes in production // load cached routes in production
if ($this->environment === 'prod' && file_exists($this->cacheFile)) { if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
$data = include $this->cacheFile; $data = include $this->cacheFile;
if (is_array($data)) { if (is_array($data) && isset($data['routes'])) {
$this->routes = $data; $this->routes = $data['routes'];
$this->prefixRoutes = $data['prefix'] ?? [];
$this->initialized = true; $this->initialized = true;
return; return;
} }
@@ -52,7 +57,8 @@ class Router
// write cache // write cache
$dir = dirname($this->cacheFile); $dir = dirname($this->cacheFile);
if (!is_dir($dir)) @mkdir($dir, 0775, true); if (!is_dir($dir)) @mkdir($dir, 0775, true);
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';'); $cache = ['routes' => $this->routes, 'prefix' => $this->prefixRoutes];
file_put_contents($this->cacheFile, '<?php return ' . var_export($cache, true) . ';');
} }
@@ -95,13 +101,17 @@ class Router
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
$attributes = array_merge( $attributes = array_merge(
$reflectionMethod->getAttributes(AnonymousRoute::class), $reflectionMethod->getAttributes(AnonymousRoute::class),
$reflectionMethod->getAttributes(AuthenticatedRoute::class) $reflectionMethod->getAttributes(AuthenticatedRoute::class),
$reflectionMethod->getAttributes(AnonymousPrefixRoute::class),
); );
foreach ($attributes as $attribute) { foreach ($attributes as $attribute) {
$route = $attribute->newInstance(); $route = $attribute->newInstance();
$httpPath = $routePrefix . $route->path; $isPrefix = $route instanceof AnonymousPrefixRoute;
$httpPath = ($isPrefix && $route->absolute)
? $route->path
: $routePrefix . $route->path;
foreach ($route->methods as $httpMethod) { foreach ($route->methods as $httpMethod) {
$this->routes[$httpMethod][$httpPath] = new Route( $routeObject = new Route(
method: $httpMethod, method: $httpMethod,
path: $httpPath, path: $httpPath,
name: $route->name, name: $route->name,
@@ -111,6 +121,11 @@ class Router
classMethodParameters: $reflectionMethod->getParameters(), classMethodParameters: $reflectionMethod->getParameters(),
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [], permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
); );
if ($isPrefix) {
$this->prefixRoutes[$httpMethod][$httpPath] = $routeObject;
} else {
$this->routes[$httpMethod][$httpPath] = $routeObject;
}
} }
} }
} }
@@ -123,7 +138,7 @@ class Router
/** /**
* Match a Request to a Route, or return null if no match. * Match a Request to a Route, or return null if no match.
* Supports exact matches and simple {param} patterns. * Supports exact matches and simple {param} patterns.
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns * Prioritizes: 1) exact matches, 2) specific patterns, 3) prefix routes, 4) catch-all patterns
*/ */
public function match(Request $request): ?Route public function match(Request $request): ?Route
{ {
@@ -161,6 +176,20 @@ class Router
return $routeObj->withParams($params); return $routeObj->withParams($params);
} }
} }
// Try prefix routes before catch-all — longest matching prefix wins
$bestPrefix = null;
$bestRoute = null;
foreach ($this->prefixRoutes[$method] ?? [] as $prefix => $routeObj) {
if (str_starts_with($path, $prefix) || str_starts_with($path . '/', $prefix)) {
if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) {
$bestPrefix = $prefix;
$bestRoute = $routeObj;
}
}
}
if ($bestRoute !== null) {
return $bestRoute;
}
// Try catch-all pattern last // Try catch-all pattern last
if ($catchAllPattern !== null) { if ($catchAllPattern !== null) {
[$routePath, $routeObj] = $catchAllPattern; [$routePath, $routeObj] = $catchAllPattern;
@@ -179,7 +208,8 @@ class Router
/** /**
* Dispatch a matched route meta and return a Response (or null if controller does not return one). * Dispatch a matched route meta and return a Response (or null if controller does not return one).
* Performs light argument resolution: Request object, route params, body fields, full body for array params. * Performs light argument resolution: Request object, route params, body fields, query params, full body for array params.
* Precedence on name collision: route params > body fields > query params > parameter default.
*/ */
public function dispatch(Route $route, Request $request): ?Response public function dispatch(Route $route, Request $request): ?Response
{ {
@@ -196,7 +226,8 @@ class Router
try { try {
$requestParameters = $request->getPayload(); $requestParameters = $request->getPayload();
} catch (\Throwable) { } catch (\Throwable) {
// ignore payload errors // ignore payload errors, fall back to an empty parameter bag
$requestParameters = new RequestInputParameters([]);
} }
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod); $reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
$routeParams = $route->params ?? []; $routeParams = $route->params ?? [];
@@ -230,6 +261,11 @@ class Router
$callArgs[] = $requestParameters->get($reflectionParameterName); $callArgs[] = $requestParameters->get($reflectionParameterName);
continue; continue;
} }
// if method parameter matches a query string param, use that
if ($request->query->has($reflectionParameterName)) {
$callArgs[] = $request->query->get($reflectionParameterName);
continue;
}
// if method parameter did not match, but has a default value, use that // if method parameter did not match, but has a default value, use that
if ($reflectionParameter->isDefaultValueAvailable()) { if ($reflectionParameter->isDefaultValueAvailable()) {
$callArgs[] = $reflectionParameter->getDefaultValue(); $callArgs[] = $reflectionParameter->getDefaultValue();
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Console;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Kernel;
use KTXC\KernelInterface;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class ConsoleRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
) {
}
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
{
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::cli(),
function () use ($input, $output): int {
$container = $this->kernel->container();
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
$console->setAutoExit(false);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
foreach ($instance->registerCI() as $commandClass) {
$this->registerCommand($console, $container, $commandClass);
}
}
return $console->run($input, $output);
}
);
}
/**
* @param class-string $commandClass
*/
private function registerCommand(ConsoleApplication $console, ContainerInterface $container, string $commandClass): void {
if (!class_exists($commandClass)) {
throw new \RuntimeException("Command class not found: {$commandClass}");
}
$reflection = new \ReflectionClass($commandClass);
$attributes = $reflection->getAttributes(AsCommand::class);
if ($attributes === []) {
throw new \RuntimeException("Command {$commandClass} is missing #[AsCommand].");
}
$attribute = $attributes[0]->newInstance();
$console->add(new LazyCommand(
$attribute->name,
[],
$attribute->description ?? '',
$attribute->hidden ?? false,
static fn() => $container->get($commandClass),
));
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Http;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\KernelInterface;
final class HttpRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly bool $debug,
) {
}
public function run(?Request $request = null, bool $send = true): Response
{
$request ??= Request::createFromGlobals();
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::http(),
function () use ($request, $send): Response {
$response = $this->pipeline()->handle($request);
if ($send) {
$response->send();
}
return $response;
},
function (\Throwable $error) use ($send): Response {
$response = $this->errorResponse($error);
if ($send) {
$response->send();
}
return $response;
},
);
}
private function pipeline(): MiddlewarePipeline
{
$pipeline = new MiddlewarePipeline($this->kernel->container());
$pipeline->pipe(TenantMiddleware::class);
$pipeline->pipe(FirewallMiddleware::class);
$pipeline->pipe(AuthenticationMiddleware::class);
$pipeline->pipe(RouterMiddleware::class);
return $pipeline;
}
private function errorResponse(\Throwable $error): Response
{
error_log(sprintf(
'Application error: %s in %s:%d',
$error->getMessage(),
$error->getFile(),
$error->getLine(),
));
$content = $this->debug
? '<pre>' . htmlspecialchars((string) $error) . '</pre>'
: 'An error occurred. Please try again later.';
return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
}
}
+17 -10
View File
@@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Service\TokenService; use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface;
@@ -26,13 +26,13 @@ class AuthenticationManager
private string $securityCode; private string $securityCode;
public function __construct( public function __construct(
private readonly SessionTenant $tenant, private readonly TenantContextInterface $tenantContext,
private readonly EphemeralCacheInterface $cache, private readonly EphemeralCacheInterface $cache,
private readonly ProviderManager $providerManager, private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService, private readonly TokenService $tokenService,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
) { ) {
$this->securityCode = $this->tenant->configuration()->security()->code(); $this->securityCode = $this->tenantContext->configuration()->security()->code();
} }
// ========================================================================= // =========================================================================
@@ -75,7 +75,7 @@ class AuthenticationManager
$methods = $this->methodsConfigured(); $methods = $this->methodsConfigured();
$session = AuthenticationSession::create( $session = AuthenticationSession::create(
$this->tenant->identifier(), $this->tenantContext->identifier(),
AuthenticationSession::STATE_FRESH AuthenticationSession::STATE_FRESH
); );
@@ -103,7 +103,7 @@ class AuthenticationManager
// Filter to non-redirect methods since redirects don't need identity first // Filter to non-redirect methods since redirects don't need identity first
$methods = $this->methodsConfigured(); $methods = $this->methodsConfigured();
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); $methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect'));
$require = $this->tenant->configuration()->authentication()->methodsMinimal(); $require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
// Store identity in session without validating to prevent enumeration // Store identity in session without validating to prevent enumeration
$session->setMethods(array_column($methods, 'id'), $require); $session->setMethods(array_column($methods, 'id'), $require);
@@ -155,6 +155,13 @@ class AuthenticationManager
); );
} }
if ($session->userIdentifier === null && $session->userIdentity) {
$user = $this->userService->fetchByIdentity($session->userIdentity);
if ($user) {
$session->userIdentifier = $user->getId();
}
}
// Build provider context // Build provider context
$context = $this->buildProviderContext($session, $method); $context = $this->buildProviderContext($session, $method);
@@ -422,7 +429,7 @@ class AuthenticationManager
$session->methodCompleted($method); $session->methodCompleted($method);
// Check if MFA is required // Check if MFA is required
$require = $this->tenant->configuration()->authentication()->methodsMinimal(); $require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
if ($require > 1) { if ($require > 1) {
$remainingMethods = $this->methodsConfigured([$method]); $remainingMethods = $this->methodsConfigured([$method]);
// Filter out redirect methods - they can't be used as secondary factors // Filter out redirect methods - they can't be used as secondary factors
@@ -516,7 +523,7 @@ class AuthenticationManager
$accessToken = $this->tokenService->createToken( $accessToken = $this->tokenService->createToken(
[ [
'tenant' => $this->tenant->identifier(), 'tenant' => $this->tenantContext->identifier(),
'identifier' => $user->getId(), 'identifier' => $user->getId(),
'identity' => $user->getIdentity(), 'identity' => $user->getIdentity(),
'label' => $user->getLabel(), 'label' => $user->getLabel(),
@@ -578,7 +585,7 @@ class AuthenticationManager
*/ */
private function getProviderConfig(string $method): array private function getProviderConfig(string $method): array
{ {
$providers = $this->tenant->configuration()->authentication()->providers(); $providers = $this->tenantContext->configuration()->authentication()->providers();
return $providers[$method]['config'] ?? []; return $providers[$method]['config'] ?? [];
} }
@@ -628,7 +635,7 @@ class AuthenticationManager
*/ */
private function methodsConfigured(array $methodsCompleted = []): array private function methodsConfigured(array $methodsCompleted = []): array
{ {
$tenantProviders = $this->tenant->configuration()->authentication()->providers(); $tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
$methods = []; $methods = [];
foreach ($tenantProviders as $providerId => $providerConfiguration) { foreach ($tenantProviders as $providerId => $providerConfiguration) {
@@ -662,7 +669,7 @@ class AuthenticationManager
private function createTokens(User $user, bool $mfaVerified = false): array private function createTokens(User $user, bool $mfaVerified = false): array
{ {
$payload = [ $payload = [
'tenant' => $this->tenant->identifier(), 'tenant' => $this->tenantContext->identifier(),
'identifier' => $user->getId(), 'identifier' => $user->getId(),
'identity' => $user->getIdentity(), 'identity' => $user->getIdentity(),
'label' => $user->getLabel(), 'label' => $user->getLabel(),
@@ -2,7 +2,7 @@
namespace KTXC\Security\Authorization; namespace KTXC\Security\Authorization;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
/** /**
* Permission Checker * Permission Checker
@@ -11,7 +11,7 @@ use KTXC\SessionIdentity;
class PermissionChecker class PermissionChecker
{ {
public function __construct( public function __construct(
private readonly SessionIdentity $sessionIdentity private readonly IdentityContextInterface $identityContext
) {} ) {}
/** /**
@@ -24,7 +24,7 @@ class PermissionChecker
*/ */
public function can(string $permission, mixed $resource = null): bool public function can(string $permission, mixed $resource = null): bool
{ {
$identity = $this->sessionIdentity->identity(); $identity = $this->identityContext->identity();
if (!$identity) { if (!$identity) {
return false; return false;
@@ -113,7 +113,7 @@ class PermissionChecker
*/ */
public function getUserPermissions(): array public function getUserPermissions(): array
{ {
$identity = $this->sessionIdentity->identity(); $identity = $this->identityContext->identity();
if (!$identity) { if (!$identity) {
return []; return [];
-92
View File
@@ -1,92 +0,0 @@
<?php
namespace KTXC;
use KTXC\Injection\Container;
/**
* Legacy Server class - now a facade to Application
* @deprecated Use Application class directly
*/
class Server
{
public const ENVIRONMENT_DEV = 'dev';
public const ENVIRONMENT_PROD = 'prod';
/**
* @deprecated Use Application instead
*/
public static function run(): void {
trigger_error('Server::run() is deprecated. Use Application class instead.', E_USER_DEPRECATED);
$projectRoot = dirname(dirname(__DIR__));
$app = new Application($projectRoot);
$app->run();
}
/**
* @deprecated Use Application::getInstance()->environment()
*/
public static function environment(): string {
return self::app()->environment();
}
/**
* @deprecated Use Application::getInstance()->debug()
*/
public static function debug(): bool {
return self::app()->debug();
}
/**
* @deprecated Use Application::getInstance()->kernel()
*/
public static function runtimeKernel(): Kernel {
return self::app()->kernel();
}
/**
* @deprecated Use Application::getInstance()->container()
*/
public static function runtimeContainer(): Container {
return self::app()->container();
}
/**
* @deprecated Use Application::getInstance()->rootDir()
*/
public static function runtimeRootLocation(): string {
return self::app()->rootDir();
}
/**
* @deprecated Use Application::getInstance()->moduleDir()
*/
public static function runtimeModuleLocation(): string {
return self::app()->moduleDir();
}
/**
* @deprecated Use Application::setComposerLoader()
*/
public static function setComposerLoader($loader): void {
Application::setComposerLoader($loader);
}
/**
* @deprecated Use Application::getComposerLoader()
*/
public static function getComposerLoader() {
return Application::getComposerLoader();
}
private static function app(): Application
{
throw new \RuntimeException(
'Server class is deprecated and no longer functional. ' .
'Update your code to use Application class with proper dependency injection. ' .
'See the migration guide for details.'
);
}
}
+14 -14
View File
@@ -5,7 +5,7 @@ namespace KTXC\Service;
use KTXC\Db\DataStore; use KTXC\Db\DataStore;
use KTXC\Db\Collection; use KTXC\Db\Collection;
use KTXC\Db\UTCDateTime; use KTXC\Db\UTCDateTime;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
class ConfigurationService class ConfigurationService
{ {
@@ -24,7 +24,7 @@ class ConfigurationService
public function __construct( public function __construct(
DataStore $store, DataStore $store,
private readonly SessionTenant $tenant private readonly TenantContextInterface $tenantContext
) { ) {
// DataStore provides selectCollection method // DataStore provides selectCollection method
$this->collection = $store->selectCollection(self::TABLE_NAME); $this->collection = $store->selectCollection(self::TABLE_NAME);
@@ -36,10 +36,10 @@ class ConfigurationService
*/ */
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
{ {
if ($tenant === null && !$this->tenant->isConfigured()) { if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]); $doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
@@ -54,10 +54,10 @@ class ConfigurationService
*/ */
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
{ {
if ($tenant === null && !$this->tenant->isConfigured()) { if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$type = $this->determineType($value); $type = $this->determineType($value);
@@ -84,10 +84,10 @@ class ConfigurationService
*/ */
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
{ {
if ($tenant === null && !$this->tenant->isConfigured()) { if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$filter = ['did' => $tenant]; $filter = ['did' => $tenant];
@@ -116,10 +116,10 @@ class ConfigurationService
*/ */
public function delete(string $path, string $key, ?string $tenant = null): bool public function delete(string $path, string $key, ?string $tenant = null): bool
{ {
if ($tenant === null && !$this->tenant->isConfigured()) { if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]); $this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
@@ -131,10 +131,10 @@ class ConfigurationService
*/ */
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
{ {
if ($tenant === null && !$this->tenant->isConfigured()) { if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$filter = ['did' => $tenant]; $filter = ['did' => $tenant];
@@ -155,10 +155,10 @@ class ConfigurationService
*/ */
public function exists(string $path, string $key, ?string $tenant = null): bool public function exists(string $path, string $key, ?string $tenant = null): bool
{ {
if ($tenant === null && !$this->tenant->isConfigured()) { if ($tenant === null && !$this->tenantContext->configured()) {
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0; return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
+25 -49
View File
@@ -8,8 +8,8 @@ use KTXC\Http\Request\Request;
use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Event\EventBus; use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent; use KTXF\Event\SecurityEvent;
use KTXF\IpUtils; use KTXF\IpUtils;
@@ -41,33 +41,9 @@ class FirewallService
public function __construct( public function __construct(
private readonly FirewallStore $store, private readonly FirewallStore $store,
private readonly SessionTenant $tenant, private readonly TenantContextInterface $tenantContext,
private readonly EventBus $eventBus private readonly EventDispatcherInterface $events,
) { ) {
// Listen for auth failures to detect brute force
$this->eventBus->subscribe(
SecurityEvent::AUTH_FAILURE,
[$this, 'handleAuthFailure'],
100 // High priority
);
// Log all security events asynchronously
$this->eventBus->subscribeAsync(
SecurityEvent::AUTH_FAILURE,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::AUTH_SUCCESS,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::ACCESS_DENIED,
[$this, 'logSecurityEvent']
);
$this->eventBus->subscribeAsync(
SecurityEvent::BRUTE_FORCE_DETECTED,
[$this, 'logSecurityEvent']
);
} }
/** /**
@@ -100,7 +76,7 @@ class FirewallService
return new FirewallAnalyzeResult(true); return new FirewallAnalyzeResult(true);
} }
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return new FirewallAnalyzeResult(true); return new FirewallAnalyzeResult(true);
} }
@@ -158,7 +134,7 @@ class FirewallService
public function handleAuthFailure(SecurityEvent $event): void public function handleAuthFailure(SecurityEvent $event): void
{ {
$ipAddress = $event->getIpAddress(); $ipAddress = $event->getIpAddress();
$tenantId = $event->getTenantId() ?? $this->tenant->identifier(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) { if (!$ipAddress || !$tenantId) {
return; return;
@@ -198,8 +174,8 @@ class FirewallService
): void { ): void {
// Publish brute force event // Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenant->identifier()); $event->setTenantId($this->tenantContext->identifier());
$this->eventBus->publish($event); $this->events->dispatch($event);
// Auto-block the IP // Auto-block the IP
$blockDuration = $this->getConfig( $blockDuration = $this->getConfig(
@@ -220,7 +196,7 @@ class FirewallService
*/ */
public function logSecurityEvent(SecurityEvent $event): void public function logSecurityEvent(SecurityEvent $event): void
{ {
$tenantId = $event->getTenantId() ?? $this->tenant->identifier(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return; return;
} }
@@ -283,8 +259,8 @@ class FirewallService
$rule->getId(), $rule->getId(),
$rule->getReason() $rule->getReason()
); );
$event->setTenantId($this->tenant->identifier()); $event->setTenantId($this->tenantContext->identifier());
$this->eventBus->publish($event); $this->events->dispatch($event);
} }
// ======================================== // ========================================
@@ -300,7 +276,7 @@ class FirewallService
?string $createdBy = null, ?string $createdBy = null,
?int $durationSeconds = null ?int $durationSeconds = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -340,7 +316,7 @@ class FirewallService
$event->setIpAddress($ipAddress) $event->setIpAddress($ipAddress)
->setReason($reason) ->setReason($reason)
->setTenantId($tenantId); ->setTenantId($tenantId);
$this->eventBus->publish($event); $this->events->dispatch($event);
return $rule; return $rule;
} }
@@ -353,7 +329,7 @@ class FirewallService
?string $reason = null, ?string $reason = null,
?string $createdBy = null ?string $createdBy = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -376,7 +352,7 @@ class FirewallService
$event->setIpAddress($ipAddress) $event->setIpAddress($ipAddress)
->setReason($reason) ->setReason($reason)
->setTenantId($tenantId); ->setTenantId($tenantId);
$this->eventBus->publish($event); $this->events->dispatch($event);
return $rule; return $rule;
} }
@@ -389,7 +365,7 @@ class FirewallService
?string $reason = null, ?string $reason = null,
?string $createdBy = null ?string $createdBy = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -419,7 +395,7 @@ class FirewallService
?string $createdBy = null, ?string $createdBy = null,
?int $durationSeconds = null ?int $durationSeconds = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -448,7 +424,7 @@ class FirewallService
$event->setDeviceFingerprint($fingerprint) $event->setDeviceFingerprint($fingerprint)
->setReason($reason) ->setReason($reason)
->setTenantId($tenantId); ->setTenantId($tenantId);
$this->eventBus->publish($event); $this->events->dispatch($event);
return $rule; return $rule;
} }
@@ -464,7 +440,7 @@ class FirewallService
} }
// Verify tenant ownership // Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) { if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false; return false;
} }
@@ -485,7 +461,7 @@ class FirewallService
} }
// Verify tenant ownership // Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) { if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false; return false;
} }
@@ -501,7 +477,7 @@ class FirewallService
*/ */
public function listRules(bool $activeOnly = true): array public function listRules(bool $activeOnly = true): array
{ {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return []; return [];
} }
@@ -518,7 +494,7 @@ class FirewallService
?string $result = null, ?string $result = null,
int $limit = 100 int $limit = 100
): array { ): array {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return []; return [];
} }
@@ -531,7 +507,7 @@ class FirewallService
*/ */
public function getBlockedCount(?\DateTimeImmutable $since = null): int public function getBlockedCount(?\DateTimeImmutable $since = null): int
{ {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return 0; return 0;
} }
@@ -556,7 +532,7 @@ class FirewallService
*/ */
private function getConfig(string $key, mixed $default = null): mixed private function getConfig(string $key, mixed $default = null): mixed
{ {
$config = $this->tenant->configuration(); $config = $this->tenantContext->configuration();
$parts = explode('.', $key); $parts = explode('.', $key);
foreach ($parts as $part) { foreach ($parts as $part) {
@@ -576,7 +552,7 @@ class FirewallService
private function getActiveRules(): array private function getActiveRules(): array
{ {
if ($this->rulesCache === null) { if ($this->rulesCache === null) {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
$this->rulesCache = $tenantId $this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true) ? $this->store->listRules($tenantId, true)
: []; : [];
+19 -4
View File
@@ -7,7 +7,7 @@ namespace KTXC\Service;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User; use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager; use KTXC\Resource\ProviderManager;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface;
/** /**
@@ -23,12 +23,12 @@ class SecurityService
private string $securityCode; private string $securityCode;
public function __construct( public function __construct(
private readonly SessionTenant $sessionTenant, private readonly TenantContextInterface $tenantContext,
private readonly TokenService $tokenService, private readonly TokenService $tokenService,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly ProviderManager $providerManager, private readonly ProviderManager $providerManager,
) { ) {
$this->securityCode = $this->sessionTenant->configuration()->security()->code(); $this->securityCode = $this->tenantContext->configuration()->security()->code();
} }
/** /**
@@ -118,7 +118,7 @@ class SecurityService
continue; continue;
} }
$context = new \KTXF\Security\Authentication\ProviderContext( $context = new \KTXF\Security\Authentication\ProviderContext(
tenantId: $this->sessionTenant->identifier(), tenantId: $this->tenantContext->identifier(),
userIdentity: $identity, userIdentity: $identity,
); );
$result = $provider->verify($context, $credentials); $result = $provider->verify($context, $credentials);
@@ -142,4 +142,19 @@ class SecurityService
{ {
return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false); return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false);
} }
/**
* Authenticate using a plain-text identity + password credential.
*
* Used by the DAV auth backend which receives credentials from the HTTP
* Basic challenge after they have been extracted from the Authorization header.
*
* @param string $identity Username / e-mail
* @param string $password Plain-text password
* @return User|null Authenticated user, or null on failure
*/
public function authenticatePassword(string $identity, string $password): ?User
{
return $this->authenticateBasic($identity, $password);
}
} }
+60 -3
View File
@@ -7,13 +7,70 @@ use KTXC\Stores\TenantStore;
class TenantService class TenantService
{ {
public function __construct(protected readonly TenantStore $store) public function __construct(
{ protected readonly TenantStore $store,
} ) {}
public function fetchByDomain(string $domain): ?TenantObject public function fetchByDomain(string $domain): ?TenantObject
{ {
return $this->store->fetchByDomain($domain); return $this->store->fetchByDomain($domain);
} }
public function fetchById(string $identifier): ?TenantObject
{
return $this->store->fetch($identifier);
}
/**
* List all tenants keyed by id
*
* @return array<string, TenantObject>
*/
public function list(): array
{
return $this->store->list();
}
public function deposit(TenantObject $tenant): ?TenantObject
{
$domains = $tenant->getDomains();
if ($domains === null || count($domains) === 0) {
throw new \InvalidArgumentException('A tenant must have at least one domain.');
}
return $this->store->deposit($tenant);
}
public function destroy(TenantObject $tenant): void
{
$this->store->destroy($tenant);
}
// =========================================================================
// Settings
// =========================================================================
/**
* Fetch all or a filtered subset of a tenant's settings.
*
* @param string $identifier Tenant identifier
* @param string[] $keys Optional list of keys to return; empty = all
*
* @return array<string, mixed>
*/
public function fetchSettings(string $identifier, array $keys = []): array
{
return $this->store->fetchSettings($identifier, $keys);
}
/**
* Merge-update settings for a tenant.
*
* @param string $identifier Tenant identifier
* @param array<string, mixed> $settings Key-value pairs to persist
*/
public function storeSettings(string $identifier, array $settings): bool
{
return $this->store->storeSettings($identifier, $settings);
}
} }
+2 -2
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
@@ -26,7 +26,7 @@ class TokenService
private string $algorithm = 'HS256'; private string $algorithm = 'HS256';
public function __construct( public function __construct(
private readonly SessionTenant $sessionTenant, private readonly TenantContextInterface $tenantContext,
private readonly EphemeralCacheInterface $cache, private readonly EphemeralCacheInterface $cache,
) { ) {
} }
+17 -17
View File
@@ -3,16 +3,16 @@
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\Models\Identity\User; use KTXC\Models\Identity\User;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
class UserAccountsService class UserAccountsService
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsStore $userStore private readonly UserAccountsStore $userStore
) { ) {
} }
@@ -26,7 +26,7 @@ class UserAccountsService
*/ */
public function listUsers(array $filters = []): array public function listUsers(array $filters = []): array
{ {
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters); $users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters);
// Remove sensitive data // Remove sensitive data
foreach ($users as &$user) { foreach ($users as &$user) {
@@ -38,7 +38,7 @@ class UserAccountsService
public function fetchByIdentity(string $identifier): User | null public function fetchByIdentity(string $identifier): User | null
{ {
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); $data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
if (!$data) { if (!$data) {
return null; return null;
} }
@@ -50,32 +50,32 @@ class UserAccountsService
public function fetchByIdentifier(string $identifier): array | null public function fetchByIdentifier(string $identifier): array | null
{ {
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier); return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier);
} }
public function fetchByIdentityRaw(string $identifier): array | null public function fetchByIdentityRaw(string $identifier): array | null
{ {
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
} }
public function fetchByProviderSubject(string $provider, string $subject): ?array public function fetchByProviderSubject(string $provider, string $subject): ?array
{ {
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject); return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject);
} }
public function createUser(array $userData): array public function createUser(array $userData): array
{ {
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData); return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
} }
public function updateUser(string $uid, array $updates): bool public function updateUser(string $uid, array $updates): bool
{ {
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates); return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
} }
public function deleteUser(string $uid): bool public function deleteUser(string $uid): bool
{ {
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid); return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
} }
// ========================================================================= // =========================================================================
@@ -84,7 +84,7 @@ class UserAccountsService
public function fetchProfile(string $uid): ?array public function fetchProfile(string $uid): ?array
{ {
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid); return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid);
} }
public function storeProfile(string $uid, array $profileFields): bool public function storeProfile(string $uid, array $profileFields): bool
@@ -109,21 +109,21 @@ class UserAccountsService
return false; return false;
} }
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields); return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields);
} }
// ========================================================================= // =========================================================================
// Settings Operations // Settings Operations
// ========================================================================= // =========================================================================
public function fetchSettings(array $settings = []): array | null public function fetchSettings(array $settings = [], bool $flatten = false): array | null
{ {
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten);
} }
public function storeSettings(array $settings): bool public function storeSettings(array $settings): bool
{ {
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
} }
// ========================================================================= // =========================================================================
+12 -12
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
class UserRolesService class UserRolesService
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly UserRolesStore $roleStore, private readonly UserRolesStore $roleStore,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
@@ -26,7 +26,7 @@ class UserRolesService
*/ */
public function listRoles(): array public function listRoles(): array
{ {
return $this->roleStore->listRoles($this->tenantIdentity->identifier()); return $this->roleStore->listRoles($this->tenantContext->identifier());
} }
/** /**
@@ -34,7 +34,7 @@ class UserRolesService
*/ */
public function getRole(string $rid): ?array public function getRole(string $rid): ?array
{ {
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid); return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid);
} }
/** /**
@@ -45,11 +45,11 @@ class UserRolesService
$this->validateRoleData($roleData); $this->validateRoleData($roleData);
$this->logger->info('Creating role', [ $this->logger->info('Creating role', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'label' => $roleData['label'] ?? 'Unnamed' 'label' => $roleData['label'] ?? 'Unnamed'
]); ]);
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData); return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData);
} }
/** /**
@@ -70,11 +70,11 @@ class UserRolesService
$this->validateRoleData($updates, false); $this->validateRoleData($updates, false);
$this->logger->info('Updating role', [ $this->logger->info('Updating role', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'rid' => $rid 'rid' => $rid
]); ]);
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates); return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates);
} }
/** /**
@@ -93,17 +93,17 @@ class UserRolesService
} }
// Check if role is assigned to users // Check if role is assigned to users
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); $userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
if ($userCount > 0) { if ($userCount > 0) {
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)"); throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
} }
$this->logger->info('Deleting role', [ $this->logger->info('Deleting role', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'rid' => $rid 'rid' => $rid
]); ]);
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid); return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid);
} }
/** /**
@@ -111,7 +111,7 @@ class UserRolesService
*/ */
public function getRoleUserCount(string $rid): int public function getRoleUserCount(string $rid): int
{ {
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
} }
/** /**
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace KTXC;
use KTXC\Models\Identity\User;
class SessionIdentity
{
private bool $identityLock = false;
private ?User $identityData = null;
public function initialize(User $identity, bool $lock = true): void
{
if ($this->identityLock) {
throw new \RuntimeException('Identity is already locked and cannot be changed.');
}
$this->identityData = $identity;
$this->identityLock = $lock;
}
public function identity(): ?User
{
return $this->identityData;
}
public function identifier(): ?string
{
return $this->identityData?->getId();
}
public function label(): ?string
{
return $this->identityData?->getLabel();
}
public function mailAddress(): ?string
{
return $this->identityData?->getEmail();
}
public function nameFirst(): ?string
{
return $this->identityData?->getFirstName();
}
public function nameLast(): ?string
{
return $this->identityData?->getLastName();
}
public function permissions(): array
{
return $this->identityData?->getPermissions() ?? [];
}
public function roles(): array
{
return $this->identityData?->getRoles() ?? [];
}
public function hasPermission(string $permission): bool
{
$permissions = $this->permissions();
// Exact match
if (in_array($permission, $permissions)) {
return true;
}
// Wildcard match
foreach ($permissions as $userPerm) {
if (str_ends_with($userPerm, '.*')) {
$prefix = substr($userPerm, 0, -2);
if (str_starts_with($permission, $prefix . '.')) {
return true;
}
}
}
// Full wildcard
if (in_array('*', $permissions)) {
return true;
}
return false;
}
public function hasRole(string $role): bool
{
return in_array($role, $this->roles());
}
}
-125
View File
@@ -1,125 +0,0 @@
<?php
namespace KTXC;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
class SessionTenant
{
private ?TenantObject $tenant = null;
private ?string $domain = null;
private bool $configured = false;
public function __construct(
private readonly TenantService $tenantService
) {}
/**
* Configure the tenant information
* This method is called by the SecurityMiddleware after validation
*/
public function configure(string $domain): void
{
if ($this->configured) {
return;
}
$tenant = $this->tenantService->fetchByDomain($domain);
if ($tenant) {
$this->domain = $domain;
$this->tenant = $tenant;
$this->configured = true;
} else {
$this->domain = null;
$this->tenant = null;
$this->configured = false;
}
}
/**
* Is the tenant configured
*/
public function configured(): bool
{
return $this->configured;
}
/**
* Is the tenant enabled
*/
public function enabled(): bool
{
return $this->tenant?->getEnabled() ?? false;
}
/**
* Current tenant domain
*/
public function domain(): ?string
{
return $this->domain;
}
/**
* Current tenant identifier
*/
public function identifier(): ?string
{
return $this->tenant?->getIdentifier();
}
/**
* Current tenant label
*/
public function label(): ?string
{
return $this->tenant?->getLabel();
}
/**
* Current tenant configuration
*/
public function configuration(): TenantConfiguration
{
return $this->tenant?->getConfiguration();
}
/**
* Current tenant settings
*/
public function settings(): array
{
return $this->tenant?->getSettings() ?? [];
}
/**
* Get all identity providers configuration for this tenant
* @return array<string, array> Map of provider ID to provider config
*/
public function identityProviders(): array
{
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
}
/**
* Get configuration for a specific identity provider
*
* @param string $providerId Provider identifier (e.g., 'default', 'oidc')
* @return array|null Provider configuration or null if not found
*/
public function identityProviderConfig(string $providerId): ?array
{
$providers = $this->identityProviders();
return $providers[$providerId] ?? null;
}
/**
* Check if an identity provider is enabled for this tenant
*/
public function isIdentityProviderEnabled(string $providerId): bool
{
$config = $this->identityProviderConfig($providerId);
return $config !== null && ($config['enabled'] ?? false);
}
}
+61 -3
View File
@@ -3,6 +3,7 @@
namespace KTXC\Stores; namespace KTXC\Stores;
use KTXC\Db\DataStore; use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
use KTXC\Models\Tenant\TenantObject; use KTXC\Models\Tenant\TenantObject;
class TenantStore class TenantStore
@@ -52,7 +53,9 @@ class TenantStore
private function create(TenantObject $entry): ?TenantObject private function create(TenantObject $entry): ?TenantObject
{ {
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize()); $document = $entry->jsonSerialize();
unset($document['id']);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
$entry->setId((string)$result->getInsertedId()); $entry->setId((string)$result->getInsertedId());
return $entry; return $entry;
} }
@@ -61,7 +64,9 @@ class TenantStore
{ {
$id = $entry->getId(); $id = $entry->getId();
if (!$id) { return null; } if (!$id) { return null; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); $document = $entry->jsonSerialize();
unset($document['id']);
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $document]);
return $entry; return $entry;
} }
@@ -69,7 +74,60 @@ class TenantStore
{ {
$id = $entry->getId(); $id = $entry->getId();
if (!$id) { return; } if (!$id) { return; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
}
// =========================================================================
// Settings Operations
// =========================================================================
/**
* Fetch settings for a tenant, optionally filtered to specific keys.
*
* @param string $identifier Tenant identifier
* @param string[] $keys Optional list of keys to return; empty = all
*
* @return array<string, mixed>
*/
public function fetchSettings(string $identifier, array $keys = []): array
{
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
['identifier' => $identifier],
['projection' => ['settings' => 1]]
);
$settings = (array)($entry['settings'] ?? []);
if (empty($keys)) {
return $settings;
}
$result = [];
foreach ($keys as $key) {
$result[$key] = $settings[$key] ?? null;
}
return $result;
}
/**
* Merge-update settings for a tenant using atomic $set on sub-fields.
*
* @param string $identifier Tenant identifier
* @param array<string, mixed> $settings Key-value pairs to persist
*/
public function storeSettings(string $identifier, array $settings): bool
{
$updates = [];
foreach ($settings as $key => $value) {
$updates["settings.{$key}"] = $value;
}
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['identifier' => $identifier],
['$set' => $updates]
);
return $result->getMatchedCount() > 0;
} }
} }
+54 -9
View File
@@ -177,8 +177,8 @@ class UserAccountsStore
$userData['uid'] = $userData['uid'] ?? UUID::v4(); $userData['uid'] = $userData['uid'] ?? UUID::v4();
$userData['enabled'] = $userData['enabled'] ?? true; $userData['enabled'] = $userData['enabled'] ?? true;
$userData['roles'] = $userData['roles'] ?? []; $userData['roles'] = $userData['roles'] ?? [];
$userData['profile'] = $userData['profile'] ?? []; $userData['profile'] = (object) ($userData['profile'] ?? []);
$userData['settings'] = $userData['settings'] ?? []; $userData['settings'] = (object) ($userData['settings'] ?? []);
$this->store->selectCollection('user_accounts')->insertOne($userData); $this->store->selectCollection('user_accounts')->insertOne($userData);
@@ -232,16 +232,25 @@ class UserAccountsStore
return false; return false;
} }
$collection = $this->store->selectCollection('user_accounts');
// Repair legacy documents where 'profile' was stored as an empty BSON array:
// dot-notation $set below cannot add a named field to an array.
$collection->updateOne(
['tid' => $tenant, 'uid' => $uid, 'profile' => []],
['$set' => ['profile' => (object) []]]
);
$updates = []; $updates = [];
foreach ($profileFields as $key => $value) { foreach ($profileFields as $key => $value) {
$updates["profile.{$key}"] = $value; $updates["profile.{$key}"] = $value;
} }
$result = $this->store->selectCollection('user_accounts')->updateOne( $result = $collection->updateOne(
['tid' => $tenant, 'uid' => $uid], ['tid' => $tenant, 'uid' => $uid],
['$set' => $updates] ['$set' => $updates]
); );
return $result->getModifiedCount() > 0; return $result->getModifiedCount() > 0;
} }
@@ -249,7 +258,7 @@ class UserAccountsStore
// Settings Operations // Settings Operations
// ========================================================================= // =========================================================================
public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array public function fetchSettings(string $tenant, string $uid, array $settings = [], bool $flatten = false): ?array
{ {
// Only fetch the settings field from the database // Only fetch the settings field from the database
$user = $this->store->selectCollection('user_accounts')->findOne( $user = $this->store->selectCollection('user_accounts')->findOne(
@@ -264,12 +273,39 @@ class UserAccountsStore
$userSettings = $user['settings'] ?? []; $userSettings = $user['settings'] ?? [];
if (empty($settings)) { if (empty($settings)) {
return $userSettings; return $flatten ? $this->flattenSettings($userSettings) : $userSettings;
} }
// Specific keys are always returned in dot-notation (already flat)
$result = []; $result = [];
foreach ($settings as $key) { foreach ($settings as $key) {
$result[$key] = $userSettings[$key] ?? null; $parts = explode('.', $key);
$value = $userSettings;
foreach ($parts as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
$value = null;
break;
}
$value = $value[$part];
}
$result[$key] = $value;
}
return $result;
}
/**
* Recursively flatten a nested settings array into dot-notation keys.
*/
private function flattenSettings(array $settings, string $prefix = ''): array
{
$result = [];
foreach ($settings as $key => $value) {
$fullKey = $prefix !== '' ? "{$prefix}.{$key}" : (string) $key;
if (is_array($value)) {
$result = array_merge($result, $this->flattenSettings($value, $fullKey));
} else {
$result[$fullKey] = $value;
}
} }
return $result; return $result;
} }
@@ -280,16 +316,25 @@ class UserAccountsStore
return false; return false;
} }
$collection = $this->store->selectCollection('user_accounts');
// Repair legacy documents where 'settings' was stored as an empty BSON array:
// dot-notation $set below cannot add a named field to an array.
$collection->updateOne(
['tid' => $tenant, 'uid' => $uid, 'settings' => []],
['$set' => ['settings' => (object) []]]
);
$updates = []; $updates = [];
foreach ($settings as $key => $value) { foreach ($settings as $key => $value) {
$updates["settings.{$key}"] = $value; $updates["settings.{$key}"] = $value;
} }
$result = $this->store->selectCollection('user_accounts')->updateOne( $result = $collection->updateOne(
['tid' => $tenant, 'uid' => $uid], ['tid' => $tenant, 'uid' => $uid],
['$set' => $updates] ['$set' => $updates]
); );
// Return true if document was matched (exists), even if not modified // Return true if document was matched (exists), even if not modified
return $result->getMatchedCount() > 0; return $result->getMatchedCount() > 0;
} }
+3 -16
View File
@@ -1,22 +1,9 @@
<?php <?php
use KTXC\Application; use KTXC\Application;
use KTXC\Module\ModuleAutoloader;
// Capture Composer ClassLoader instance // Capture Composer ClassLoader instance for compatibility
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php'; $composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
// Determine project root (one level up from this file) $application = Application::create(dirname(__DIR__), $composerLoader);
$projectRoot = dirname(__DIR__); $application->runHttp();
// Create and run application
$app = new Application($projectRoot);
// Store composer loader for compatibility
Application::setComposerLoader($composerLoader);
// Register custom module autoloader for lazy loading
$moduleAutoloader = new ModuleAutoloader($app->moduleDir());
$moduleAutoloader->register();
$app->run();
+2 -13
View File
@@ -1,20 +1,9 @@
<template> <template>
<RouterView></RouterView> <RouterView></RouterView>
<SharedSnackbar />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import { onMounted } from 'vue'; import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
import { useTheme } from 'vuetify';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
const theme = useTheme();
const layoutStore = useLayoutStore();
// Apply saved theme on mount
onMounted(() => {
if (layoutStore.theme) {
theme.global.name.value = layoutStore.theme;
}
});
</script> </script>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path d="M3 5H10V10H14V5H22V10H26V5H33V19L18 33L3 19Z" fill="#818CF8"/><path d="M11 14L18 21L25 14V19.5L18 26.5L11 19.5Z" fill="#4F46E5"/></svg>

After

Width:  |  Height:  |  Size: 205 B

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useSnackbar } from '@KTXC/composables/useSnackbar'
const {
snackbarVisible,
snackbarMessage,
snackbarColor,
snackbarTimeout,
hideSnackbar,
} = useSnackbar()
</script>
<template>
<v-snackbar
:model-value="snackbarVisible"
:color="snackbarColor"
:timeout="snackbarTimeout"
location="bottom right"
@update:model-value="value => !value && hideSnackbar()"
>
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="hideSnackbar()">
Close
</v-btn>
</template>
</v-snackbar>
</template>
+2
View File
@@ -3,4 +3,6 @@
*/ */
export { useClipboard } from './useClipboard' export { useClipboard } from './useClipboard'
export { useL10n, t } from './useL10n'
export type { L10N, TranslateParams } from './useL10n'
export { useUser } from './useUser' export { useUser } from './useUser'
+42
View File
@@ -0,0 +1,42 @@
/**
* Translation composable over the shared i18n runtime.
* This is the only surface components and modules use; vue-i18n itself
* stays confined to the plugin.
*/
import { i18n } from '@KTXC/l10n/runtime';
export type TranslateParams = Record<string, unknown>;
export interface L10N {
/**
* Translate a catalog key, rendering the inline English default when the
* key is absent from every loaded catalog. Keys are namespaced with the
* handle bound at useL10n(); the `en` catalog is generated from these
* defaults by scripts/l10n-extract.mjs — keep both literal.
*/
t: (key: string, defaultText: string, params?: TranslateParams) => string;
/** Locale-aware date/time formatting (Intl-backed). */
d: typeof i18n.global.d;
/** Locale-aware number formatting (Intl-backed). */
n: typeof i18n.global.n;
}
/**
* Translate a fully-qualified catalog key (one that already carries its
* namespace, e.g. an integration entry's `l10n` key, which the integration
* store prefixes with the module handle). `defaultText` is rendered when the
* key is undefined or absent from every loaded catalog. Such keys are
* dynamic, so their catalog entries live in the module's hand-maintained
* en.manual.json rather than the extracted catalog.
*/
export function t(key: string | undefined, defaultText?: string, params: TranslateParams = {}): string {
if (!key) return defaultText ?? '';
return i18n.global.t(key, params, { default: defaultText ?? key });
}
export function useL10n(namespace: string): L10N {
const scoped = (key: string, defaultText: string, params: TranslateParams = {}): string =>
t(`${namespace}.${key}`, defaultText, params);
return { t: scoped, d: i18n.global.d, n: i18n.global.n };
}
+45 -5
View File
@@ -2,7 +2,7 @@
* Simple snackbar/toast notification composable * Simple snackbar/toast notification composable
* Uses Vuetify's snackbar component * Uses Vuetify's snackbar component
*/ */
import { ref } from 'vue' import { nextTick, ref } from 'vue'
export interface SnackbarOptions { export interface SnackbarOptions {
message: string message: string
@@ -10,21 +10,60 @@ export interface SnackbarOptions {
timeout?: number timeout?: number
} }
interface SnackbarState {
message: string
color: string
timeout: number
}
const snackbarVisible = ref(false) const snackbarVisible = ref(false)
const snackbarMessage = ref('') const snackbarMessage = ref('')
const snackbarColor = ref<string>('info') const snackbarColor = ref<string>('info')
const snackbarTimeout = ref(3000) const snackbarTimeout = ref(3000)
const snackbarQueue = ref<SnackbarState[]>([])
function showNextSnackbar() {
if (snackbarVisible.value || snackbarQueue.value.length === 0) {
return
}
const nextSnackbar = snackbarQueue.value.shift()
if (!nextSnackbar) {
return
}
snackbarMessage.value = nextSnackbar.message
snackbarColor.value = nextSnackbar.color
snackbarTimeout.value = nextSnackbar.timeout
snackbarVisible.value = true
}
export function useSnackbar() { export function useSnackbar() {
const showSnackbar = (options: SnackbarOptions) => { const showSnackbar = (options: SnackbarOptions) => {
snackbarMessage.value = options.message
snackbarColor.value = options.color || 'info' const notification = {
snackbarTimeout.value = options.timeout || 3000 message: options.message,
snackbarVisible.value = true color: options.color || 'info',
timeout: options.timeout || 3000,
}
if (!snackbarVisible.value && snackbarQueue.value.length === 0) {
snackbarMessage.value = notification.message
snackbarColor.value = notification.color
snackbarTimeout.value = notification.timeout
snackbarVisible.value = true
return
}
snackbarQueue.value = [...snackbarQueue.value, notification]
} }
const hideSnackbar = () => { const hideSnackbar = () => {
snackbarVisible.value = false snackbarVisible.value = false
void nextTick(() => {
showNextSnackbar()
})
} }
return { return {
@@ -32,6 +71,7 @@ export function useSnackbar() {
snackbarMessage, snackbarMessage,
snackbarColor, snackbarColor,
snackbarTimeout, snackbarTimeout,
snackbarQueue,
showSnackbar, showSnackbar,
hideSnackbar, hideSnackbar,
} }
-15
View File
@@ -1,15 +0,0 @@
export type ConfigProps = {
Sidebar_drawer: boolean;
mini_sidebar: boolean;
actTheme: string;
fontTheme: string;
};
const config: ConfigProps = {
Sidebar_drawer: true,
mini_sidebar: false,
actTheme: 'light',
fontTheme: 'Public sans'
};
export default config;

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