Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da646da1d1 | ||
|
|
6f48827f4d |
@@ -42,6 +42,9 @@ jobs:
|
||||
- name: Git-Staging-Filter Regressionstest
|
||||
run: npm run verify:git-staging
|
||||
|
||||
- name: Git-Ignore-Grenzen für Quell- und Runtimeordner prüfen
|
||||
run: npm run verify:ignore-boundaries
|
||||
|
||||
- name: Syntaxprüfung
|
||||
run: npm run check
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ jobs:
|
||||
npm run verify:git
|
||||
npm run verify:git-regression
|
||||
npm run verify:git-staging
|
||||
npm run verify:ignore-boundaries
|
||||
npm run check
|
||||
npm run verify:source-boundaries
|
||||
npm run verify:esm-exports
|
||||
@@ -135,7 +136,7 @@ jobs:
|
||||
|
||||
- name: Container bauen und veröffentlichen
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -13,22 +13,22 @@
|
||||
node_modules/
|
||||
|
||||
# SQLite runtime data
|
||||
db/*.db
|
||||
db/*.db-shm
|
||||
db/*.db-wal
|
||||
db/*.sqlite
|
||||
db/*.sqlite3
|
||||
/db/*.db
|
||||
/db/*.db-shm
|
||||
/db/*.db-wal
|
||||
/db/*.sqlite
|
||||
/db/*.sqlite3
|
||||
|
||||
# User and runtime data
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
backups/
|
||||
logs/
|
||||
tmp/
|
||||
temp/
|
||||
sessions/
|
||||
data/
|
||||
docker-data/
|
||||
/uploads/*
|
||||
!/uploads/.gitkeep
|
||||
/backups/
|
||||
/logs/
|
||||
/tmp/
|
||||
/temp/
|
||||
/sessions/
|
||||
/data/
|
||||
/docker-data/
|
||||
*.log
|
||||
|
||||
# OS and editor files
|
||||
@@ -43,20 +43,20 @@ compose.override.yaml
|
||||
docker-compose.override.yml
|
||||
|
||||
# Local AI runtimes and downloaded model data
|
||||
local-ai/comfyui/
|
||||
local-ai/downloads/
|
||||
local-ai/extract-temp/
|
||||
local-ai/fashn-vton/.venv/
|
||||
local-ai/fashn-vton/engine/
|
||||
local-ai/fashn-vton/weights/
|
||||
local-ai/fashn-vton/outputs/
|
||||
local-ai/fashn-vton/runtime/
|
||||
local-ai/fashn-vton/hf-cache/
|
||||
local-ai/fashn-vton/fashn-vton-*.zip
|
||||
local-ai/fashn-vton/python311/
|
||||
local-ai/fashn-vton/uv/
|
||||
local-ai/fashn-vton/uv-cache/
|
||||
local-ai/fashn-vton/downloads/
|
||||
/local-ai/comfyui/
|
||||
/local-ai/downloads/
|
||||
/local-ai/extract-temp/
|
||||
/local-ai/fashn-vton/.venv/
|
||||
/local-ai/fashn-vton/engine/
|
||||
/local-ai/fashn-vton/weights/
|
||||
/local-ai/fashn-vton/outputs/
|
||||
/local-ai/fashn-vton/runtime/
|
||||
/local-ai/fashn-vton/hf-cache/
|
||||
/local-ai/fashn-vton/fashn-vton-*.zip
|
||||
/local-ai/fashn-vton/python311/
|
||||
/local-ai/fashn-vton/uv/
|
||||
/local-ai/fashn-vton/uv-cache/
|
||||
/local-ai/fashn-vton/downloads/
|
||||
|
||||
# Historical handover and feature-package archives (never source)
|
||||
/Archiv/
|
||||
@@ -64,32 +64,32 @@ local-ai/fashn-vton/downloads/
|
||||
/archives/
|
||||
|
||||
# Local update, rollback and slice archives
|
||||
updates/
|
||||
/updates/
|
||||
|
||||
# Operations Center runtime
|
||||
operations/staging/
|
||||
operations/updates/
|
||||
operations/rollback/
|
||||
operations/apply-*/
|
||||
operations/pending-operation.json
|
||||
operations/last-operation.json
|
||||
/operations/staging/
|
||||
/operations/updates/
|
||||
/operations/rollback/
|
||||
/operations/apply-*/
|
||||
/operations/pending-operation.json
|
||||
/operations/last-operation.json
|
||||
|
||||
# Deployment assistant state
|
||||
.vendoo-install-mode
|
||||
runtime/
|
||||
/.vendoo-install-mode
|
||||
/runtime/
|
||||
# Generated deployment and release output
|
||||
release-output/
|
||||
/release-output/
|
||||
*.release.zip
|
||||
*.release.sha256.txt
|
||||
.vendoo-github-state.json
|
||||
/.vendoo-github-state.json
|
||||
|
||||
# First-run and local installer state
|
||||
operations/first-admin-code.txt
|
||||
config/local/
|
||||
/operations/first-admin-code.txt
|
||||
/config/local/
|
||||
|
||||
|
||||
# Installed Vendoo module packages (runtime)
|
||||
modules/
|
||||
/modules/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix
|
||||
|
||||
- Root-Runtimeordner in `.gitignore` sind jetzt mit `/` verankert.
|
||||
- `app/modules/` und `app/modules/sessions/` werden nicht mehr durch `modules/` beziehungsweise `sessions/` ausgeblendet.
|
||||
- Neues echtes Git-Regression-Gate prüft `git check-ignore`, `git add --all` und `git ls-files`.
|
||||
- GitHub-Deploy-Assistent blockiert den Push, wenn produktive Moduldateien fehlen, ignoriert oder nicht getrackt sind.
|
||||
- Keine Datenbankmigration und keine Änderung an Nutzdaten.
|
||||
|
||||
## 1.41.1 – CI Source Root & Archive Exclusion Hotfix
|
||||
|
||||
- ESM-Import-/Exportprüfung auf den produktiven Source-Root begrenzt.
|
||||
|
||||
@@ -1063,3 +1063,11 @@ Neue Kernel-Routen benötigen zwingend einen Modulbesitzer und eine explizite Po
|
||||
- 17 Module, 15 nativ, 2 Legacy Bridges und 38 aufgelöste Capabilities
|
||||
- GHCR-Releasebilder mit versionierten, `stable`- und `latest`-Tags
|
||||
|
||||
|
||||
|
||||
## 1.41.2 – Git Ignore Boundary & Module Tracking
|
||||
|
||||
- Root-verankerte Runtime-Ignores
|
||||
- `app/modules/` und `app/modules/sessions/` bleiben versionierter Source
|
||||
- Git-Ignore-Semantiktest mit echtem temporärem Repository
|
||||
- Pflichtdatei- und Tracking-Gate im GitHub-Deploy-Assistenten
|
||||
|
||||
@@ -3,9 +3,9 @@ aa343f8ee7948e6b87603e39cf208f8fd1c42a023f1c2261c708c0cd8a46faba .env.example
|
||||
58e3f4b76cc9398f32dcd5481b2080e7e5ba322aca04b8b8374446299c5d7dd6 .github/CODEOWNERS
|
||||
5bd335eb750d084ca12b6a3b3f35d0cd7bc454ba24ed280187c66acf75eaf396 .github/dependabot.yml
|
||||
f65684e98b01d0e0b6bfe1284b5de3354559e4cd51e634fe9e9e2e5473b890e5 .github/pull_request_template.md
|
||||
c171b46c41f53363139b7ba2b442a6dc4fd2f2807811a2dcc4adbd14844972c1 .github/workflows/ci.yml
|
||||
6b1fc844cf0f220c9791a049bbaf3e53b7a93d1036dbbd5abe1aad2f0f7637fb .github/workflows/release.yml
|
||||
6caf6e9ab52bd7f634994876c359e543cc7bc8bed2f0e7c09b70eee2651e07eb .gitignore
|
||||
505e50970bda1156b91a2f3f5031877495e95b63db06129910ac7326c14c9c92 .github/workflows/ci.yml
|
||||
a5b12e2d6bd326034c5c45ff137b649aee580dccc9cf4a41c9d6cc44e81c1b0f .github/workflows/release.yml
|
||||
851ad84ebaa6d07af5fe975b3461c2402e7315a294ae29f0cd1a5f13cb7fc44f .gitignore
|
||||
9bfa732952ad571d06527b32d96d79133fc23fa607df00de440b2802f543608c .npmrc
|
||||
9d7c2a587c12f2b12ba2923e367ade8cdd4601dcac800ca2ac34afb302e9a6e1 app/architecture-boundaries.json
|
||||
a6bdb9b6fdc97a0b2bbc052bd242a9885a75bcc46ee4f0414d8b7e215b6940de app/contracts/module.schema.json
|
||||
@@ -84,11 +84,11 @@ c6558b8a0cd9d7ef30d0e259747805e3d91a5359c2dbfbd79e6794bb403a9926 app/modules/us
|
||||
8640accd1550932459ecd14738139284cbcdd93f31a61ab6e7748791fc82933f app/modules/users/routes.mjs
|
||||
fc4af51a35fd08289ec4380a78c06b3236a2469041ecfc911b9e0d7d269afa19 app/modules/users/service.mjs
|
||||
c197ce417fbce6dab1d8ee157b0a105a099dd2021ece4dac3891d0b2022a0d58 bootstrap.mjs
|
||||
bee3a2c088e7fc26d4b59429e51f127adf6c0acaefb9ee28a9e35d349f533de2 CHANGELOG.md
|
||||
c525080bcf08aa43d694f5075c43c7ad9c06901b0aed453695b343393f7a8b9f CHANGELOG.md
|
||||
61823fc9fdc036170cce6edeecbb612b442d1c65fc9f7a7eaba47e5016316ee5 CLAUDE.md
|
||||
61199cecda2c84954de2b60c07eb9f3a2117fbaaa4c430319c461b2e76a10254 compose.bind-mounts.example.yaml
|
||||
7cb13c091d0ee663ca6a5c004735bf1fdb1a4507ad967ddd2e6624dd9d47faf1 compose.vps.yaml
|
||||
a2a091aa0c39059b48aa12403ba22ed017266d310d9b21c192211d6f3f926356 compose.yaml
|
||||
b8167851bcdbefa4a22e143cc6de8ab4441d3970752629bb2d8fdb03984f2904 compose.vps.yaml
|
||||
b76a40463ce31202dbdc85db371eb9c6fcafce8cd263c685dac990311dbd50f4 compose.yaml
|
||||
6323f984f1e04ab135356a14ec151941638041f6b305ed68772d5e1812972075 db/schema.sql
|
||||
49690c4e4c8e84de8eb40ffe79823493b7be20fe46fa2ad9e6544f7f0afbfd2c deploy/Caddyfile
|
||||
4ffc43e64b0e1e51a97d3f7f6a344ebcff537395008cf909c0fc4e3d3ab0c0cf deploy/coolify.env.example
|
||||
@@ -107,6 +107,7 @@ c050c8e11f2bdb0a742851423dcff864bafba9cc839c52b2f8d48b75f7b18633 docs/ABNAHMEBE
|
||||
513144e961bcc6d3a6c6a5f82b80b24733f20cd2e0d14259b500b426a4f9dee6 docs/ABNAHMEBERICHT_1_40_2.md
|
||||
1439ecb811af31dc2d54ca63f0521ad445e30940fae91ac7d23adc352c8332ad docs/ABNAHMEBERICHT_1_41_0.md
|
||||
b23f235ef4ddf3bbc0fde848fefe9bdcc4b90d1efb25dc61d51cf7cb295415ec docs/ABNAHMEBERICHT_1_41_1.md
|
||||
2f199ee66a97bbec2671d528f3b22c4583530644f7c16a4820fbe9c05e731bbd docs/ABNAHMEBERICHT_1_41_2.md
|
||||
b39d2d892068e1313fc00c9345d54240b6d50caf8a940261a088e67d8778ac63 docs/architecture/CATALOG_MEDIA_MODULES_1_39_0.md
|
||||
e5f065ba8d516a5ebd47e7e6859d0aab40ef69797413e358d969ef27c454fcdd docs/architecture/DESIGN_SYSTEM_1_35_0.md
|
||||
732ef23db1ef50862a423841f58e4628ad3b2a2ef2744ed7c5ef1fe01e3e38d1 docs/architecture/IDENTITY_SETTINGS_MODULES_1_38_0.md
|
||||
@@ -125,6 +126,7 @@ ba9a9b93622fadc399cd9067d80b4ea339193eb19825106264a3b86b7128d107 docs/GENERATOR
|
||||
e5c6ac33f5c0dc93099148d95af123a9aeb72298d885cabf0b1dc96a9cad31d7 docs/GITHUB_IMPORT_STATUS_1_39_0.md
|
||||
509fd2822e4c547459fef9a54054d583dc21adb1b1101eda92603f1cbb783604 docs/GITHUB_IMPORT_STATUS_1_41_0.md
|
||||
8e4ee3c837df6e929a819491b977123872191c8e4f041f8598c9479df10d28fa docs/GITHUB_IMPORT_STATUS_1_41_1.md
|
||||
16effe96702a6951b6cf3bb06da052420a492024c252a88b69256a3f4a92eccf docs/GITHUB_IMPORT_STATUS_1_41_2.md
|
||||
5a25e952d7d79b562bc19c990451830ea2ac3e730c9af399425ea18ffb8fd61d docs/GITHUB_IMPORT_STATUS.md
|
||||
65b7c1cb3a300fa8209628f32d843d1cfba7baaae1f1078bd3cfc1737443df92 docs/GITHUB_MERGE_RELEASE_1_41_0.md
|
||||
65d19c1576f4a6011008a7e447a3530222517752fb59ebc5bbb506b791e32605 docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md
|
||||
@@ -175,6 +177,7 @@ dd77564536e5d85eaa76c7a429a61f73ac78547d72734620bc4102b3452cd7b4 docs/RELEASE_1
|
||||
69b6df0262868a6e6ef81f406ee4c9acd3f662d1f6ee11d7fdd5fea96a2f0de7 docs/RELEASE_1_41_0_COOLIFY_PRODUCTION.md
|
||||
97f7bc395f2ce6bef24140e75dc8956a0d56cbaf15cb94876d4594d7aa54b1db docs/RELEASE_1_41_0_VERIFICATION.json
|
||||
29c795b5599bafdf0f911a4c7ab2844ca3fb311bcdd5f3983c7fa2742110c3ff docs/RELEASE_1_41_1_CI_SOURCE_ROOT_ARCHIVE_EXCLUSION_HOTFIX.md
|
||||
178927940cc1266307f81bbf418870e627d2e52b4956c2d9065c31e04c9bf7dc docs/RELEASE_1_41_2_GIT_IGNORE_BOUNDARY_HOTFIX.md
|
||||
1908713d7458ecacc833faced77984ef5c0155cd61b0eac13a4f874e3f3a5676 docs/ROADMAP_1_38_0.md
|
||||
185b1007c90273126ebe92bffe69f88ae4ec4a3f04451cb9d5d4b772a0482391 docs/ROADMAP_1_39_0.md
|
||||
b581d7d949139f37798dded557235a679621172f444a6271e7fc7ade3020299a docs/ROADMAP_1_40_0.md
|
||||
@@ -299,7 +302,7 @@ e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/saf
|
||||
2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/safari/web-extension/popup.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/safari/web-extension/vinted-categories.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/vinted-categories.js
|
||||
f4caaaad32095dbb6dbb7ef68f1777fdd0a036e1efdfe611446789790021b51d FEATURES.md
|
||||
4be8e17f83ebb805abb1beddf4d7a79e3a44ce58585cb6b8143bb01a47e16c6b FEATURES.md
|
||||
7f78a0d512de717ba76c9a5e3e1bd2e52db9edf146e8eeb7297458fde5d92afa GitHub-Deploy-Assistent.bat
|
||||
9a5bfcafdce53130d7bc36695090a8f87096b4ead9dd5ccc3d46d1d80c07a0fa lib/ai-claude.mjs
|
||||
ad6ba4ef521557d5c06bc546cda70db57e710e8173daadae74e8f15f99836c1c lib/ai-flux-prompt-jobs.mjs
|
||||
@@ -346,15 +349,15 @@ b1c62e118d9be316274e966c558cdcccdd41afb3a9f05a60ec1e4a3433d2ca0c MockupDesign/L
|
||||
a52983b9ea2bf920d115485d10bc30226da88bc5cba8b00bebf801b21c096346 MockupDesign/Publish.png
|
||||
8c8bf278d0c6725e1dbb97b3c3d0c36a9bfc0753c3c86433fdc294bec70dbc0e MockupDesign/Startseite.png
|
||||
62f14221b1d71c9e631efdadd221397ff657adae4b737df52c8bff1610b90999 MockupDesign/Vorlagen.png
|
||||
62daf878d676237dff931c292a79309662176b84f28be9dbd1af8af42827e883 package-lock.json
|
||||
7b4ca7c0bad995c03180c04291f0f71a4a6cd35446b4db96fc33bf0a0dd36930 package.json
|
||||
a111671a21576140185a0c4d01038e200f87cdcbf903efe57bb84f65c972fd1a package-lock.json
|
||||
740a9ee1699aa8e24fab4ab66373bf4322bee0e60c6dd80dbc07e8f9d18a3771 package.json
|
||||
3da71dd28cb25c7c7317b9061855d40c6a3ab8bc39391963b49ee1e707f838e5 platforms/ebay-de.mjs
|
||||
cc1ca2b4384c8c548bf755dc33101d248541bf893c3d5f35369961f904afcc00 platforms/ebay-ka.mjs
|
||||
fa52b247dd758aeee06120499bd1ae6c53e9f460f8bd7762b8f06141b201e32b platforms/etsy.mjs
|
||||
5c925e3934a4c9e2f26dc15b3449a249f5ce20ecebbd459b15365a34804824d6 platforms/vinted.mjs
|
||||
a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/android-chrome-192x192.png
|
||||
46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/android-chrome-512x512.png
|
||||
d06f9ac5e795a2a1c246e89ec69b64d7c90cdf0d34d8d3507ec1950a523ce17c public/app.js
|
||||
cdf390c3ae338cc422ecd5338b3f50adb20fdbf4fe6193f2f4553c096efb2f10 public/app.js
|
||||
e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/apple-touch-icon.png
|
||||
a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/favicons/android-chrome-192x192.png
|
||||
46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/favicons/android-chrome-512x512.png
|
||||
@@ -396,8 +399,8 @@ fb3dbcd03f7ed15b773b582c2a2b785c3040d3319aba5b7c682a7e74e1cf3b4e public/core/sa
|
||||
4d69e2cb6b7f9f63b54d708a5d8b1ebefcdcb9f88a977053db5031c05b098464 public/design-system/tokens/source.json
|
||||
4a9e65edeaee3421edc3dd4831230062494f1a42f8495f4cb979fde8664d06c4 public/design-system/tokens.css
|
||||
15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/favicon.ico
|
||||
b9a6d053dc1b664e515b06e532f454f34be9b82bd95d456218dc0e991b6afcce public/index.html
|
||||
248b411391d654e1d87903cb36d65582acbb08a78dbafe156feaf335c1fbe434 public/login.html
|
||||
6d90d0dc93416395caf6155e5efc13e3e87c9c44b828542983d9033bb7a49e08 public/index.html
|
||||
85a655300980b6ec32b35faadec4b30b85cf45cd5799e8179050ac07a280917d public/login.html
|
||||
e37558ee9612a36db8a19999fbb320f1c249a8e155f94c83a82926bec920f989 public/login.js
|
||||
59291f75eca405e3856bafea76cc601d681060eb6b36bf82a4be3c3ab7eac290 public/mobile-upload-expired.html
|
||||
f046f166d89fc32774b87deb02e6d144fd2f40c0925aac67487e5b7c489a0ec1 public/mobile-upload.css
|
||||
@@ -410,23 +413,23 @@ bf10d5ccc53f287a8b61d488af50c3151c764f36a29ee5d4fe9ee321fa0eda06 public/modules
|
||||
0edc61f0c2bb604c3adc40813b18fe5ca8d9b846ec2a3d9f406ae26ae313d3ee public/modules/theme-manager/theme-manager.js
|
||||
e0a6c981d4231eab26d981be62647d0e6f89e05e8371baad121348202ad54f49 public/style.css
|
||||
ff525e0af963e6f54636cfdc1bd2d045aaf82e7b0199755eb5cdf4c52110be2e public/vendoo-icons.js
|
||||
bfcb47b331d55689921a3839950af20e17e96a6c44eddad5c74a7e342f078aa8 README.md
|
||||
9cf6628dd714347563fdc5925e323c0a274ee5967d78555757be85c56fcdc0b0 README.md
|
||||
f397e46da92394d34c2fc01715f037cca380703ecfa1d4a451de8ec7342e05b3 scripts/docker-backup.sh
|
||||
86a411ae1fa5e36a11b0a8cb61e6c0169e0b80d3cedc9b7b82c3cfff23dd7390 scripts/docker-entrypoint.sh
|
||||
1b58c1e5f762c4928d42605c01872f38f6d6bca92342260f3cd88a132e6af095 scripts/docker-restore.sh
|
||||
be7cfcdd337279294efca8e3c4541e8c60a0d638ae80fe3cfd9021c0d6c326e1 scripts/github-deploy.ps1
|
||||
8f594c7cc7f0d3a00a4167a9e1a9f34173d13d73d08388da0e2c932bbd318302 scripts/github-deploy.sh
|
||||
1720c13fed290c08361266257279e86c8b5909c4b39d3729439335cbcc06a387 scripts/github-deploy.ps1
|
||||
d4d41170106979e1be9266a569215ff484e80452f568f0bf8dd9cdc9978dbb7c scripts/github-deploy.sh
|
||||
0a9bafc79c1c5d59fb9b51ebc34c00eec4a4e9a5e873ce1818f50f3ab983da70 scripts/install-local-flux.ps1
|
||||
2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 scripts/runtime-start-local-flux.ps1
|
||||
7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 scripts/runtime-stop-local-flux.ps1
|
||||
1a5736cf5fccc991df122163977c2eac80281bd99a54a5680b3d7eae99e4b79d scripts/start-local-flux.bat
|
||||
3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd scripts/uninstall-local-flux.ps1
|
||||
af7234a31ef7dca4545c8c071f3773739f856a173fdbf96d0e060cd95c7ade63 SECURITY.md
|
||||
327bb6e7b99939508b807d0c3c6d5617a2d8f0d5464ba0b1865d8e9e1ed82dde SECURITY.md
|
||||
abb508872f30a0d6e4b6d33ab2f4cb14dea11838b15dbe502e6fac2ecc16cf13 server.mjs
|
||||
a8a56c5b7062f4a671317b53e50442b53bc641ff44f32155444b6b605ec263b4 setup-gui.ps1
|
||||
eae7a799aaeec708ea28aca9fc6b08132909cf0b41d412e889064876ce4ba94b setup-gui.ps1
|
||||
cf4519ce5adc01f05458e3286e86f3a419cceaff5fcdd869a1d07f1c1d1a0d8c setup.bat
|
||||
eb20bcc9151a9ed4a039aaebc6f5776223313bd4281972735856aa202fdfc2f2 setup.ps1
|
||||
94f028ca7249592af0fe583d72537afd2fe9cd8e87e6ecae806719293386bc4f setup.sh
|
||||
9433d663b6537ab5bb52a80733b18ac97e932ec50650b2d6bae8d67d5f05cdb4 setup.ps1
|
||||
9cf6480f9eeda7fecc82a6bf1d9bfb3d94b75ca42c92e7697679b9efd8816215 setup.sh
|
||||
2851c914a1b50a036cb265ddd32e5545cc7e8e34ba9d682445926acc23eb8f28 THIRD_PARTY_NOTICES.md
|
||||
90485a16e3468cb9a6750646700fbb036cb9ac410e9b8bd5c582f7b923bf3c31 tools/build-release.mjs
|
||||
94f4ced84834fceeb4f797bf8aab0703c873b57fd5d4a5b3fb02fee12e42bd2e tools/check-syntax.mjs
|
||||
@@ -435,17 +438,21 @@ ac8440bce2659a035302609722f1dfed5d125223376cd575ffc0636397c94b54 tools/generate
|
||||
e3d8c6e8dfd566acbe037ad744edbf60816152d4994698c2a9c46d2b890e246a tools/verify-capability-graph-1.40.2.mjs
|
||||
8916ca58f8f67b7186d4b3644d13a56743965a57a50dc30bb33bd6baa8b2b646 tools/verify-capability-graph-1.41.0.mjs
|
||||
e7cbaaa0ca1f0a655af500aea14044455a80cfae10246716a17488f96e99dae7 tools/verify-capability-graph-1.41.1.mjs
|
||||
62b3e3c998759d59b78ae0a6609f51bf87369880ea1384b274c771d70d2a6476 tools/verify-capability-graph-1.41.2.mjs
|
||||
85114eff887ba65ca7df00f43333414c44b3aadc16fbc2fe10dc4dd4f9aa7aed tools/verify-catalog-media-1.39.0.mjs
|
||||
753288b83a655dbe4a9da8e93d8c3956ae17544699e2221873d479d72b977e2f tools/verify-catalog-media-1.40.0.mjs
|
||||
f208ce5267103ab81c30da71e7b7c51c973b36dd617a1bc42c12063fbf1473f0 tools/verify-catalog-media-1.41.0.mjs
|
||||
1c8da7af9bb5d743a60997fcba3345e568a862c97ff7102621134fa347a0056f tools/verify-catalog-media-1.41.1.mjs
|
||||
b05890cd996c6dc28a47fb662026ac794bd096c23c3b1bb7a9fdc01224e8032e tools/verify-catalog-media-1.41.2.mjs
|
||||
93d8fdee3feaf6e82ef15e7ac84bfd7e43ad13192f94655ccfce37f5a87b21ef tools/verify-catalog-services-1.39.0.mjs
|
||||
60de35103ed4c4abf30445b4465b849f8be8842f1a4c22da6ae5d74943dba386 tools/verify-catalog-services-1.40.0.mjs
|
||||
f89edc3b9d32c1618522c5810e53f25b47e72d52e8a041a2e7ae7939fbe169f2 tools/verify-catalog-services-1.41.0.mjs
|
||||
122d1664ce9eff57331fd5f86554943494d46ba38e90f7e1a13c0de33ad6906d tools/verify-catalog-services-1.41.1.mjs
|
||||
fb8ee4afa1d6abc09106ab032793da8dfef43bcb8614c7fe04086eb52cfb2a0b tools/verify-catalog-services-1.41.2.mjs
|
||||
8f9ede5f112ee03323db3a6c73aee00b7037f5139dad241b11baf5f0e9cee4e0 tools/verify-config-store.mjs
|
||||
e5a0614ef4f2ea73bdaaa1924cfafe86550a1a50c0105c569e405c332d8389a5 tools/verify-coolify-deployment-1.41.0.mjs
|
||||
63afde37353f750f4bfe0b02324f774a7b3c7ece9339d85e5428b9a427c0d11e tools/verify-coolify-deployment-1.41.1.mjs
|
||||
a66efb128565db5267c03a1503eb29d1f12024e1a357e1fd17d409430c2503f3 tools/verify-coolify-deployment-1.41.2.mjs
|
||||
5752f6edb956696781da8bddb1c1848d26941fbed5a524691653c84c88c34bff tools/verify-db-migrations.py
|
||||
3745de55a110d8254983b096c6afab196ddaec7f1153cd2f34c1e480b15aa33e tools/verify-deployment-1.32.0.mjs
|
||||
44f6b349b722e5a83d4725c4879fa1a9383f59ab5634c49d3d7d9d7aa0cd5e12 tools/verify-deployment-1.33.0.mjs
|
||||
@@ -462,14 +469,17 @@ b65eec7fd54e1459b363d99d20f9438ae5bc4b518e775de8b97e33e89a382e42 tools/verify-d
|
||||
77f44ad4e13da47bcf4bc9c6a37d014c8b5f6c3e735ecfdb63830e09e43df296 tools/verify-deployment-1.40.2.mjs
|
||||
9a33b8594744ed4c7774f12da5b14dce64049c1c0df65e98c9cd26d5404fda0c tools/verify-deployment-1.41.0.mjs
|
||||
a885727e8847bf32ead7d7dc80e2e27deac62df53763a187717fcd8e6157a1f5 tools/verify-deployment-1.41.1.mjs
|
||||
ca6e6baebc9489fd101d005f75c34c90b28e914443a11e3ad759513f653f4c82 tools/verify-deployment-1.41.2.mjs
|
||||
c2d270efae9209d6fdf810f44db4cfdb9642db29060d5b75c2d920a859bc6918 tools/verify-esm-export-contracts.mjs
|
||||
58c275a972393d7db7cab087523d3963a743e5a0d654fbc9b2cc09618abfea6c tools/verify-flux-loop-hotfix-1.40.1.mjs
|
||||
58c275a972393d7db7cab087523d3963a743e5a0d654fbc9b2cc09618abfea6c tools/verify-flux-loop-hotfix-1.40.2.mjs
|
||||
824ea8ed05fb68ef6bedba83e76697f9b03eb9830db981781e002c64f8b6858d tools/verify-flux-loop-hotfix-1.41.0.mjs
|
||||
a055379e9ca3e7ebded24d4c75ccd54f8f81aa3618d63cdf41d79e3a17f126ac tools/verify-flux-loop-hotfix-1.41.1.mjs
|
||||
c84f814c5e7dba7144709c6be860ab8ecbcb0541d167dca1e3172717196a327b tools/verify-flux-loop-hotfix-1.41.2.mjs
|
||||
e06cda8d9850a84cde34a99009c51a18dbc1b7a66e45c82f35aadd631a71540b tools/verify-git-ignore-boundaries-1.41.2.mjs
|
||||
d716f48436179c7f156b43a687b6149648fd38de12767da2be1bc63e3e1fce31 tools/verify-git-safety-regression.mjs
|
||||
872cb8d1e0f86e2e571ddcce1adbb002d0a86f2d834f4ee7f601e011c7306e70 tools/verify-git-safety.mjs
|
||||
3e2eb9358b3a7060cf7d47d52dadb7744544a7682befb45822303cb96b2788ba tools/verify-git-staging.mjs
|
||||
ad2d1df5725da388ca427ddf6da0e0d62bf02999c6554e8c1ab918d63f5b8d50 tools/verify-git-staging.mjs
|
||||
1d4351e4a827e718766a1677ddfbc3771a91942d0d5a93753e39f1b99c68a4dd tools/verify-hotfix-1.23.2.mjs
|
||||
2f43dd1a89a36527cd3b4873b8cc9f7386cdfc06ade96fcc05dd5deb15dbcec7 tools/verify-identity-db-1.38.0.py
|
||||
2f43dd1a89a36527cd3b4873b8cc9f7386cdfc06ade96fcc05dd5deb15dbcec7 tools/verify-identity-db-1.39.0.py
|
||||
@@ -479,12 +489,15 @@ a1dddb35f4b6eeafe47ff3f40e5565bdb4477c8f63101d3c0d7f84c7393abe4c tools/verify-i
|
||||
ff839066b02121a19046f19093904550e1184785e73dd2d079ab518cc74583ff tools/verify-identity-settings-1.40.0.mjs
|
||||
3725fdbb20e93dd0bcbf3ad385bbb0c459cdd68448371757e549543b007155cc tools/verify-identity-settings-1.41.0.mjs
|
||||
3dc54d074497913255774603f043e1154e2ae865ca62d7d4d682826cb02906c2 tools/verify-identity-settings-1.41.1.mjs
|
||||
6d1d890a184a4cb4e1ae9edf784a0df250563e7287f6d3ee5fc43c89913b95c9 tools/verify-identity-settings-1.41.2.mjs
|
||||
9492911fac3771e3b906b5a583e4bbf136ec8383b5fc23b0478f58b7407f7c65 tools/verify-initial-disabled-modules-1.41.0.mjs
|
||||
932e2359c3ee00924e1a89cdcdabe44ddf1a60479f75f4d4094f2102d1c12949 tools/verify-initial-disabled-modules-1.41.1.mjs
|
||||
745cb929519db756ba23e3198b4c92ff9e91173bd03d821bc2e37280af0a32b7 tools/verify-initial-disabled-modules-1.41.2.mjs
|
||||
44c95844422be3deaad6054ee3f0cced9c25986815d976e0c64b042d0c2c5e1e tools/verify-installer-shell.sh
|
||||
33ca957bf8acd3cc73354ef4c8d5e4b61c44810ecd8a8994ff89305cf1b9429c tools/verify-module-manager-1.40.0.mjs
|
||||
2a80e837f754e7490f775956161e4fc8866c127c37b95d688baa239fee4cd883 tools/verify-module-manager-1.41.0.mjs
|
||||
6b83edfaa57dd3633c7d100a8ed476ef63ae41384d215c787a2cbb6e608b22e5 tools/verify-module-manager-1.41.1.mjs
|
||||
f4da6e0be42aa7eda0a496f4ab77547af6e3238a6d405aec00a61a9bb3622c1a tools/verify-module-manager-1.41.2.mjs
|
||||
cab03e98687af87f359f6424d440fc0b644da97b3c614509b8c7d008d4b18a8d tools/verify-platform-architecture-1.35.0.mjs
|
||||
732bb15431746a829e45ae55c695360cbf1dd21d4fc3be26a98f35708d571e84 tools/verify-platform-architecture-1.36.0.mjs
|
||||
ee8ae7bed4e93a2ef278662552a4ae5d751306aad7133a93f380bbf2a5d5a28c tools/verify-platform-architecture-1.37.0.mjs
|
||||
@@ -494,6 +507,7 @@ c9e704c372cf6a973fcdb887b337ead6509fd03e78570e6d1c9e24b1d879fe6b tools/verify-p
|
||||
8b1b0eda98f0d16a99b47b0cb7a9e550d40d3dd048abbec6695c11ceca96f259 tools/verify-platform-architecture-1.40.2.mjs
|
||||
2a06e9f6e73b37f35d055d0dd911e9cb741fa1fdb2cdfc5d926a2635f817356c tools/verify-platform-architecture-1.41.0.mjs
|
||||
957cc887090f8d9c3a477c532e1aca79d24a3725924cb6e5a934f121a155c0e4 tools/verify-platform-architecture-1.41.1.mjs
|
||||
ef13554f4d1a16dc97e5c17af3a719a0bd25839dae0f0f28608a706f893010f4 tools/verify-platform-architecture-1.41.2.mjs
|
||||
36e35fedb08923c22fd8551389d33432b66dbd4215d95c594ef29bfc790c98e0 tools/verify-release-1.25.0.mjs
|
||||
89621694b7bad18b6f3f1ac1956032f64112c40d3edc069ca581dc813d57d628 tools/verify-release-1.25.1-static.mjs
|
||||
c908f5186cf2d71ef83d5e649327ae19965568933acfb32057ca00d29cf890bc tools/verify-release-1.25.1.mjs
|
||||
@@ -511,11 +525,13 @@ a415d294679adab8ea76b86235a522e73ad9dd9ea0e2348691b1de3191255b81 tools/verify-s
|
||||
f5296410e7c56a924978569a8ae904a1de8d4e327cb833e34e4b49e0384f3892 tools/verify-security-foundation-1.40.0.mjs
|
||||
cd21f37b51ca99312c624a3b353d9eb5bff7e3dbca774600ae429f468b1d6b3f tools/verify-security-foundation-1.41.0.mjs
|
||||
a619c7ecff5febe8afa746125ba100c603598951dfc177562307614ecf509715 tools/verify-security-foundation-1.41.1.mjs
|
||||
6610a5d5ea6e4debedcf582c5d9992914110526bc2c371d6b9c5472fb8083768 tools/verify-security-foundation-1.41.2.mjs
|
||||
31a120622ce8bb8451014f2662366f81abd28d0f3be59adfe35d17e0ada1838f tools/verify-slice30-mock.mjs
|
||||
20957a4a038b9afdb01a8c98b881644daf793d2a6bb778a4c8e04780687d00e4 tools/verify-slice31-image-editor.mjs
|
||||
21061ed5dc88feef48e80efffd9a1054d38598dce5a5d3f9f6bde39b539b83f0 tools/verify-slice32-batch.mjs
|
||||
0f05b1ac3b686cf535bcfb3e6f6df24a29c3429ade1b3c666c1b99abb1a7311b tools/verify-slice32-image-render.mjs
|
||||
168e58bd08b657abf2c7a84c7d0210e8393720e3f2b665703b03d45646480b04 tools/verify-source-boundaries-1.41.1.mjs
|
||||
4424e4abf9a1dddd138b0dcb91b6d63f646c3a0c8eaae484d1e9d5b34bb35d77 tools/verify-source-boundaries-1.41.2.mjs
|
||||
c9aa6693aa93ff1f220a2ce3bc35ef714a0b18a53eae0f907879fc92c66c1e38 tools/verify-theme-manager-1.36.0.mjs
|
||||
1e61c4b180aa09de3dae9e72aef313b09a6f422229f180984b69a8d226e7cd8d tools/verify-theme-manager-1.37.0.mjs
|
||||
3257b31d80034c077b25d3f1b13f8378378772bc6512445aa5ac8e882e4be109 tools/verify-theme-manager-1.38.0.mjs
|
||||
@@ -523,5 +539,6 @@ c9aa6693aa93ff1f220a2ce3bc35ef714a0b18a53eae0f907879fc92c66c1e38 tools/verify-t
|
||||
072a20e27b80b911ace2c9c18d1bb0298f59345967b6ee8f3d6b9075c522a6ba tools/verify-theme-manager-1.40.0.mjs
|
||||
251bc56d2d52cbac1e126e35ad5e0077affb2b5cde6b251ad3df6a39ce67d16d tools/verify-theme-manager-1.41.0.mjs
|
||||
33a6f24d26cda4d2129501c4e826ed0dda4eb9e15a324bf823b2ef19540e31ef tools/verify-theme-manager-1.41.1.mjs
|
||||
8d825f95253f96b182ac9322b20c8f32c189eedd2a8c366170791c7623acbb1f tools/verify-theme-manager-1.41.2.mjs
|
||||
0f3f684019763a14f5fbaec42e42c680cf748d930d46cd607fc61eb413ca4553 tools/verify-ui-contracts-1.26.1.mjs
|
||||
5f599423e8bec8b88de8aaf1223be49f63fb53b4a090e9129b7592c4a6e5c4d7 update-manifest.json
|
||||
a7ba863085e196ba5629cfbc344fcf8a05f45406f6bc6c6b75b3f45bf48bfa46 update-manifest.json
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
Vendoo Logo & Favicon Paket
|
||||
|
||||
Inhalt:
|
||||
- logo/: Hauptlogo und transparente Varianten
|
||||
- icons/: PNG-App-Icons und allgemeine Größen für Web/Mobile/Desktop
|
||||
- favicons/: favicon.ico, PNG-Favicons, Apple/Android/Web-Manifest-Dateien
|
||||
- social/: Markenübersicht / Guideline-Sheet
|
||||
|
||||
Wichtige Dateien:
|
||||
- logo/vendoo-logo-transparent.png
|
||||
- icons/vendoo-icon-512x512.png
|
||||
- favicons/favicon.ico
|
||||
- favicons/apple-touch-icon.png
|
||||
- favicons/site.webmanifest
|
||||
- favicons/html-snippet.txt
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#faf6f2</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 498 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,9 +0,0 @@
|
||||
<!-- Vendoo favicon package -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicons/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicons/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/favicons/site.webmanifest">
|
||||
<meta name="msapplication-config" content="/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#FAF6F2">
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "Vendoo",
|
||||
"short_name": "Vendoo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#FAF6F2",
|
||||
"background_color": "#FAF6F2",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 498 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 882 KiB |
|
Before Width: | Height: | Size: 838 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -1,7 +1,11 @@
|
||||
# Vendoo 1.41.1
|
||||
# Vendoo 1.41.2
|
||||
|
||||
Vendoo 1.41.1 ist die korrigierte Produktionsgrundlage für den Betrieb über **Coolify**, GitHub und `https://vendoo.flatlined.de`. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify.
|
||||
|
||||
## Hotfix 1.41.2: native Module zuverlässig in Git
|
||||
|
||||
Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschlossen. Dadurch bleiben `app/modules/` und `app/modules/sessions/` normale, versionierte Quellordner. Der Deploy-Assistent prüft vor jedem Push, dass alle produktiven Moduldateien physisch vorhanden und durch Git getrackt sind.
|
||||
|
||||
## CI-Hotfix 1.41.1
|
||||
|
||||
- `Archiv/`, `archive/` und `archives/` werden nicht mehr als produktiver Quellcode geprüft, nach GitHub übertragen oder in Releases gepackt.
|
||||
|
||||
@@ -144,3 +144,8 @@ Die Weboberfläche verwendet eine erzwungene Content Security Policy ohne Inline
|
||||
- Antworten externer Deploymentdienste werden begrenzt und Redirects nicht automatisch verfolgt
|
||||
- der Initialwert für deaktivierte Module überschreibt keine spätere persistente Administratorentscheidung
|
||||
|
||||
|
||||
|
||||
## Git-Ignore-Grenzen
|
||||
|
||||
Lokale Runtimeordner werden ausschließlich als Root-Pfade (`/modules/`, `/sessions/`, `/logs/` usw.) ignoriert. Produktive Quellpfade unter `app/modules/` dürfen weder ignoriert noch ungetrackt sein; CI und Deploy-Assistent erzwingen diese Grenze.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHash, createPublicKey, verify as verifySignature } from 'crypto';
|
||||
import { posix as pathPosix } from 'path';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { validateModuleManifest } from '../../kernel/module-contract.mjs';
|
||||
|
||||
export const MODULE_PACKAGE_FORMAT = 'vendoo.module.package';
|
||||
export const MODULE_PACKAGE_SCHEMA = 1;
|
||||
export const MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_FILE_COUNT = 200;
|
||||
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = stable(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function canonicalJson(value) {
|
||||
return JSON.stringify(stable(value));
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function packageIntegrityPayload(modulePackage) {
|
||||
return {
|
||||
format: MODULE_PACKAGE_FORMAT,
|
||||
schemaVersion: MODULE_PACKAGE_SCHEMA,
|
||||
manifest: modulePackage.manifest,
|
||||
runtime: modulePackage.runtime,
|
||||
files: modulePackage.files.map(file => ({ path: file.path, sha256: file.sha256, size: file.size })),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePackageDigest(modulePackage) {
|
||||
return sha256(canonicalJson(packageIntegrityPayload(modulePackage)));
|
||||
}
|
||||
|
||||
function safeRelativePath(value) {
|
||||
const raw = String(value || '').replace(/\\/g, '/').trim();
|
||||
const normalized = pathPosix.normalize(raw);
|
||||
if (!raw || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || normalized.startsWith('/') || /^[A-Za-z]:/.test(raw) || raw.includes('\0')) {
|
||||
throw new PlatformError(`Unsicherer Moduldateipfad: ${raw || '(leer)'}`, {
|
||||
code: 'MODULE_PACKAGE_PATH_INVALID', status: 400, expose: true,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateRuntime(runtime = {}) {
|
||||
const mode = String(runtime.mode || 'declarative').trim();
|
||||
if (!['builtin-reference', 'declarative', 'isolated'].includes(mode)) {
|
||||
throw new PlatformError(`Nicht unterstützter Modul-Runtime-Modus: ${mode}`, { code: 'MODULE_RUNTIME_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const entry = runtime.entry === undefined || runtime.entry === null || runtime.entry === '' ? null : safeRelativePath(runtime.entry);
|
||||
if (mode === 'declarative' && entry) {
|
||||
throw new PlatformError('Deklarative Module dürfen keinen ausführbaren Einstiegspunkt besitzen.', { code: 'MODULE_RUNTIME_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
|
||||
}
|
||||
if (mode === 'isolated' && !entry) {
|
||||
throw new PlatformError('Isolierte Module benötigen einen Einstiegspunkt.', { code: 'MODULE_RUNTIME_ENTRY_REQUIRED', status: 400, expose: true });
|
||||
}
|
||||
return Object.freeze({ mode, entry });
|
||||
}
|
||||
|
||||
function validateFiles(files, runtime) {
|
||||
if (!Array.isArray(files)) throw new PlatformError('Moduldateien fehlen.', { code: 'MODULE_PACKAGE_FILES_INVALID', status: 400, expose: true });
|
||||
if (files.length > MAX_FILE_COUNT) throw new PlatformError('Das Modulpaket enthält zu viele Dateien.', { code: 'MODULE_PACKAGE_TOO_MANY_FILES', status: 400, expose: true });
|
||||
const seen = new Set();
|
||||
let total = 0;
|
||||
const normalized = files.map(item => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new PlatformError('Ungültiger Moduldateieintrag.', { code: 'MODULE_PACKAGE_FILE_INVALID', status: 400, expose: true });
|
||||
const path = safeRelativePath(item.path);
|
||||
if (seen.has(path)) throw new PlatformError(`Doppelte Moduldatei: ${path}`, { code: 'MODULE_PACKAGE_FILE_DUPLICATE', status: 400, expose: true });
|
||||
seen.add(path);
|
||||
const content = String(item.content || '');
|
||||
let buffer;
|
||||
try { buffer = Buffer.from(content, 'base64'); } catch { buffer = null; }
|
||||
if (!buffer || buffer.length > MAX_FILE_BYTES) throw new PlatformError(`Moduldatei ist ungültig oder zu groß: ${path}`, { code: 'MODULE_PACKAGE_FILE_TOO_LARGE', status: 400, expose: true });
|
||||
const digest = sha256(buffer);
|
||||
if (!/^[a-f0-9]{64}$/.test(String(item.sha256 || '')) || digest !== String(item.sha256)) {
|
||||
throw new PlatformError(`Prüfsumme stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_HASH_MISMATCH', status: 400, expose: true });
|
||||
}
|
||||
if (Number(item.size) !== buffer.length) throw new PlatformError(`Dateigröße stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_SIZE_MISMATCH', status: 400, expose: true });
|
||||
total += buffer.length;
|
||||
if (runtime.mode === 'declarative' && /\.(?:mjs|cjs|js|node|exe|dll|so|dylib|ps1|bat|cmd|sh)$/i.test(path)) {
|
||||
throw new PlatformError(`Deklaratives Modul enthält ausführbaren Code: ${path}`, { code: 'MODULE_PACKAGE_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
|
||||
}
|
||||
return Object.freeze({ path, sha256: digest, size: buffer.length, content });
|
||||
});
|
||||
if (total > MAX_PACKAGE_BYTES) throw new PlatformError('Das Modulpaket ist zu groß.', { code: 'MODULE_PACKAGE_TOO_LARGE', status: 400, expose: true });
|
||||
if (runtime.entry && !seen.has(runtime.entry)) throw new PlatformError('Der Runtime-Einstiegspunkt fehlt im Paket.', { code: 'MODULE_RUNTIME_ENTRY_MISSING', status: 400, expose: true });
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function verifyOptionalSignature(modulePackage, trustKeys = {}) {
|
||||
const signature = modulePackage.signature;
|
||||
if (!signature) return { signed: false, trusted: false, keyId: null };
|
||||
const algorithm = String(signature.algorithm || '').toLowerCase();
|
||||
const keyId = String(signature.keyId || '').trim();
|
||||
const value = String(signature.value || '').trim();
|
||||
if (algorithm !== 'ed25519' || !keyId || !value) {
|
||||
throw new PlatformError('Ungültige Modulsignatur.', { code: 'MODULE_SIGNATURE_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const publicKey = trustKeys[keyId];
|
||||
if (!publicKey) return { signed: true, trusted: false, keyId };
|
||||
try {
|
||||
const verified = verifySignature(null, Buffer.from(modulePackage.integrity.digest, 'hex'), createPublicKey(publicKey), Buffer.from(value, 'base64'));
|
||||
if (!verified) throw new Error('Signaturprüfung fehlgeschlagen');
|
||||
return { signed: true, trusted: true, keyId };
|
||||
} catch (error) {
|
||||
throw new PlatformError('Modulsignatur konnte nicht bestätigt werden.', { code: 'MODULE_SIGNATURE_REJECTED', status: 400, expose: true, cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function validateModulePackage(input, { trustKeys = {}, allowBuiltinReference = false } = {}) {
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
|
||||
if (!source) throw new PlatformError('Ungültiges Modulpaket.', { code: 'MODULE_PACKAGE_INVALID', status: 400, expose: true });
|
||||
if (source.format !== MODULE_PACKAGE_FORMAT || Number(source.schemaVersion) !== MODULE_PACKAGE_SCHEMA) {
|
||||
throw new PlatformError('Nicht unterstütztes Modulpaketformat.', { code: 'MODULE_PACKAGE_VERSION_UNSUPPORTED', status: 400, expose: true });
|
||||
}
|
||||
const manifest = validateModuleManifest(source.manifest);
|
||||
const runtime = validateRuntime(source.runtime);
|
||||
if (runtime.mode === 'builtin-reference' && !allowBuiltinReference) {
|
||||
throw new PlatformError('Referenzexporte eingebauter Module können nicht installiert werden.', { code: 'MODULE_BUILTIN_REFERENCE_NOT_INSTALLABLE', status: 400, expose: true });
|
||||
}
|
||||
if (manifest.type !== 'extension-host' && runtime.mode !== 'builtin-reference') {
|
||||
throw new PlatformError('Installierbare Fremdmodule müssen den Typ extension-host verwenden.', { code: 'MODULE_EXTERNAL_TYPE_REQUIRED', status: 400, expose: true });
|
||||
}
|
||||
const files = validateFiles(source.files || [], runtime);
|
||||
const normalized = {
|
||||
format: MODULE_PACKAGE_FORMAT,
|
||||
schemaVersion: MODULE_PACKAGE_SCHEMA,
|
||||
manifest,
|
||||
runtime,
|
||||
files,
|
||||
createdAt: String(source.createdAt || new Date().toISOString()),
|
||||
integrity: {
|
||||
algorithm: String(source.integrity?.algorithm || '').toLowerCase(),
|
||||
digest: String(source.integrity?.digest || '').toLowerCase(),
|
||||
},
|
||||
signature: source.signature || null,
|
||||
};
|
||||
if (normalized.integrity.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(normalized.integrity.digest)) {
|
||||
throw new PlatformError('Paketintegrität fehlt oder ist ungültig.', { code: 'MODULE_PACKAGE_INTEGRITY_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const expected = calculatePackageDigest(normalized);
|
||||
if (expected !== normalized.integrity.digest) {
|
||||
throw new PlatformError('Paketintegrität stimmt nicht.', { code: 'MODULE_PACKAGE_INTEGRITY_MISMATCH', status: 400, expose: true });
|
||||
}
|
||||
const trust = verifyOptionalSignature(normalized, trustKeys);
|
||||
return Object.freeze({ ...normalized, trust: Object.freeze(trust) });
|
||||
}
|
||||
|
||||
export function buildModulePackage({ manifest, runtime = { mode: 'builtin-reference', entry: null }, files = [], createdAt = new Date().toISOString() }) {
|
||||
const normalizedFiles = files.map(file => {
|
||||
const path = safeRelativePath(file.path);
|
||||
const buffer = Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content || ''), 'utf8');
|
||||
return { path, sha256: sha256(buffer), size: buffer.length, content: buffer.toString('base64') };
|
||||
});
|
||||
const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null };
|
||||
draft.integrity.digest = calculatePackageDigest(draft);
|
||||
return validateModulePackage(draft, { allowBuiltinReference: true });
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'fs';
|
||||
import { dirname, join, resolve, sep } from 'path';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { validateModulePackage } from './module-package-contract.mjs';
|
||||
|
||||
function ensureInside(root, pathname) {
|
||||
const base = resolve(root);
|
||||
const target = resolve(pathname);
|
||||
if (target !== base && !target.startsWith(`${base}${sep}`)) {
|
||||
throw new PlatformError('Moduldatei liegt außerhalb des Modulspeichers.', { code: 'MODULE_STORAGE_ESCAPE', status: 400, expose: true });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export class ModulePackageStore {
|
||||
#root;
|
||||
#trustKeys;
|
||||
|
||||
constructor(root, { trustKeys = {} } = {}) {
|
||||
this.#root = resolve(root);
|
||||
this.#trustKeys = { ...trustKeys };
|
||||
mkdirSync(this.#root, { recursive: true });
|
||||
}
|
||||
|
||||
#moduleRoot(id) {
|
||||
return ensureInside(this.#root, join(this.#root, id));
|
||||
}
|
||||
|
||||
#packagePath(id) {
|
||||
return join(this.#moduleRoot(id), 'package.vmod');
|
||||
}
|
||||
|
||||
list() {
|
||||
if (!existsSync(this.#root)) return [];
|
||||
const result = [];
|
||||
for (const entry of readdirSync(this.#root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
try {
|
||||
const packagePath = this.#packagePath(entry.name);
|
||||
if (!existsSync(packagePath)) continue;
|
||||
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
|
||||
const pkg = validateModulePackage(parsed, { trustKeys: this.#trustKeys });
|
||||
result.push({
|
||||
id: pkg.manifest.id,
|
||||
manifest: pkg.manifest,
|
||||
runtime: pkg.runtime,
|
||||
trust: pkg.trust,
|
||||
installedAt: String(parsed.installedAt || parsed.createdAt || ''),
|
||||
activatable: pkg.runtime.mode === 'declarative' || (pkg.runtime.mode === 'isolated' && pkg.trust.trusted),
|
||||
packagePath,
|
||||
});
|
||||
} catch (error) {
|
||||
result.push({ id: entry.name, invalid: true, error: error?.message || String(error), activatable: false });
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const packagePath = this.#packagePath(id);
|
||||
if (!existsSync(packagePath)) return null;
|
||||
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
|
||||
return validateModulePackage(parsed, { trustKeys: this.#trustKeys });
|
||||
}
|
||||
|
||||
install(input) {
|
||||
const pkg = validateModulePackage(input, { trustKeys: this.#trustKeys });
|
||||
const root = this.#moduleRoot(pkg.manifest.id);
|
||||
if (existsSync(root)) throw new PlatformError('Dieses Fremdmodul ist bereits installiert.', { code: 'MODULE_ALREADY_INSTALLED', status: 409, expose: true });
|
||||
const temporary = `${root}.${process.pid}.tmp`;
|
||||
mkdirSync(temporary, { recursive: true });
|
||||
try {
|
||||
for (const file of pkg.files) {
|
||||
const destination = ensureInside(temporary, join(temporary, file.path));
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
writeFileSync(destination, Buffer.from(file.content, 'base64'), { mode: 0o600 });
|
||||
}
|
||||
const persisted = { ...pkg, trust: undefined, installedAt: new Date().toISOString() };
|
||||
writeFileSync(join(temporary, 'package.vmod'), `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(temporary, root);
|
||||
} catch (error) {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return this.describe(pkg.manifest.id);
|
||||
}
|
||||
|
||||
remove(id) {
|
||||
const root = this.#moduleRoot(id);
|
||||
if (!existsSync(root)) return false;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export(id) {
|
||||
const packagePath = this.#packagePath(id);
|
||||
if (!existsSync(packagePath)) throw new PlatformError('Installiertes Modulpaket nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
return readFileSync(packagePath);
|
||||
}
|
||||
|
||||
describe(id) {
|
||||
const pkg = this.get(id);
|
||||
if (!pkg) return null;
|
||||
return {
|
||||
id: pkg.manifest.id,
|
||||
manifest: pkg.manifest,
|
||||
runtime: pkg.runtime,
|
||||
trust: pkg.trust,
|
||||
activatable: pkg.runtime.mode === 'declarative' || (pkg.runtime.mode === 'isolated' && pkg.trust.trusted),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
function normalizeState(input = {}) {
|
||||
const modules = {};
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input.modules : null;
|
||||
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
||||
for (const [id, record] of Object.entries(source)) {
|
||||
if (!/^vendoo\.[a-z][a-z0-9-]*$/.test(id)) continue;
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
||||
modules[id] = {
|
||||
enabled: record.enabled !== false,
|
||||
source: String(record.source || 'admin'),
|
||||
updatedAt: String(record.updatedAt || new Date(0).toISOString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { schemaVersion: SCHEMA_VERSION, modules };
|
||||
}
|
||||
|
||||
export class ModuleStateStore {
|
||||
#path;
|
||||
#state;
|
||||
|
||||
constructor(pathname) {
|
||||
this.#path = pathname;
|
||||
this.#state = this.#read();
|
||||
}
|
||||
|
||||
#read() {
|
||||
try {
|
||||
if (!existsSync(this.#path)) return normalizeState();
|
||||
const parsed = JSON.parse(readFileSync(this.#path, 'utf8'));
|
||||
if (Number(parsed?.schemaVersion || 0) !== SCHEMA_VERSION) {
|
||||
throw new Error(`Nicht unterstützte Modulstatus-Version: ${parsed?.schemaVersion}`);
|
||||
}
|
||||
return normalizeState(parsed);
|
||||
} catch (error) {
|
||||
throw new PlatformError('Der persistente Modulstatus konnte nicht gelesen werden.', {
|
||||
code: 'MODULE_STATE_READ_FAILED',
|
||||
cause: error,
|
||||
details: { path: this.#path },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#persist() {
|
||||
mkdirSync(dirname(this.#path), { recursive: true });
|
||||
const temporary = `${this.#path}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(this.#state, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(temporary, this.#path);
|
||||
}
|
||||
|
||||
has(id) { return Boolean(this.#state.modules[id]); }
|
||||
|
||||
isEnabled(id, fallback = true) {
|
||||
const record = this.#state.modules[id];
|
||||
return record ? record.enabled !== false : Boolean(fallback);
|
||||
}
|
||||
|
||||
setEnabled(id, enabled, { source = 'admin' } = {}) {
|
||||
if (!/^vendoo\.[a-z][a-z0-9-]*$/.test(String(id || ''))) {
|
||||
throw new PlatformError('Ungültige Modul-ID für den Modulstatus.', { code: 'MODULE_ID_INVALID', status: 400, expose: true });
|
||||
}
|
||||
this.#state.modules[id] = {
|
||||
enabled: Boolean(enabled),
|
||||
source: String(source || 'admin').slice(0, 80),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.#persist();
|
||||
return { id, ...this.#state.modules[id] };
|
||||
}
|
||||
|
||||
remove(id) {
|
||||
if (!this.#state.modules[id]) return false;
|
||||
delete this.#state.modules[id];
|
||||
this.#persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return JSON.parse(JSON.stringify(this.#state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
let runtime = null;
|
||||
let started = false;
|
||||
|
||||
export function configureAiModule(deps = {}) {
|
||||
runtime = {
|
||||
startModelJobs: deps.startModelJobs || (() => {}),
|
||||
stopModelJobs: deps.stopModelJobs || (() => {}),
|
||||
startBatchJobs: deps.startBatchJobs || (() => {}),
|
||||
stopBatchJobs: deps.stopBatchJobs || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAiModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() {
|
||||
if (!runtime) throw new Error('AI-Modul ist nicht konfiguriert.');
|
||||
runtime.startModelJobs();
|
||||
runtime.startBatchJobs();
|
||||
started = true;
|
||||
},
|
||||
async stop() {
|
||||
runtime?.stopModelJobs();
|
||||
runtime?.stopBatchJobs();
|
||||
started = false;
|
||||
},
|
||||
async health() { return { ok: Boolean(runtime && started), native: true, workers: ['model-photos', 'image-batch'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.ai",
|
||||
"name": "AI Services",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native AI-Services für Text-, Modellfoto- und Batch-Jobs mit kontrolliertem Worker-Lifecycle.",
|
||||
"requires": [
|
||||
"vendoo.security",
|
||||
"vendoo.media"
|
||||
],
|
||||
"permissions": [
|
||||
"generation.execute"
|
||||
],
|
||||
"provides": [
|
||||
"ai.text",
|
||||
"ai.images"
|
||||
],
|
||||
"consumes": [
|
||||
"media.assets",
|
||||
"security.policies"
|
||||
],
|
||||
"owns": [
|
||||
"ai.providers",
|
||||
"ai.jobs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
let setupTokenMatcher = () => false;
|
||||
|
||||
export function configureAuthModule({ setupTokenMatches }) {
|
||||
if (typeof setupTokenMatches === 'function') setupTokenMatcher = setupTokenMatches;
|
||||
}
|
||||
|
||||
export function getAuthService() {
|
||||
if (!service) throw new PlatformError('Auth-Modul ist noch nicht gestartet.', { code: 'AUTH_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createAuthModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() {
|
||||
const { createAuthService } = await import('./service.mjs');
|
||||
service = createAuthService({ setupTokenMatches: setupTokenMatcher });
|
||||
},
|
||||
async stop() { service = null; },
|
||||
async health() { return getAuthService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.auth",
|
||||
"name": "Authentication",
|
||||
"version": "2.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Native Authentifizierung mit Setup, Login, Einladungen, Passwortregeln und sicheren Sitzungsübergaben.",
|
||||
"requires": ["vendoo.security"],
|
||||
"permissions": ["sessions.manage"],
|
||||
"provides": ["auth.identity", "auth.tokens"],
|
||||
"consumes": ["security.policies"],
|
||||
"owns": ["auth.tokens", "auth.login-history"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { getAuthService } from './index.mjs';
|
||||
|
||||
const emailRule = { type: 'string', required: true, minLength: 3, maxLength: 254, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ };
|
||||
|
||||
export function registerPublicAuthRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'auth.setup-status.read', method: 'GET', path: '/auth/setup-check', owner: 'vendoo.auth', policy: 'auth.public',
|
||||
csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getAuthService().setupStatus({ mailConfigured: deps.isMailerConfigured(), setupTokenRequired: deps.setupTokenRequired })),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.setup.create', method: 'POST', path: '/auth/setup', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, password: { type: 'string', required: true, maxLength: 512, trim: false }, setup_token: { type: 'string', maxLength: 512, trim: false } }),
|
||||
handler: async (req, res) => {
|
||||
const result = getAuthService().setup({ ...req.validatedBody, setupToken: req.get('x-vendoo-setup-token') || req.validatedBody.setup_token, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
|
||||
deps.setSessionCookies(req, res, result.session, result.csrf);
|
||||
res.json({ ok: true, redirect: '/' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.login.create', method: 'POST', path: '/auth/login', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
input: objectSchema({ email: emailRule, password: { type: 'string', required: true, maxLength: 512, trim: false } }),
|
||||
handler: async (req, res) => {
|
||||
const result = getAuthService().login({ ...req.validatedBody, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
|
||||
deps.setSessionCookies(req, res, result.session, result.csrf);
|
||||
res.json({ ok: true, redirect: '/' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.invite.create', method: 'POST', path: '/auth/invite', owner: 'vendoo.auth', policy: 'auth.authenticated',
|
||||
middleware: [deps.requireAuth, deps.csrfProtect], csrf: true, stability: 'stable',
|
||||
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: ['admin','manager','editor','publisher','viewer'] } }),
|
||||
handler: async (req, res) => {
|
||||
const { token } = getAuthService().createInvite({ email: req.validatedBody.email, role: req.validatedBody.role || 'editor', createdBy: req.user.id });
|
||||
const result = await deps.sendMagicLink(req.validatedBody.email, token, `${req.protocol}://${req.get('host')}`, 'invite');
|
||||
deps.auditLog(req.user.id, req.user.email, 'user.invited', 'user', req.validatedBody.email, `Rolle: ${req.validatedBody.role || 'editor'}`, deps.getClientIp(req));
|
||||
res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.magic.redirect', method: 'GET', path: '/auth/magic', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const token = String(req.query?.token || '');
|
||||
if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben'));
|
||||
res.redirect(`/login.html?invite=${encodeURIComponent(token)}`);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.invite.accept', method: 'POST', path: '/auth/accept-invite', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
input: objectSchema({ token: { type: 'string', required: true, minLength: 16, maxLength: 512, trim: false }, password: { type: 'string', required: true, maxLength: 512, trim: false }, name: { type: 'string', maxLength: 120 } }),
|
||||
handler: async (req, res) => {
|
||||
const result = getAuthService().acceptInvite({ ...req.validatedBody, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
|
||||
deps.setSessionCookies(req, res, result.session, result.csrf);
|
||||
res.json({ ok: true, redirect: '/' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.logout.create', method: 'POST', path: '/auth/logout', owner: 'vendoo.auth', policy: 'auth.authenticated',
|
||||
middleware: [deps.requireAuth, deps.csrfProtect], csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
getAuthService().logout({ token: req.cookies?.auth_token, ip: deps.getClientIp(req) });
|
||||
deps.clearSessionCookies(res);
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
auditLog, checkRateLimit, clearFailedLogins, createAuthToken, createSession, createUser,
|
||||
generateCsrfToken, getUserByEmail, isSetupMode, isUserLocked, recordLogin,
|
||||
registerFailedLogin, setPassword, updateUser, validateAuthToken, validatePasswordStrength, verifyPassword,
|
||||
validateSession, destroySession,
|
||||
} from '../../core/identity/identity-store.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
function fail(message, status = 400, code = 'AUTH_FAILED', details = null) {
|
||||
throw new PlatformError(message, { status, code, expose: true, details });
|
||||
}
|
||||
|
||||
export function createAuthService({ setupTokenMatches }) {
|
||||
return Object.freeze({
|
||||
setupStatus({ mailConfigured, setupTokenRequired }) {
|
||||
return { setupMode: isSetupMode(), mailConfigured: Boolean(mailConfigured), setupTokenRequired: Boolean(setupTokenRequired) };
|
||||
},
|
||||
|
||||
setup({ email, name, password, setupToken, ip, userAgent }) {
|
||||
if (!isSetupMode()) fail('Setup bereits abgeschlossen', 400, 'SETUP_COMPLETED');
|
||||
if (!setupTokenMatches(setupToken)) fail('Ungültiger Einrichtungs-Code.', 403, 'SETUP_TOKEN_INVALID');
|
||||
const passwordCheck = validatePasswordStrength(password);
|
||||
if (!passwordCheck.valid) fail(`Passwort benötigt ${passwordCheck.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
|
||||
try {
|
||||
const user = createUser(email, name || email.split('@')[0], 'admin', null, password);
|
||||
auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip);
|
||||
const session = createSession(user.id, ip, userAgent);
|
||||
recordLogin(user.id, ip, userAgent, true);
|
||||
auditLog(user.id, email, 'login.success', null, null, 'via setup', ip);
|
||||
return { user, session, csrf: generateCsrfToken() };
|
||||
} catch (error) {
|
||||
fail(error?.message || 'Setup fehlgeschlagen.', 500, 'SETUP_FAILED');
|
||||
}
|
||||
},
|
||||
|
||||
login({ email, password, ip, userAgent }) {
|
||||
const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!rl.allowed) fail(`Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.`, 429, 'LOGIN_RATE_LIMIT', { retry_after: rl.retryAfter });
|
||||
const user = getUserByEmail(email);
|
||||
if (user && isUserLocked(user)) {
|
||||
auditLog(user.id, email, 'login.blocked_locked', null, null, user.locked_until, ip);
|
||||
fail('Account nach mehreren Fehlversuchen vorübergehend gesperrt.', 423, 'ACCOUNT_LOCKED', { locked_until: user.locked_until });
|
||||
}
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
if (user) {
|
||||
recordLogin(user.id, ip, userAgent, false);
|
||||
const lockResult = registerFailedLogin(user.id);
|
||||
if (lockResult?.locked) {
|
||||
auditLog(user.id, email, 'login.account_locked', 'user', String(user.id), lockResult.lockedUntil, ip);
|
||||
fail('Zu viele Fehlversuche. Account für 15 Minuten gesperrt.', 423, 'ACCOUNT_LOCKED', { locked_until: lockResult.lockedUntil });
|
||||
}
|
||||
}
|
||||
auditLog(user?.id || null, email, 'login.failed', null, null, null, ip);
|
||||
fail('E-Mail oder Passwort falsch', 401, 'LOGIN_INVALID');
|
||||
}
|
||||
if (!user.active) fail('Account deaktiviert', 403, 'ACCOUNT_DISABLED');
|
||||
clearFailedLogins(user.id);
|
||||
const session = createSession(user.id, ip, userAgent);
|
||||
recordLogin(user.id, ip, userAgent, true);
|
||||
auditLog(user.id, email, 'login.success', null, null, 'via password', ip);
|
||||
return { user, session, csrf: generateCsrfToken() };
|
||||
},
|
||||
|
||||
createInvite({ email, role, createdBy }) {
|
||||
return createAuthToken(email, 'invite', role || 'editor', createdBy);
|
||||
},
|
||||
|
||||
acceptInvite({ token, password, name, ip, userAgent }) {
|
||||
const passwordCheck = validatePasswordStrength(password);
|
||||
if (!passwordCheck.valid) fail(`Passwort benötigt ${passwordCheck.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
|
||||
const authToken = validateAuthToken(token);
|
||||
if (!authToken) fail('Link ungültig oder abgelaufen', 400, 'INVITE_INVALID');
|
||||
let user = getUserByEmail(authToken.email);
|
||||
if (!user) {
|
||||
user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password);
|
||||
auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip);
|
||||
} else {
|
||||
setPassword(user.id, password);
|
||||
if (name) user = updateUser(user.id, { name });
|
||||
}
|
||||
if (!user.active) fail('Account deaktiviert', 403, 'ACCOUNT_DISABLED');
|
||||
const session = createSession(user.id, ip, userAgent);
|
||||
recordLogin(user.id, ip, userAgent, true);
|
||||
auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip);
|
||||
return { user, session, csrf: generateCsrfToken() };
|
||||
},
|
||||
|
||||
logout({ token, ip }) {
|
||||
if (!token) return { ok: true };
|
||||
const session = validateSession(token);
|
||||
if (session) auditLog(session.user_id, session.email, 'logout', null, null, null, ip);
|
||||
destroySession(token);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
health() {
|
||||
return { ok: true, setup_mode: isSetupMode() };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createAuthModule } from './auth/index.mjs';
|
||||
import { createSecurityModule } from './security/index.mjs';
|
||||
import { createSessionsModule } from './sessions/index.mjs';
|
||||
import { createSettingsModule } from './settings/index.mjs';
|
||||
import { createListingsModule } from './listings/index.mjs';
|
||||
import { createInventoryModule } from './inventory/index.mjs';
|
||||
import { createMediaModule } from './media/index.mjs';
|
||||
import { createThemesModule } from './themes/index.mjs';
|
||||
import { createUsersModule } from './users/index.mjs';
|
||||
import { createModuleManagerModule } from './module-manager/index.mjs';
|
||||
import { createAiModule } from './ai/index.mjs';
|
||||
import { createFluxStudioModule } from './flux-studio/index.mjs';
|
||||
import { createQualityModule } from './quality/index.mjs';
|
||||
import { createPublishingModule } from './publishing/index.mjs';
|
||||
import { createDeploymentsModule } from './deployments/index.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const legacyModuleNames = ['system', 'operations'];
|
||||
|
||||
function legacyModule(name) {
|
||||
return {
|
||||
manifest: JSON.parse(readFileSync(join(here, name, 'module.json'), 'utf8')),
|
||||
lifecycle: {
|
||||
async start() {}, async stop() {}, async health() { return { ok: true, bridge: 'legacy' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadBuiltInModules() {
|
||||
return [
|
||||
...legacyModuleNames.map(legacyModule),
|
||||
createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(),
|
||||
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { createDeploymentService } from './service.mjs';
|
||||
|
||||
let service = null;
|
||||
let adapters = {};
|
||||
|
||||
export function configureDeploymentsModule(nextAdapters = {}) {
|
||||
adapters = { ...adapters, ...nextAdapters };
|
||||
}
|
||||
|
||||
export function getDeploymentService() {
|
||||
if (!service) throw new PlatformError('Deployment-Modul ist noch nicht gestartet.', {
|
||||
code: 'DEPLOYMENT_MODULE_NOT_READY', status: 503, expose: true,
|
||||
});
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createDeploymentsModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = createDeploymentService(adapters); },
|
||||
async stop() { service = null; },
|
||||
async health() { return getDeploymentService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.deployments",
|
||||
"name": "Deployments & Updates",
|
||||
"version": "1.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Kontrollierte Coolify-Deployments, verschlüsselte Deployment-Zugangsdaten und sichere Web-Updates ohne Docker-Socket.",
|
||||
"requires": [
|
||||
"vendoo.security",
|
||||
"vendoo.settings",
|
||||
"vendoo.operations",
|
||||
"vendoo.module-manager"
|
||||
],
|
||||
"permissions": [
|
||||
"updates.manage"
|
||||
],
|
||||
"provides": [
|
||||
"deployments.control",
|
||||
"deployments.coolify",
|
||||
"updates.web"
|
||||
],
|
||||
"consumes": [
|
||||
"security.secrets",
|
||||
"settings.configuration",
|
||||
"operations.backup",
|
||||
"modules.management"
|
||||
],
|
||||
"owns": [
|
||||
"deployments.configuration",
|
||||
"deployments.history",
|
||||
"updates.coolify"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { getDeploymentService } from './index.mjs';
|
||||
|
||||
const settingsSchema = objectSchema({
|
||||
provider: { type: 'string', enum: ['none', 'coolify'] },
|
||||
coolify_url: { type: 'string', maxLength: 1000 },
|
||||
resource_uuid: { type: 'string', maxLength: 120 },
|
||||
trigger_mode: { type: 'string', enum: ['webhook', 'api'] },
|
||||
deploy_method: { type: 'string', enum: ['GET', 'POST'] },
|
||||
deploy_endpoint: { type: 'string', maxLength: 500 },
|
||||
github_repository: { type: 'string', maxLength: 200 },
|
||||
github_branch: { type: 'string', maxLength: 120 },
|
||||
ghcr_image: { type: 'string', maxLength: 300 },
|
||||
update_channel: { type: 'string', enum: ['stable', 'beta'] },
|
||||
backup_before_deploy: { type: 'boolean' },
|
||||
backup_uploads: { type: 'boolean' },
|
||||
api_token: { type: 'string', maxLength: 16384, trim: false },
|
||||
deploy_webhook_url: { type: 'string', maxLength: 2000, trim: false },
|
||||
});
|
||||
const triggerSchema = objectSchema({
|
||||
force: { type: 'boolean' }, confirmation: { type: 'string', required: true, maxLength: 20 }, reason: { type: 'string', maxLength: 120 },
|
||||
});
|
||||
|
||||
export function registerDeploymentRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'deployments.status.read', method: 'GET', path: '/api/admin/deployment', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.status.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getDeploymentService().status()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.settings.updated', rateLimit: 'administration', csrf: true, stability: 'stable', input: settingsSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = getDeploymentService().update(req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.settings.updated', 'deployment', 'coolify', null, deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.connection.test', method: 'POST', path: '/api/admin/deployment/test', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.connection.tested', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().testConnection();
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.connection.tested', 'deployment', 'coolify', JSON.stringify({ ok: result.ok }), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.updates.check', method: 'POST', path: '/api/admin/deployment/updates/check', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.updates.checked', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(await getDeploymentService().checkUpdates()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.trigger', method: 'POST', path: '/api/admin/deployment/trigger', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.triggered', rateLimit: 'administration', csrf: true, stability: 'stable', input: triggerSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().trigger(req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', 'coolify', JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch }), deps.getClientIp(req));
|
||||
res.status(202).json(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
|
||||
import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
|
||||
import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json');
|
||||
const PROVIDERS = new Set(['none', 'coolify']);
|
||||
const MODES = new Set(['webhook', 'api']);
|
||||
const CHANNELS = new Set(['stable', 'beta']);
|
||||
const METHODS = new Set(['GET', 'POST']);
|
||||
|
||||
function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) {
|
||||
throw new PlatformError(message, { code, status, expose: true, details });
|
||||
}
|
||||
|
||||
function clean(value, max = 1000) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > max) fail('Deployment-Einstellung ist zu lang.');
|
||||
if (/\r|\n|\0/.test(text)) fail('Deployment-Einstellung enthält unzulässige Steuerzeichen.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function cleanUuid(value) {
|
||||
const text = clean(value, 120);
|
||||
if (text && !/^[a-zA-Z0-9_-]{6,120}$/.test(text)) fail('Coolify Resource UUID besitzt ein ungültiges Format.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
const text = clean(value, 1000).replace(/\/$/, '');
|
||||
if (!text) return '';
|
||||
let url;
|
||||
try { url = new URL(text); } catch { fail('Coolify URL ist ungültig.'); }
|
||||
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify URL muss HTTP oder HTTPS verwenden.');
|
||||
if (url.username || url.password || url.search || url.hash) fail('Coolify URL darf keine Zugangsdaten, Query oder Fragment enthalten.');
|
||||
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
|
||||
fail('In Produktion muss die Coolify URL HTTPS verwenden.');
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function normalizeWebhookUrl(value) {
|
||||
const text = clean(value, 2000);
|
||||
if (!text) return '';
|
||||
let url;
|
||||
try { url = new URL(text); } catch { fail('Coolify Deploy-Webhook ist ungültig.'); }
|
||||
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify Deploy-Webhook muss HTTP oder HTTPS verwenden.');
|
||||
if (url.username || url.password || url.hash) fail('Coolify Deploy-Webhook darf keine URL-Zugangsdaten oder Fragmente enthalten.');
|
||||
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
|
||||
fail('In Produktion muss der Coolify Deploy-Webhook HTTPS verwenden.');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function readLastDeployment() {
|
||||
try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function writeLastDeployment(value) {
|
||||
mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true });
|
||||
const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
renameSync(temporary, LAST_DEPLOYMENT_PATH);
|
||||
}
|
||||
|
||||
function currentSettings() {
|
||||
const provider = PROVIDERS.has(process.env.VENDOO_DEPLOY_PROVIDER) ? process.env.VENDOO_DEPLOY_PROVIDER : 'none';
|
||||
const mode = MODES.has(process.env.COOLIFY_TRIGGER_MODE) ? process.env.COOLIFY_TRIGGER_MODE : 'webhook';
|
||||
const channel = CHANNELS.has(process.env.VENDOO_UPDATE_CHANNEL) ? process.env.VENDOO_UPDATE_CHANNEL : 'stable';
|
||||
const method = METHODS.has(process.env.COOLIFY_DEPLOY_METHOD) ? process.env.COOLIFY_DEPLOY_METHOD : 'GET';
|
||||
return {
|
||||
provider,
|
||||
coolify_url: String(process.env.COOLIFY_API_URL || '').replace(/\/$/, ''),
|
||||
resource_uuid: String(process.env.COOLIFY_RESOURCE_UUID || ''),
|
||||
trigger_mode: mode,
|
||||
deploy_method: method,
|
||||
deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'),
|
||||
github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'),
|
||||
github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'),
|
||||
ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'),
|
||||
update_channel: channel,
|
||||
backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')),
|
||||
backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')),
|
||||
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
|
||||
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettings() {
|
||||
const settings = currentSettings();
|
||||
const configured = settings.provider === 'coolify' && (
|
||||
(settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) ||
|
||||
(settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured)
|
||||
);
|
||||
return {
|
||||
...settings,
|
||||
configured,
|
||||
api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider },
|
||||
deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider },
|
||||
current_version: APP_VERSION,
|
||||
last_deployment: readLastDeployment(),
|
||||
docker_socket_access: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiDeployUrl(settings, force) {
|
||||
const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}';
|
||||
if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.');
|
||||
if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.');
|
||||
const path = endpoint
|
||||
.replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid))
|
||||
.replaceAll('{force}', force ? 'true' : 'false');
|
||||
return `${settings.coolify_url}${path}`;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); }
|
||||
catch (error) {
|
||||
if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504);
|
||||
fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502);
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
async function readSafeResponse(response) {
|
||||
const text = (await response.text()).slice(0, 4000);
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
|
||||
}
|
||||
|
||||
export function createDeploymentService({ createBackup, checkForUpdates } = {}) {
|
||||
if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
||||
|
||||
async function testConnection() {
|
||||
const settings = currentSettings();
|
||||
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
|
||||
if (!settings.coolify_url) fail('Coolify URL fehlt.');
|
||||
const response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000);
|
||||
const body = await readSafeResponse(response);
|
||||
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body });
|
||||
return { ok: true, status: response.status, response: body, url: settings.coolify_url };
|
||||
}
|
||||
|
||||
async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) {
|
||||
if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
|
||||
const settings = currentSettings();
|
||||
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
|
||||
|
||||
let backup = null;
|
||||
if (settings.backup_before_deploy) {
|
||||
backup = await createBackup({
|
||||
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
|
||||
label: `pre-deploy-${APP_VERSION}`,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
||||
let method = settings.deploy_method;
|
||||
if (settings.trigger_mode === 'webhook') {
|
||||
url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL'));
|
||||
if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.');
|
||||
} else {
|
||||
const token = getSecret('COOLIFY_API_TOKEN');
|
||||
if (!settings.coolify_url || !settings.resource_uuid || !token) fail('Coolify API URL, Resource UUID oder API Token fehlt.');
|
||||
url = buildApiDeployUrl(settings, Boolean(force));
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const requestedAt = new Date().toISOString();
|
||||
const response = await fetchWithTimeout(url, { method, headers }, 20000);
|
||||
const body = await readSafeResponse(response);
|
||||
const record = {
|
||||
requested_at: requestedAt,
|
||||
provider: 'coolify', trigger_mode: settings.trigger_mode, method,
|
||||
resource_uuid: settings.resource_uuid || null,
|
||||
github_repository: settings.github_repository, github_branch: settings.github_branch,
|
||||
version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120),
|
||||
accepted: response.ok, http_status: response.status,
|
||||
backup_id: backup?.id || null, backup_file: backup?.filename || null,
|
||||
response: body,
|
||||
};
|
||||
writeLastDeployment(record);
|
||||
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null });
|
||||
return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' };
|
||||
}
|
||||
|
||||
function update(input = {}) {
|
||||
const provider = clean(input.provider || 'none', 20);
|
||||
const mode = clean(input.trigger_mode || 'webhook', 20);
|
||||
const channel = clean(input.update_channel || 'stable', 20);
|
||||
const method = clean(input.deploy_method || 'GET', 10).toUpperCase();
|
||||
if (!PROVIDERS.has(provider)) fail('Unbekannter Deployment-Provider.');
|
||||
if (!MODES.has(mode)) fail('Unbekannter Coolify Trigger-Modus.');
|
||||
if (!CHANNELS.has(channel)) fail('Unbekannter Update-Kanal.');
|
||||
if (!METHODS.has(method)) fail('Coolify Deploy-Methode muss GET oder POST sein.');
|
||||
|
||||
const values = {
|
||||
VENDOO_DEPLOY_PROVIDER: provider,
|
||||
COOLIFY_API_URL: normalizeBaseUrl(input.coolify_url || ''),
|
||||
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
|
||||
COOLIFY_TRIGGER_MODE: mode,
|
||||
COOLIFY_DEPLOY_METHOD: method,
|
||||
COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500),
|
||||
VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200),
|
||||
VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120),
|
||||
VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300),
|
||||
VENDOO_UPDATE_CHANNEL: channel,
|
||||
VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true',
|
||||
VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false',
|
||||
};
|
||||
let content = readRuntimeConfig();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
content = setConfigValue(content, key, value);
|
||||
process.env[key] = value;
|
||||
}
|
||||
writeRuntimeConfig(content);
|
||||
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') {
|
||||
setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' });
|
||||
}
|
||||
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') {
|
||||
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
|
||||
}
|
||||
return publicSettings();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status: publicSettings,
|
||||
update,
|
||||
testConnection,
|
||||
trigger,
|
||||
async checkUpdates() {
|
||||
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
|
||||
return checkForUpdates();
|
||||
},
|
||||
health() {
|
||||
const status = publicSettings();
|
||||
return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
let runtime = null;
|
||||
let started = false;
|
||||
|
||||
export function configureFluxStudioModule(deps = {}) {
|
||||
runtime = { start: deps.start || (() => {}), stop: deps.stop || (() => {}) };
|
||||
}
|
||||
|
||||
export function createFluxStudioModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() { if (!runtime) throw new Error('FLUX-Modul ist nicht konfiguriert.'); runtime.start(); started = true; },
|
||||
async stop() { runtime?.stop(); started = false; },
|
||||
async health() { return { ok: Boolean(runtime && started), native: true, worker: 'flux-prompt' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.flux-studio",
|
||||
"name": "FLUX Studio",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native FLUX-Studio-Verträge und kontrollierter Prompt-Job-Worker.",
|
||||
"requires": [
|
||||
"vendoo.ai"
|
||||
],
|
||||
"permissions": [
|
||||
"generation.execute"
|
||||
],
|
||||
"provides": [
|
||||
"flux.prompt-jobs"
|
||||
],
|
||||
"consumes": [
|
||||
"ai.images"
|
||||
],
|
||||
"owns": [
|
||||
"flux.generations",
|
||||
"flux.history"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import manifest from './module.json' with { type:'json' };
|
||||
import { InventoryService } from './service.mjs';
|
||||
let service = null;
|
||||
export function configureInventoryModule({ repository }) { service = new InventoryService(repository); }
|
||||
export function getInventoryService(){ if (!service) throw new Error('Inventory-Modul ist nicht konfiguriert.'); return service; }
|
||||
export function createInventoryModule(){ return { manifest, lifecycle:{ async start(){}, async stop(){}, async health(){ return {ok:Boolean(service),native:true,contracts:['locations','bulk-update']}; } } }; }
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.inventory",
|
||||
"name": "Inventory",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Lagerort- und Bestandsverwaltung mit begrenzten Bulk-Aktionen.",
|
||||
"requires": [
|
||||
"vendoo.auth",
|
||||
"vendoo.listings"
|
||||
],
|
||||
"permissions": [
|
||||
"listings.view",
|
||||
"inventory.edit"
|
||||
],
|
||||
"provides": [
|
||||
"inventory.stock"
|
||||
],
|
||||
"consumes": [
|
||||
"auth.identity",
|
||||
"listings.catalog"
|
||||
],
|
||||
"owns": [
|
||||
"inventory.locations"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation } from '../../../lib/db.mjs';
|
||||
export const inventoryRepository = Object.freeze({ locations:getStorageLocations, byLocation:getListingsByLocation, unassigned:getUnassignedListings, bulkUpdateLocation });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getInventoryService } from './index.mjs';
|
||||
export function registerInventoryRoutes({app,routes,deps}){ const service=getInventoryService();
|
||||
routes.register(app,{id:'inventory.locations.list',method:'GET',path:'/api/inventory/locations',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(_q,r)=>r.json(service.locations())});
|
||||
routes.register(app,{id:'inventory.unassigned.list',method:'GET',path:'/api/inventory/unassigned',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(_q,r)=>r.json(service.unassigned())});
|
||||
routes.register(app,{id:'inventory.location.read',method:'GET',path:'/api/inventory/location/:loc',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(q,r)=>r.json(service.byLocation(q.params.loc))});
|
||||
routes.register(app,{id:'inventory.location.bulk-update',method:'PUT',path:'/api/inventory/bulk-location',owner:'vendoo.inventory',policy:'inventory.manage',stability:'stable',input:body=>service.validateBulk(body),handler:async(q,r)=>{const result=service.bulk(q.validatedBody);deps.auditLog(q.user.id,q.user.email,'inventory.location_bulk_updated','listing',null,JSON.stringify({count:result.updated,location:q.validatedBody.location}),deps.getClientIp(q));r.json(result);}});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
function cleanLocation(value){ const text=String(value ?? '').trim(); if(text.length>160 || /[\u0000\r\n]/.test(text)) throw new PlatformError('Lagerort ist ungültig.',{code:'INVENTORY_LOCATION_INVALID',status:400,expose:true}); return text; }
|
||||
export class InventoryService {
|
||||
constructor(repository) { this.repository = repository; }
|
||||
locations(){ return this.repository.locations(); }
|
||||
unassigned(){ return this.repository.unassigned(); }
|
||||
byLocation(location){ return this.repository.byLocation(cleanLocation(location)); }
|
||||
validateBulk(input={}){ for(const key of Object.keys(input||{})) if(!['ids','location'].includes(key)) throw new PlatformError(`Unbekanntes Lagerfeld: ${key}`,{code:'INVENTORY_INPUT_INVALID',status:400,expose:true}); const ids=Array.isArray(input.ids)?[...new Set(input.ids.map(Number).filter(Number.isInteger).filter(id=>id>0))]:[]; if(!ids.length) throw new PlatformError('Mindestens eine Artikel-ID ist erforderlich.',{code:'INVENTORY_IDS_REQUIRED',status:400,expose:true}); if(ids.length>500) throw new PlatformError('Maximal 500 Artikel pro Vorgang.',{code:'INVENTORY_BATCH_TOO_LARGE',status:400,expose:true}); return Object.freeze({ ids, location:cleanLocation(input.location) }); }
|
||||
bulk(input){ const valid=this.validateBulk(input); return { ok:true, updated:this.repository.bulkUpdateLocation(valid.ids, valid.location) }; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
import { ListingsService } from './service.mjs';
|
||||
let service = null;
|
||||
export function configureListingsModule({ repository }) { service = new ListingsService(repository); }
|
||||
export function getListingsService() { if (!service) throw new Error('Listings-Modul ist nicht konfiguriert.'); return service; }
|
||||
export function createListingsModule() { return { manifest, lifecycle: { async start(){}, async stop(){}, async health(){ return { ok:Boolean(service), native:true, contracts:['catalog','trash'] }; } } }; }
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.listings",
|
||||
"name": "Listings",
|
||||
"version": "2.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Artikelverwaltung mit validierten Payloads, Papierkorb, Duplikaten und Audit-Verträgen.",
|
||||
"requires": [
|
||||
"vendoo.auth",
|
||||
"vendoo.media"
|
||||
],
|
||||
"permissions": [
|
||||
"listings.view",
|
||||
"listings.edit",
|
||||
"listings.delete",
|
||||
"listings.permanent-delete"
|
||||
],
|
||||
"provides": [
|
||||
"listings.catalog"
|
||||
],
|
||||
"consumes": [
|
||||
"media.assets",
|
||||
"auth.identity"
|
||||
],
|
||||
"owns": [
|
||||
"listings.records",
|
||||
"listings.trash"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
createListing, getListing, getListings, updateListing, softDeleteListing,
|
||||
restoreListing, getDeletedListings, permanentlyDeleteListing, getTrashCount,
|
||||
duplicateListing,
|
||||
} from '../../../lib/db.mjs';
|
||||
|
||||
export const listingsRepository = Object.freeze({
|
||||
create: createListing,
|
||||
get: getListing,
|
||||
list: getListings,
|
||||
update: updateListing,
|
||||
softDelete: softDeleteListing,
|
||||
restore: restoreListing,
|
||||
listDeleted: getDeletedListings,
|
||||
permanentDelete: permanentlyDeleteListing,
|
||||
trashCount: getTrashCount,
|
||||
duplicate: duplicateListing,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getListingsService } from './index.mjs';
|
||||
|
||||
export function registerListingsRoutes({ app, routes, deps }) {
|
||||
const service = getListingsService();
|
||||
routes.register(app, { id:'listings.catalog.create', method:'POST', path:'/api/listings', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', input: body => service.validateCreate(body),
|
||||
handler: async (req,res) => { const item = service.create(req.validatedBody, req.user.id); deps.auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
|
||||
routes.register(app, { id:'listings.catalog.list', method:'GET', path:'/api/listings', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable',
|
||||
handler: async (req,res) => res.json(service.list(req.query || {})) });
|
||||
routes.register(app, { id:'listings.catalog.read', method:'GET', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler: async (req,res)=>res.json(service.get(req.params.id)) });
|
||||
routes.register(app, { id:'listings.catalog.update', method:'PUT', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', input: body => service.validateUpdate(Object.fromEntries(Object.entries(body || {}).filter(([k]) => k !== '_edit_lock'))),
|
||||
handler: async (req,res) => { const lock = deps.validateResourceLock({ resourceType:'listing', resourceId:req.params.id, userId:req.user.id, token:req.get('x-edit-lock') || req.body?._edit_lock }); if (!lock.valid) return res.status(409).json({ error:`${lock.lock?.user_name || lock.lock?.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Artikel gerade.`, code:'EDIT_LOCKED', lock:lock.lock }); const item=service.update(req.params.id, req.validatedBody, req.user.id); deps.auditLog(req.user.id, req.user.email, 'listing.updated', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
|
||||
routes.register(app, { id:'listings.catalog.delete', method:'DELETE', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.delete', stability:'stable', handler: async (req,res)=>{ const result=service.softDelete(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
|
||||
routes.register(app, { id:'listings.catalog.duplicate', method:'POST', path:'/api/listings/:id/duplicate', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', handler: async (req,res)=>{ const item=service.duplicate(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
|
||||
routes.register(app, { id:'listings.trash.list', method:'GET', path:'/api/trash', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler:async(_req,res)=>res.json(service.trash()) });
|
||||
routes.register(app, { id:'listings.trash.count', method:'GET', path:'/api/trash/count', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler:async(_req,res)=>res.json(service.trashCount()) });
|
||||
routes.register(app, { id:'listings.trash.restore', method:'POST', path:'/api/trash/:id/restore', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', handler:async(req,res)=>{ const result=service.restore(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
|
||||
routes.register(app, { id:'listings.trash.permanent-delete', method:'DELETE', path:'/api/trash/:id', owner:'vendoo.listings', policy:'listings.permanent-delete', stability:'stable', handler:async(req,res)=>{ const result=service.permanentDelete(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
|
||||
routes.register(app, { id:'listings.trash.empty', method:'DELETE', path:'/api/trash', owner:'vendoo.listings', policy:'listings.permanent-delete', stability:'stable', handler:async(req,res)=>{ const result=service.emptyTrash(); deps.auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${result.deleted} Einträge`, deps.getClientIp(req)); res.json(result); } });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { sanitizeRichHtml } from '../../../lib/html-templates.mjs';
|
||||
|
||||
const ALLOWED_FIELDS = new Set([
|
||||
'platform','ai_provider','title','description','description_html','tags','photos','seller_notes',
|
||||
'brand','category','size','color','condition','price','status','language','sku','storage_location',
|
||||
]);
|
||||
const ARRAY_LIMITS = { tags: 50, photos: 100 };
|
||||
|
||||
function fail(message, field, status = 400, code = 'LISTING_INPUT_INVALID') {
|
||||
throw new PlatformError(message, { code, status, expose: true, details: { field } });
|
||||
}
|
||||
function htmlToPlainText(html) {
|
||||
return String(html || '').replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<')
|
||||
.replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/gi, "'").replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
function cleanText(value, max, field, { required = false } = {}) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (required && !text) fail(`${field} ist erforderlich.`, field);
|
||||
if (text.length > max) fail(`${field} ist zu lang.`, field);
|
||||
if (/\u0000/.test(text)) fail(`${field} enthält ungültige Steuerzeichen.`, field);
|
||||
return text;
|
||||
}
|
||||
function normalizePayload(input = {}, { partial = false } = {}) {
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
||||
for (const key of Object.keys(source)) if (!ALLOWED_FIELDS.has(key)) fail(`Unbekanntes Artikelfeld: ${key}`, key);
|
||||
const out = {};
|
||||
const textRules = {
|
||||
platform:64, ai_provider:64, title:240, description:12000, seller_notes:6000, brand:160,
|
||||
category:240, size:120, color:120, condition:120, status:64, language:16, sku:80, storage_location:160,
|
||||
};
|
||||
for (const [field,max] of Object.entries(textRules)) if (source[field] !== undefined) out[field] = cleanText(source[field], max, field, { required: field === 'title' && !partial });
|
||||
if (!partial && source.title === undefined) fail('title ist erforderlich.', 'title');
|
||||
if (source.description_html !== undefined) {
|
||||
const html = String(source.description_html || '');
|
||||
if (html.length > 50000) fail('description_html ist zu lang.', 'description_html');
|
||||
out.description_html = sanitizeRichHtml(html);
|
||||
if (source.description === undefined) out.description = htmlToPlainText(out.description_html).slice(0, 12000);
|
||||
}
|
||||
for (const field of ['tags','photos']) if (source[field] !== undefined) {
|
||||
if (!Array.isArray(source[field])) fail(`${field} muss eine Liste sein.`, field);
|
||||
out[field] = source[field].map(v => cleanText(v, field === 'photos' ? 500 : 120, field)).filter(Boolean).slice(0, ARRAY_LIMITS[field]);
|
||||
}
|
||||
if (source.price !== undefined && source.price !== null && source.price !== '') {
|
||||
const price = Number(source.price);
|
||||
if (!Number.isFinite(price) || price < 0 || price > 100000000) fail('price ist ungültig.', 'price');
|
||||
out.price = price;
|
||||
} else if (source.price !== undefined) out.price = null;
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
export class ListingsService {
|
||||
constructor(repository) { this.repository = repository; }
|
||||
validateCreate(input) { return normalizePayload(input, { partial: false }); }
|
||||
validateUpdate(input) { return normalizePayload(input, { partial: true }); }
|
||||
create(input, actorId) { return this.repository.create({ ...this.validateCreate(input), created_by: actorId || null, updated_by: actorId || null }); }
|
||||
list(filters = {}) { return this.repository.list(cleanText(filters.q || '', 240, 'q'), cleanText(filters.platform || '', 64, 'platform'), cleanText(filters.status || '', 64, 'status')); }
|
||||
get(id) { const item = this.repository.get(Number(id)); if (!item) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return item; }
|
||||
update(id, input, actorId) { this.get(id); return this.repository.update(Number(id), { ...this.validateUpdate(input), updated_by: actorId || null }); }
|
||||
softDelete(id) { this.get(id); this.repository.softDelete(Number(id)); return { ok:true }; }
|
||||
duplicate(id) { const copy = this.repository.duplicate(Number(id)); if (!copy) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return copy; }
|
||||
trash() { return this.repository.listDeleted(); }
|
||||
trashCount() { return { count: this.repository.trashCount() }; }
|
||||
restore(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.restore(Number(id)); return { ok:true }; }
|
||||
permanentDelete(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.permanentDelete(Number(id)); return { ok:true }; }
|
||||
emptyTrash() { const items = this.repository.listDeleted(); for (const item of items) this.repository.permanentDelete(item.id); return { ok:true, deleted:items.length }; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import manifest from './module.json' with {type:'json'}; import { MediaService } from './service.mjs'; let service=null; export function configureMediaModule(deps){service=new MediaService(deps);} export function getMediaService(){if(!service)throw new Error('Media-Modul ist nicht konfiguriert.');return service;} export function createMediaModule(){return {manifest,lifecycle:{async start(){},async stop(){},async health(){return {ok:Boolean(service),native:true,contracts:['library','reference-protection']};}}};}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.media",
|
||||
"name": "Media Library",
|
||||
"version": "2.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Medienbibliothek mit sicherer Pfadbehandlung, Referenzschutz, Favoriten und Metadaten.",
|
||||
"requires": [
|
||||
"vendoo.auth"
|
||||
],
|
||||
"permissions": [
|
||||
"media.view",
|
||||
"media.edit",
|
||||
"media.delete"
|
||||
],
|
||||
"provides": [
|
||||
"media.assets",
|
||||
"media.derivatives"
|
||||
],
|
||||
"consumes": [
|
||||
"auth.identity"
|
||||
],
|
||||
"owns": [
|
||||
"media.library",
|
||||
"media.uploads"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { listMediaLibraryItems, setMediaLibraryFavorite, getListingReferencedMediaPaths, removeMediaLibraryRecords } from '../../../lib/db.mjs';
|
||||
export const mediaRepository=Object.freeze({list:listMediaLibraryItems,setFavorite:setMediaLibraryFavorite,referenced:getListingReferencedMediaPaths,removeRecords:removeMediaLibraryRecords});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { getMediaService } from './index.mjs';
|
||||
export function registerMediaRoutes({app,routes,deps}){const service=getMediaService();routes.register(app,{id:'media.library.list',method:'GET',path:'/api/media-library',owner:'vendoo.media',policy:'media.view',csrf:false,stability:'stable',handler:async(q,r)=>r.json(await service.list(q.query||{}))});routes.register(app,{id:'media.library.favorite',method:'PATCH',path:'/api/media-library/favorite',owner:'vendoo.media',policy:'media.edit',stability:'stable',input:body=>service.validateFavorite(body),handler:async(q,r)=>{const result=service.favorite(q.validatedBody);if(!result.ok)return r.status(400).json({error:'Nur FLUX-Bilder können als Favorit markiert werden.'});deps.auditLog(q.user.id,q.user.email,'media.favorite_updated','media',q.validatedBody.image_path,String(q.validatedBody.favorite),deps.getClientIp(q));r.json(result);}});routes.register(app,{id:'media.library.delete',method:'DELETE',path:'/api/media-library/items',owner:'vendoo.media',policy:'media.delete',stability:'stable',input:body=>service.validateDelete(body),handler:async(q,r)=>{const result=service.delete(q.validatedBody);deps.auditLog(q.user.id,q.user.email,'media.deleted','media',null,JSON.stringify({deleted:result.deleted,protected:result.protected.length}),deps.getClientIp(q));r.json(result);}});}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { existsSync, statSync, unlinkSync } from 'node:fs'; import { resolve, sep, extname } from 'node:path'; import { PlatformError } from '../../kernel/errors.mjs'; function normalizePath(value){const p=String(value||'').replace(/\\/g,'/').replace(/^\/+/, '').trim();if(!p||p.includes('..')||/[\u0000\r\n]/.test(p)) throw new PlatformError('Ungültiger Medienpfad.',{code:'MEDIA_PATH_INVALID',status:400,expose:true});return p;}
|
||||
export class MediaService { constructor({uploadRoot,sharp,repository}){this.uploadRoot=resolve(uploadRoot);this.sharp=sharp;this.repository=repository;}
|
||||
async list(query={}){const source=String(query.source||'all').slice(0,32),search=String(query.q||'').slice(0,240),favoritesOnly=String(query.favorites||'0')==='1',sort=['newest','oldest','name'].includes(String(query.sort))?String(query.sort):'newest';const all=this.repository.list({source,search,favoritesOnly,sort});const pageSize=Math.max(12,Math.min(96,Number(query.page_size)||24)),total=all.length,totalPages=Math.max(1,Math.ceil(total/pageSize)),page=Math.min(Math.max(1,Number(query.page)||1),totalPages);const pageItems=all.slice((page-1)*pageSize,page*pageSize);const items=await Promise.all(pageItems.map(async item=>{const imagePath=normalizePath(item.image_path),absolute=resolve(this.uploadRoot,imagePath);let fileSize=null,width=Number(item.width)||null,height=Number(item.height)||null,format=item.format||extname(imagePath).replace('.','').toLowerCase()||null;if(absolute!==this.uploadRoot&&absolute.startsWith(this.uploadRoot+sep)&&existsSync(absolute)){try{fileSize=statSync(absolute).size;}catch{}if((!width||!height)&&this.sharp){try{const m=await this.sharp(absolute,{failOn:'none'}).metadata();width=m.width||width;height=m.height||height;format=m.format||format;}catch{}}}return {...item,image_path:imagePath,public_url:`/uploads/${imagePath}`,file_size:fileSize,width,height,format};}));const summary=this.repository.list({source:'all',search,favoritesOnly,sort});const counts=summary.reduce((acc,item)=>{for(const s of item.sources||[])acc[s]=(acc[s]||0)+1;return acc;},{});return {items,pagination:{page,page_size:pageSize,total,total_pages:totalPages},summary:{total:summary.length,sources:counts}};}
|
||||
validateFavorite(input={}){for(const key of Object.keys(input||{}))if(!['image_path','favorite'].includes(key))throw new PlatformError(`Unbekanntes Medienfeld: ${key}`,{code:'MEDIA_INPUT_INVALID',status:400,expose:true});return Object.freeze({image_path:normalizePath(input.image_path),favorite:input.favorite!==false});}
|
||||
favorite(input={}){const valid=this.validateFavorite(input);return this.repository.setFavorite(valid.image_path,valid.favorite);}
|
||||
validateDelete(input={}){for(const key of Object.keys(input||{}))if(!['image_paths'].includes(key))throw new PlatformError(`Unbekanntes Medienfeld: ${key}`,{code:'MEDIA_INPUT_INVALID',status:400,expose:true});if(!Array.isArray(input.image_paths))throw new PlatformError('image_paths muss eine Liste sein.',{code:'MEDIA_PATHS_REQUIRED',status:400,expose:true});const paths=[...new Set(input.image_paths.map(normalizePath))].slice(0,500);if(!paths.length)throw new PlatformError('Keine Medien ausgewählt.',{code:'MEDIA_PATHS_REQUIRED',status:400,expose:true});return Object.freeze({image_paths:paths});}
|
||||
delete(input={}){const requested=this.validateDelete(input).image_paths,referenced=this.repository.referenced(),protectedPaths=requested.filter(p=>referenced.has(p)),deletable=requested.filter(p=>!referenced.has(p)),deletedFiles=[];for(const p of deletable){const absolute=resolve(this.uploadRoot,p);if(absolute===this.uploadRoot||!absolute.startsWith(this.uploadRoot+sep))continue;try{if(existsSync(absolute)&&statSync(absolute).isFile()){unlinkSync(absolute);deletedFiles.push(p);}}catch{}}this.repository.removeRecords(deletable);return {ok:true,deleted:deletable.length,deleted_files:deletedFiles.length,protected:protectedPaths};}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
import { ModuleManagerService } from './service.mjs';
|
||||
|
||||
let service = null;
|
||||
|
||||
export function createModuleManagerModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start(context) {
|
||||
service = new ModuleManagerService({
|
||||
modules: context.modules,
|
||||
stateStore: context.moduleStateStore,
|
||||
packageStore: context.modulePackageStore,
|
||||
context,
|
||||
});
|
||||
},
|
||||
async stop() { service = null; },
|
||||
async health() { return { ok: Boolean(service), protected: true }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getModuleManagerService() {
|
||||
if (!service) throw new Error('Module Manager ist noch nicht gestartet.');
|
||||
return service;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.module-manager",
|
||||
"name": "Module Manager",
|
||||
"version": "1.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Sichere Verwaltung eingebauter und installierter Vendoo-Module mit Aktivierung, Export, Quarantäne und kontrollierter Entfernung.",
|
||||
"requires": ["vendoo.security", "vendoo.settings"],
|
||||
"permissions": ["modules.manage"],
|
||||
"provides": ["modules.management", "modules.packages"],
|
||||
"consumes": ["security.policies", "settings.configuration"],
|
||||
"owns": ["modules.state", "modules.packages"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { getModuleManagerService } from './index.mjs';
|
||||
|
||||
const toggleSchema = objectSchema({
|
||||
cascade: { type: 'boolean' },
|
||||
});
|
||||
|
||||
export function registerModuleManagerRoutes({ app, routes, upload, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'modules.catalog.read', method: 'GET', path: '/api/admin/modules', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.catalog.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getModuleManagerService().catalog()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.enable', method: 'POST', path: '/api/admin/modules/:id/enable', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.enabled', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const module = await getModuleManagerService().enable(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.enabled', 'module', req.params.id, null, deps.getClientIp(req));
|
||||
res.json({ module });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.disable', method: 'POST', path: '/api/admin/modules/:id/disable', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.disabled', rateLimit: 'administration', csrf: true, stability: 'stable', input: toggleSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = await getModuleManagerService().disable(req.params.id, req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.disabled', 'module', req.params.id, JSON.stringify(result.disabled), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.install', method: 'POST', path: '/api/admin/modules/install', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.installed', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
middleware: [upload.single('module')],
|
||||
handler: async (req, res) => {
|
||||
const module = getModuleManagerService().install(req.file?.buffer);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.installed', 'module', module.id, module.version, deps.getClientIp(req));
|
||||
res.status(201).json({ module });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.remove', method: 'DELETE', path: '/api/admin/modules/:id', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.removed', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const result = await getModuleManagerService().remove(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.removed', 'module', req.params.id, null, deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.export', method: 'GET', path: '/api/admin/modules/:id/export', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.exported', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const payload = getModuleManagerService().export(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.exported', 'module', req.params.id, null, deps.getClientIp(req));
|
||||
res.setHeader('Content-Type', 'application/vnd.vendoo.module+json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${String(req.params.id).replace(/[^a-z0-9.-]/gi, '_')}.vmod"`);
|
||||
res.send(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { buildModulePackage } from '../../core/modules/module-package-contract.mjs';
|
||||
|
||||
export const PROTECTED_MODULE_IDS = Object.freeze([
|
||||
'vendoo.system',
|
||||
'vendoo.security',
|
||||
'vendoo.auth',
|
||||
'vendoo.users',
|
||||
'vendoo.sessions',
|
||||
'vendoo.settings',
|
||||
'vendoo.themes',
|
||||
'vendoo.listings',
|
||||
'vendoo.inventory',
|
||||
'vendoo.media',
|
||||
'vendoo.operations',
|
||||
'vendoo.module-manager',
|
||||
'vendoo.deployments',
|
||||
]);
|
||||
const PROTECTED_MODULES = new Set(PROTECTED_MODULE_IDS);
|
||||
|
||||
function asPublicModule(record, packagesById) {
|
||||
const external = record.source === 'external';
|
||||
const pkg = packagesById.get(record.id);
|
||||
const protectedModule = PROTECTED_MODULES.has(record.id);
|
||||
const canDisable = !protectedModule && record.enabled;
|
||||
const canEnable = !record.enabled && (!external || pkg?.activatable === true);
|
||||
return {
|
||||
...record,
|
||||
protected: protectedModule,
|
||||
removable: external && !record.enabled,
|
||||
exportable: true,
|
||||
installSource: external ? 'installed' : 'builtin',
|
||||
package: pkg ? {
|
||||
runtime: pkg.runtime,
|
||||
trust: pkg.trust,
|
||||
activatable: pkg.activatable,
|
||||
invalid: Boolean(pkg.invalid),
|
||||
error: pkg.error || null,
|
||||
} : null,
|
||||
actions: {
|
||||
enable: canEnable,
|
||||
disable: canDisable,
|
||||
remove: external && !record.enabled,
|
||||
export: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalLifecycle(pkg) {
|
||||
return {
|
||||
async start() {
|
||||
if (pkg.runtime.mode === 'isolated') {
|
||||
throw new PlatformError('Der isolierte Extension-Host ist für ausführbare Fremdmodule noch nicht aktiviert.', {
|
||||
code: 'MODULE_EXTENSION_HOST_UNAVAILABLE', status: 409, expose: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
async stop() {},
|
||||
async health() {
|
||||
return { ok: true, runtime: pkg.runtime.mode, external: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ModuleManagerService {
|
||||
#modules;
|
||||
#stateStore;
|
||||
#packageStore;
|
||||
#context;
|
||||
|
||||
constructor({ modules, stateStore, packageStore, context }) {
|
||||
this.#modules = modules;
|
||||
this.#stateStore = stateStore;
|
||||
this.#packageStore = packageStore;
|
||||
this.#context = context;
|
||||
}
|
||||
|
||||
registerInstalledPackages() {
|
||||
const installed = this.#packageStore.list();
|
||||
for (const entry of installed) {
|
||||
if (entry.invalid || this.#modules.has(entry.id)) continue;
|
||||
const pkg = this.#packageStore.get(entry.id);
|
||||
const enabled = entry.activatable && this.#stateStore.isEnabled(entry.id, false);
|
||||
this.#modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled });
|
||||
}
|
||||
return installed.length;
|
||||
}
|
||||
|
||||
catalog() {
|
||||
const packages = this.#packageStore.list();
|
||||
const packagesById = new Map(packages.filter(item => item.id).map(item => [item.id, item]));
|
||||
const modules = this.#modules.snapshot().map(record => asPublicModule(record, packagesById));
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.invalid || modules.some(item => item.id === pkg.id)) continue;
|
||||
modules.push({
|
||||
id: pkg.id,
|
||||
name: pkg.id,
|
||||
version: null,
|
||||
type: 'extension-host',
|
||||
status: 'invalid',
|
||||
source: 'external',
|
||||
enabled: false,
|
||||
state: 'invalid',
|
||||
protected: false,
|
||||
removable: true,
|
||||
exportable: false,
|
||||
installSource: 'installed',
|
||||
package: { invalid: true, error: pkg.error, activatable: false },
|
||||
actions: { enable: false, disable: false, remove: true, export: false },
|
||||
});
|
||||
}
|
||||
return {
|
||||
modules: modules.sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id), 'de')),
|
||||
summary: {
|
||||
total: modules.length,
|
||||
enabled: modules.filter(item => item.enabled).length,
|
||||
disabled: modules.filter(item => !item.enabled && item.state !== 'invalid').length,
|
||||
native: modules.filter(item => item.status === 'native').length,
|
||||
legacy: modules.filter(item => item.status === 'legacy-bridge').length,
|
||||
external: modules.filter(item => item.source === 'external').length,
|
||||
protected: modules.filter(item => item.protected).length,
|
||||
},
|
||||
safety: {
|
||||
unsignedDeclarativeAllowed: true,
|
||||
executableRequiresTrustedSignature: true,
|
||||
isolatedHostAvailable: false,
|
||||
directMainProcessExecution: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async enable(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
if (record.source === 'external') {
|
||||
const pkg = this.#packageStore.describe(id);
|
||||
if (!pkg?.activatable) {
|
||||
throw new PlatformError('Dieses Fremdmodul ist nicht aktivierbar. Ausführbarer Code benötigt eine vertrauenswürdige Signatur und den isolierten Extension-Host.', {
|
||||
code: 'MODULE_NOT_ACTIVATABLE', status: 409, expose: true,
|
||||
});
|
||||
}
|
||||
if (pkg.runtime.mode === 'isolated') {
|
||||
throw new PlatformError('Der isolierte Extension-Host ist noch nicht verfügbar.', { code: 'MODULE_EXTENSION_HOST_UNAVAILABLE', status: 409, expose: true });
|
||||
}
|
||||
}
|
||||
const enabling = new Set();
|
||||
const enableWithDependencies = async moduleId => {
|
||||
if (enabling.has(moduleId)) throw new PlatformError('Zyklische Modulabhängigkeit erkannt.', { code: 'MODULE_DEPENDENCY_CYCLE', status: 409, expose: true });
|
||||
const target = this.#modules.get(moduleId);
|
||||
if (!target) throw new PlatformError(`Fehlende Modulabhängigkeit: ${moduleId}`, { code: 'MODULE_DEPENDENCY_MISSING', status: 409, expose: true });
|
||||
if (target.enabled && target.state === 'running') return this.#modules.describe(moduleId);
|
||||
enabling.add(moduleId);
|
||||
for (const dependency of target.manifest.requires) await enableWithDependencies(dependency);
|
||||
const enabled = await this.#modules.enable(moduleId, this.#context);
|
||||
this.#stateStore.setEnabled(moduleId, true, { source: moduleId === id ? 'module-manager' : 'module-manager-dependency' });
|
||||
enabling.delete(moduleId);
|
||||
return enabled;
|
||||
};
|
||||
return enableWithDependencies(id);
|
||||
}
|
||||
|
||||
async disable(id, { cascade = false } = {}) {
|
||||
if (PROTECTED_MODULES.has(id)) {
|
||||
throw new PlatformError('Dieses Kernmodul ist geschützt und kann nicht deaktiviert werden.', {
|
||||
code: 'MODULE_PROTECTED', status: 409, expose: true,
|
||||
});
|
||||
}
|
||||
const dependents = this.#modules.dependentsOf(id, { enabledOnly: true, recursive: true });
|
||||
const protectedDependents = dependents.filter(dependent => PROTECTED_MODULES.has(dependent));
|
||||
if (protectedDependents.length) {
|
||||
throw new PlatformError('Das Modul wird von geschützten Kernmodulen benötigt.', {
|
||||
code: 'MODULE_REQUIRED_BY_PROTECTED', status: 409, expose: true, details: { dependents: protectedDependents },
|
||||
});
|
||||
}
|
||||
const result = await this.#modules.disable(id, this.#context, { cascade: Boolean(cascade) });
|
||||
for (const disabledId of result.disabled) this.#stateStore.setEnabled(disabledId, false, { source: 'module-manager' });
|
||||
return result;
|
||||
}
|
||||
|
||||
install(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new PlatformError('Modulpaket fehlt.', { code: 'MODULE_PACKAGE_REQUIRED', status: 400, expose: true });
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(buffer.toString('utf8')); }
|
||||
catch (error) { throw new PlatformError('Das .vmod-Paket enthält kein gültiges JSON.', { code: 'MODULE_PACKAGE_JSON_INVALID', status: 400, expose: true, cause: error }); }
|
||||
const id = String(parsed?.manifest?.id || '');
|
||||
if (this.#modules.has(id)) throw new PlatformError('Eine Modul-ID mit diesem Namen ist bereits registriert.', { code: 'MODULE_DUPLICATE', status: 409, expose: true });
|
||||
const installed = this.#packageStore.install(parsed);
|
||||
const pkg = this.#packageStore.get(installed.id);
|
||||
try {
|
||||
this.#modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled: false });
|
||||
this.#stateStore.setEnabled(installed.id, false, { source: 'module-install' });
|
||||
} catch (error) {
|
||||
this.#packageStore.remove(installed.id);
|
||||
this.#stateStore.remove(installed.id);
|
||||
throw error;
|
||||
}
|
||||
return this.catalog().modules.find(item => item.id === installed.id);
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) {
|
||||
const removedInvalid = this.#packageStore.remove(id);
|
||||
if (removedInvalid) this.#stateStore.remove(id);
|
||||
return { ok: removedInvalid, id };
|
||||
}
|
||||
if (record.source !== 'external') {
|
||||
throw new PlatformError('Eingebaute Vendoo-Module können nicht gelöscht werden.', { code: 'MODULE_BUILTIN_NOT_REMOVABLE', status: 409, expose: true });
|
||||
}
|
||||
if (record.enabled) throw new PlatformError('Das Modul muss vor dem Löschen deaktiviert werden.', { code: 'MODULE_DISABLE_BEFORE_REMOVE', status: 409, expose: true });
|
||||
await this.#modules.unregister(id, this.#context);
|
||||
this.#packageStore.remove(id);
|
||||
this.#stateStore.remove(id);
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
export(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
if (record.source === 'external') return this.#packageStore.export(id);
|
||||
const pkg = buildModulePackage({
|
||||
manifest: record.manifest,
|
||||
runtime: { mode: 'builtin-reference', entry: null },
|
||||
files: [{ path: 'module.json', content: `${JSON.stringify(record.manifest, null, 2)}\n` }],
|
||||
});
|
||||
return Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
stateSnapshot() {
|
||||
return this.#stateStore.snapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.operations",
|
||||
"name": "Operations Center",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "legacy-bridge",
|
||||
"description": "Vertrag für den bestehenden Vendoo-Bereich Operations Center; schrittweise Migration ohne Funktionsbruch.",
|
||||
"requires": [
|
||||
"vendoo.security"
|
||||
],
|
||||
"permissions": [
|
||||
"operations.manage"
|
||||
],
|
||||
"provides": [
|
||||
"operations.backup",
|
||||
"operations.update"
|
||||
],
|
||||
"consumes": [
|
||||
"security.audit"
|
||||
],
|
||||
"owns": [
|
||||
"operations.backups",
|
||||
"operations.updates"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
let runtime = null;
|
||||
let started = false;
|
||||
|
||||
export function configurePublishingModule(deps = {}) {
|
||||
runtime = {
|
||||
start: deps.start || (() => {}),
|
||||
stop: deps.stop || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPublishingModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() { if (!runtime) throw new Error('Publishing-Modul ist nicht konfiguriert.'); await runtime.start(); started = true; },
|
||||
async stop() { await runtime?.stop?.(); started = false; },
|
||||
async health() { return { ok: Boolean(runtime && started), native: true, queue: 'publishing-jobs' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.publishing",
|
||||
"name": "Publishing",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Publishing-Jobs, Plattformadapter und kontrollierter Queue-Lifecycle.",
|
||||
"requires": [
|
||||
"vendoo.listings",
|
||||
"vendoo.security"
|
||||
],
|
||||
"permissions": [
|
||||
"publishing.view",
|
||||
"publishing.execute",
|
||||
"publishing.manage"
|
||||
],
|
||||
"provides": [
|
||||
"publishing.jobs",
|
||||
"publishing.adapters"
|
||||
],
|
||||
"consumes": [
|
||||
"listings.catalog",
|
||||
"security.policies"
|
||||
],
|
||||
"owns": [
|
||||
"publishing.queue",
|
||||
"publishing.events"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
export function createQualityModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async health() { return { ok: true, native: true, contracts: ['reports', 'ai-suggestions'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.quality",
|
||||
"name": "Quality Center",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Qualitätsanalyse, Verlauf und bestätigte AI-Vorschläge.",
|
||||
"requires": [
|
||||
"vendoo.listings",
|
||||
"vendoo.ai"
|
||||
],
|
||||
"permissions": [
|
||||
"quality.execute"
|
||||
],
|
||||
"provides": [
|
||||
"quality.reports"
|
||||
],
|
||||
"consumes": [
|
||||
"listings.catalog",
|
||||
"ai.text"
|
||||
],
|
||||
"owns": [
|
||||
"quality.history"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { secretStoreStatus } from '../../../lib/secret-store.mjs';
|
||||
import { getRequestTelemetry } from '../../../lib/structured-logger.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
|
||||
export function createSecurityModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async health() {
|
||||
const secrets = secretStoreStatus();
|
||||
return { ok: true, provider: secrets.provider, encrypted_secrets: secrets.encrypted };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getSecurityFoundationStatus() {
|
||||
return { secrets: secretStoreStatus(), requests: getRequestTelemetry() };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.security",
|
||||
"name": "Security Core",
|
||||
"version": "2.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Nativer Sicherheitskern für verschlüsselte Secrets, Request-Telemetrie, Sicherheitsheader und Security-Dashboard.",
|
||||
"requires": [
|
||||
"vendoo.system"
|
||||
],
|
||||
"permissions": [
|
||||
"security.manage"
|
||||
],
|
||||
"provides": [
|
||||
"security.policies",
|
||||
"security.audit",
|
||||
"security.secrets",
|
||||
"security.telemetry",
|
||||
"security.headers"
|
||||
],
|
||||
"consumes": [
|
||||
"platform.events"
|
||||
],
|
||||
"owns": [
|
||||
"security.permissions",
|
||||
"security.rate-limits",
|
||||
"security.secret-store",
|
||||
"security.request-telemetry"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
|
||||
export function getSessionsService() {
|
||||
if (!service) throw new PlatformError('Sitzungs-Modul ist noch nicht gestartet.', { code: 'SESSIONS_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createSessionsModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = (await import('./service.mjs')).createSessionsService(); },
|
||||
async stop() { service = null; },
|
||||
async health() { return getSessionsService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.sessions",
|
||||
"name": "Sessions",
|
||||
"version": "1.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Native Sitzungsverwaltung mit Idle-Timeout, Eigentümerprüfung, Revocation und administrativer Bereinigung.",
|
||||
"requires": ["vendoo.security"],
|
||||
"permissions": ["sessions.manage"],
|
||||
"provides": ["auth.sessions", "sessions.lifecycle"],
|
||||
"consumes": ["security.policies"],
|
||||
"owns": ["auth.sessions"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getSessionsService } from './index.mjs';
|
||||
|
||||
export function registerSessionRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'sessions.admin.list', method: 'GET', path: '/api/admin/sessions', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getSessionsService().listAll()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'sessions.admin.revoke', method: 'DELETE', path: '/api/admin/sessions/:id', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const session = getSessionsService().revokeAsAdmin(Number(req.params.id));
|
||||
deps.auditLog(req.user.id, req.user.email, 'session.destroyed', 'session', String(session.id), null, deps.getClientIp(req));
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'sessions.admin.revoke-user', method: 'DELETE', path: '/api/admin/users/:id/sessions', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
getSessionsService().revokeAllForUser(Number(req.params.id));
|
||||
deps.auditLog(req.user.id, req.user.email, 'sessions.destroyed_all', 'user', String(req.params.id), null, deps.getClientIp(req));
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'sessions.admin.cleanup', method: 'POST', path: '/api/admin/sessions/cleanup', owner: 'vendoo.sessions', policy: 'sessions.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
getSessionsService().cleanup();
|
||||
deps.auditLog(req.user.id, req.user.email, 'sessions.cleanup', null, null, null, deps.getClientIp(req));
|
||||
res.json({ ok: true, message: 'Abgelaufene Sessions bereinigt' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'sessions.self.list', method: 'GET', path: '/api/my/sessions', owner: 'vendoo.sessions', policy: 'sessions.self', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => res.json(getSessionsService().listForUser(req.user.id)),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'sessions.self.revoke', method: 'DELETE', path: '/api/my/sessions/:id', owner: 'vendoo.sessions', policy: 'sessions.self', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const session = getSessionsService().revokeOwn(req.user.id, Number(req.params.id));
|
||||
deps.auditLog(req.user.id, req.user.email, 'session.destroyed_own', 'session', String(session.id), null, deps.getClientIp(req));
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
cleanExpiredSessions, destroyOwnedSessionById, destroySessionById, destroyUserSessions,
|
||||
getAllActiveSessions, getSessionById, getUserSessions,
|
||||
} from '../../core/identity/identity-store.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
function fail(message, status = 400, code = 'SESSION_OPERATION_FAILED') {
|
||||
throw new PlatformError(message, { status, code, expose: true });
|
||||
}
|
||||
|
||||
export function createSessionsService() {
|
||||
return Object.freeze({
|
||||
listAll() { return getAllActiveSessions(); },
|
||||
listForUser(userId) { return getUserSessions(userId); },
|
||||
revokeAsAdmin(sessionId) {
|
||||
const session = getSessionById(sessionId);
|
||||
if (!session) fail('Sitzung nicht gefunden.', 404, 'SESSION_NOT_FOUND');
|
||||
destroySessionById(sessionId);
|
||||
return session;
|
||||
},
|
||||
revokeAllForUser(userId) {
|
||||
destroyUserSessions(userId);
|
||||
return { user_id: Number(userId) };
|
||||
},
|
||||
revokeOwn(userId, sessionId) {
|
||||
const changed = destroyOwnedSessionById(userId, sessionId);
|
||||
if (!changed) fail('Sitzung nicht gefunden oder gehört nicht zu diesem Benutzer.', 404, 'SESSION_NOT_OWNED');
|
||||
return { id: Number(sessionId), user_id: Number(userId) };
|
||||
},
|
||||
cleanup() { cleanExpiredSessions(); return { ok: true }; },
|
||||
health() { return { ok: true, active_sessions: getAllActiveSessions().length }; },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
let adapters = { etsyApi: null, ebayApi: null };
|
||||
|
||||
export function configureSettingsModule(nextAdapters = {}) { adapters = { ...adapters, ...nextAdapters }; }
|
||||
export function getSettingsService() {
|
||||
if (!service) throw new PlatformError('Einstellungs-Modul ist noch nicht gestartet.', { code: 'SETTINGS_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
export function createSettingsModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = (await import('./service.mjs')).createSettingsService(adapters); },
|
||||
async stop() { service = null; },
|
||||
async health() { return getSettingsService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.settings",
|
||||
"name": "Settings",
|
||||
"version": "1.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Native Anwendungs- und SMTP-Einstellungen mit Allowlist, verschlüsselten Secrets und Runtime-Konfigurationsadapter.",
|
||||
"requires": [
|
||||
"vendoo.security",
|
||||
"vendoo.users"
|
||||
],
|
||||
"permissions": [
|
||||
"settings.manage"
|
||||
],
|
||||
"provides": [
|
||||
"settings.configuration",
|
||||
"settings.application",
|
||||
"settings.smtp"
|
||||
],
|
||||
"consumes": [
|
||||
"security.secrets",
|
||||
"users.directory"
|
||||
],
|
||||
"owns": [
|
||||
"settings.application",
|
||||
"settings.smtp"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { db } from '../../../lib/db.mjs';
|
||||
|
||||
export function readApplicationSettings() {
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all();
|
||||
return Object.fromEntries(rows.map(row => [row.key, row.value]));
|
||||
}
|
||||
|
||||
export function writeApplicationSettings(settings) {
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
|
||||
const transaction = db.transaction(entries => {
|
||||
for (const [key, value] of entries) stmt.run(key, value == null ? '' : String(value));
|
||||
});
|
||||
transaction(Object.entries(settings));
|
||||
return readApplicationSettings();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { getSettingsService } from './index.mjs';
|
||||
|
||||
const smtpSchema = objectSchema({
|
||||
smtp_host: { type: 'string', maxLength: 255 }, smtp_port: { type: 'string', maxLength: 8 },
|
||||
smtp_user: { type: 'string', maxLength: 255 }, smtp_pass: { type: 'string', maxLength: 4096, trim: false },
|
||||
smtp_from: { type: 'string', maxLength: 255 },
|
||||
});
|
||||
|
||||
export function registerSettingsRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'settings.application.read', method: 'GET', path: '/api/settings', owner: 'vendoo.settings', policy: 'settings.read', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getSettingsService().get()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'settings.application.update', method: 'PUT', path: '/api/settings', owner: 'vendoo.settings', policy: 'settings.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const result = getSettingsService().update(req.body || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'settings.updated', 'settings', 'application', null, deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'settings.smtp.read', method: 'GET', path: '/api/admin/smtp', owner: 'vendoo.settings', policy: 'settings.manage', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getSettingsService().smtpStatus()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'settings.smtp.update', method: 'PUT', path: '/api/admin/smtp', owner: 'vendoo.settings', policy: 'settings.manage', csrf: true, stability: 'stable', input: smtpSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = getSettingsService().updateSmtp(req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'smtp.updated', 'settings', 'smtp', null, deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'settings.smtp.test', method: 'POST', path: '/api/admin/smtp/test', owner: 'vendoo.settings', policy: 'settings.manage', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
if (!deps.isMailerConfigured()) return res.status(400).json({ error: 'SMTP nicht konfiguriert. Bitte zuerst speichern.' });
|
||||
if (!req.user?.email) return res.status(400).json({ error: 'Admin-E-Mail nicht gefunden' });
|
||||
await deps.testMailer(req.user.email);
|
||||
deps.auditLog(req.user.id, req.user.email, 'smtp.test_sent', 'settings', 'smtp', `an ${req.user.email}`, deps.getClientIp(req));
|
||||
res.json({ ok: true, message: `Testmail an ${req.user.email} gesendet` });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
|
||||
import { getSecret, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
|
||||
import { isMailerConfigured, reinitMailer } from '../../../lib/mailer.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { readApplicationSettings, writeApplicationSettings } from './repository.mjs';
|
||||
|
||||
const SECRET_FIELDS = Object.freeze({
|
||||
anthropic_key: 'ANTHROPIC_API_KEY', openai_key: 'OPENAI_API_KEY', openrouter_key: 'OPENROUTER_API_KEY',
|
||||
etsy_key: 'ETSY_API_KEY', ebay_client_secret: 'EBAY_CLIENT_SECRET', ai_model_photo_local_token: 'LOCAL_IMAGE_TOKEN',
|
||||
});
|
||||
const RUNTIME_FIELDS = Object.freeze({
|
||||
ebay_client_id: 'EBAY_CLIENT_ID', ebay_ru_name: 'EBAY_RU_NAME', ebay_sandbox: 'EBAY_SANDBOX',
|
||||
ollama_url: 'OLLAMA_URL', public_base_url: 'PUBLIC_BASE_URL', ai_model_photo_local_url: 'LOCAL_IMAGE_URL',
|
||||
ai_model_photo_local_workflow_path: 'LOCAL_IMAGE_WORKFLOW_PATH', ai_model_photo_local_install_path: 'LOCAL_IMAGE_INSTALL_PATH',
|
||||
});
|
||||
const DB_FIELDS = new Set([
|
||||
'default_platform','default_ai','default_language','seller_notes','ai_model_photos_enabled','ai_model_photo_provider',
|
||||
'ai_model_photo_default_mode','ai_model_photo_default_variants','ai_model_photo_default_preset','ai_model_photo_openrouter_free_only',
|
||||
'ai_model_photo_block_underwear','ai_model_photo_block_kids','ai_model_photo_preserve_logos','ai_model_photo_openrouter_model',
|
||||
'ai_model_photo_openai_model','ai_model_photo_local_enabled','ai_model_photo_local_require_token','ai_model_photo_use_source_reference',
|
||||
'ai_model_photo_reference_strength','ai_model_photo_local_resolution','flux_runtime_profile','flux_default_profile',
|
||||
'flux_prompt_assistant','flux_default_style','flux_default_format','flux_default_variants','flux_seed_lock','flux_max_parallel_jobs',
|
||||
'smtp_host','smtp_port','smtp_user','smtp_from',
|
||||
]);
|
||||
const ALL_FIELDS = new Set([...Object.keys(SECRET_FIELDS), ...Object.keys(RUNTIME_FIELDS), ...DB_FIELDS]);
|
||||
|
||||
function fail(message, status = 400, code = 'SETTINGS_INVALID') {
|
||||
throw new PlatformError(message, { status, code, expose: true });
|
||||
}
|
||||
|
||||
function cleanString(value, { max = 4096, multiline = false } = {}) {
|
||||
const result = String(value ?? '').trim();
|
||||
if (result.length > max) fail('Einstellungswert ist zu lang.');
|
||||
if (!multiline && /[\r\n\0]/.test(result)) fail('Einstellungswert enthält unzulässige Steuerzeichen.');
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createSettingsService({ etsyApi, ebayApi }) {
|
||||
function decorate(settings) {
|
||||
const result = { ...settings };
|
||||
result.has_anthropic_key = hasSecret('ANTHROPIC_API_KEY');
|
||||
result.has_openai_key = hasSecret('OPENAI_API_KEY');
|
||||
result.has_openrouter_key = hasSecret('OPENROUTER_API_KEY');
|
||||
result.has_etsy_key = hasSecret('ETSY_API_KEY');
|
||||
result.etsy_connected = Boolean(etsyApi?.isConnected?.());
|
||||
result.has_ebay_credentials = Boolean(ebayApi?.hasCredentials?.());
|
||||
result.ebay_connected = Boolean(ebayApi?.isConnected?.());
|
||||
result.ebay_sandbox = process.env.EBAY_SANDBOX === 'true';
|
||||
result.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434';
|
||||
result.public_base_url = process.env.PUBLIC_BASE_URL || '';
|
||||
result.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || result.ai_model_photo_local_url || 'http://127.0.0.1:8188';
|
||||
result.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || result.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json';
|
||||
result.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || result.ai_model_photo_local_install_path || 'local-ai/comfyui';
|
||||
result.ai_model_photo_local_enabled = result.ai_model_photo_local_enabled || '1';
|
||||
result.ai_model_photo_local_require_token = result.ai_model_photo_local_require_token || '1';
|
||||
result.ai_model_photo_use_source_reference = result.ai_model_photo_use_source_reference || '1';
|
||||
result.ai_model_photo_reference_strength = result.ai_model_photo_reference_strength || '70';
|
||||
result.ai_model_photo_local_resolution = result.ai_model_photo_local_resolution || '768';
|
||||
result.ai_model_photo_preserve_logos = result.ai_model_photo_preserve_logos || '1';
|
||||
result.flux_runtime_profile = result.flux_runtime_profile || 'auto';
|
||||
result.flux_default_profile = result.flux_default_profile || 'balanced';
|
||||
result.flux_prompt_assistant = result.flux_prompt_assistant || '1';
|
||||
result.flux_default_style = result.flux_default_style || 'photorealistic';
|
||||
result.flux_default_format = result.flux_default_format || '768x768';
|
||||
result.flux_default_variants = result.flux_default_variants || '1';
|
||||
result.flux_seed_lock = result.flux_seed_lock || '0';
|
||||
result.flux_max_parallel_jobs = result.flux_max_parallel_jobs || '1';
|
||||
result.local_image_token_set = hasSecret('LOCAL_IMAGE_TOKEN');
|
||||
return result;
|
||||
}
|
||||
|
||||
function validatePatch(input) {
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
||||
for (const key of Object.keys(source)) if (!ALL_FIELDS.has(key)) fail(`Unbekanntes Einstellungsfeld: ${key}`);
|
||||
return source;
|
||||
}
|
||||
|
||||
function smtpStatus() {
|
||||
const settings = readApplicationSettings();
|
||||
return {
|
||||
smtp_host: settings.smtp_host || process.env.SMTP_HOST || '', smtp_port: settings.smtp_port || process.env.SMTP_PORT || '587',
|
||||
smtp_user: settings.smtp_user || process.env.SMTP_USER || '', smtp_pass: hasSecret('SMTP_PASS') ? '••••••••' : '',
|
||||
smtp_from: settings.smtp_from || process.env.SMTP_FROM || '', configured: isMailerConfigured(),
|
||||
source: hasSecret('SMTP_PASS') ? 'encrypted-secret-store' : (process.env.SMTP_HOST ? 'environment' : 'none'),
|
||||
};
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
get() { return decorate(readApplicationSettings()); },
|
||||
update(input) {
|
||||
const source = validatePatch(input);
|
||||
let envContent = readRuntimeConfig();
|
||||
const dbPatch = {};
|
||||
for (const [field, secretName] of Object.entries(SECRET_FIELDS)) {
|
||||
if (source[field] !== undefined && source[field] !== '') setSecret(secretName, cleanString(source[field], { max: 16384 }), { source: 'settings-ui' });
|
||||
}
|
||||
for (const [field, envName] of Object.entries(RUNTIME_FIELDS)) {
|
||||
if (source[field] === undefined) continue;
|
||||
let value = cleanString(source[field], { max: 4096 });
|
||||
if (['public_base_url','ai_model_photo_local_url'].includes(field)) value = value.replace(/\/$/, '');
|
||||
envContent = setConfigValue(envContent, envName, value);
|
||||
process.env[envName] = value;
|
||||
}
|
||||
for (const field of DB_FIELDS) {
|
||||
if (source[field] === undefined) continue;
|
||||
dbPatch[field] = cleanString(source[field], { max: field === 'seller_notes' ? 16000 : 4096, multiline: field === 'seller_notes' });
|
||||
}
|
||||
if (dbPatch.ai_model_photo_local_enabled !== undefined) {
|
||||
const value = String(dbPatch.ai_model_photo_local_enabled) === '0' ? 'false' : 'true';
|
||||
envContent = setConfigValue(envContent, 'LOCAL_IMAGE_ENABLED', value); process.env.LOCAL_IMAGE_ENABLED = value;
|
||||
}
|
||||
if (dbPatch.ai_model_photo_local_require_token !== undefined) {
|
||||
const value = String(dbPatch.ai_model_photo_local_require_token) === '0' ? 'false' : 'true';
|
||||
envContent = setConfigValue(envContent, 'LOCAL_IMAGE_REQUIRE_TOKEN', value); process.env.LOCAL_IMAGE_REQUIRE_TOKEN = value;
|
||||
}
|
||||
writeRuntimeConfig(envContent);
|
||||
return decorate(writeApplicationSettings(dbPatch));
|
||||
},
|
||||
smtpStatus,
|
||||
|
||||
updateSmtp(input) {
|
||||
const patch = {};
|
||||
for (const field of ['smtp_host','smtp_port','smtp_user','smtp_from']) if (input[field] !== undefined) patch[field] = cleanString(input[field], { max: 512 });
|
||||
if (input.smtp_pass !== undefined && input.smtp_pass !== '••••••••' && input.smtp_pass !== '') setSecret('SMTP_PASS', cleanString(input.smtp_pass, { max: 4096 }), { source: 'smtp-ui' });
|
||||
patch.smtp_pass = '';
|
||||
const settings = writeApplicationSettings(patch);
|
||||
const smtpSecret = getSecret('SMTP_PASS');
|
||||
if (settings.smtp_host && settings.smtp_user && smtpSecret) {
|
||||
reinitMailer({ host: settings.smtp_host, port: settings.smtp_port || '587', user: settings.smtp_user, pass: smtpSecret });
|
||||
if (settings.smtp_from) process.env.SMTP_FROM = settings.smtp_from;
|
||||
}
|
||||
return smtpStatus();
|
||||
},
|
||||
health() { return { ok: true, settings: Object.keys(readApplicationSettings()).length, smtp_configured: isMailerConfigured() }; },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.system",
|
||||
"name": "System Kernel",
|
||||
"version": "1.0.0",
|
||||
"type": "core",
|
||||
"status": "legacy-bridge",
|
||||
"description": "Vertrag für den bestehenden Vendoo-Bereich System Kernel; schrittweise Migration ohne Funktionsbruch.",
|
||||
"requires": [],
|
||||
"permissions": [
|
||||
"dashboard.view"
|
||||
],
|
||||
"provides": [
|
||||
"platform.lifecycle",
|
||||
"platform.events",
|
||||
"platform.features"
|
||||
],
|
||||
"consumes": [],
|
||||
"owns": [
|
||||
"system.runtime",
|
||||
"system.health"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
|
||||
export function getThemeService() {
|
||||
if (!service) throw new PlatformError('Theme-Modul ist noch nicht gestartet.', { code: 'THEME_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createThemesModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() {
|
||||
const { createThemeService } = await import('./service.mjs');
|
||||
service = createThemeService();
|
||||
},
|
||||
async stop() { service = null; },
|
||||
async health() { return getThemeService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS theme_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_theme TEXT NOT NULL DEFAULT 'light',
|
||||
values_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_theme_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
theme_id TEXT NOT NULL DEFAULT 'inherit',
|
||||
density TEXT NOT NULL DEFAULT 'comfortable',
|
||||
reduced_motion TEXT NOT NULL DEFAULT 'system',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_theme_profiles_updated ON theme_profiles(updated_at DESC);
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.themes",
|
||||
"name": "Themes & Design System",
|
||||
"version": "1.1.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Natives Theme- und Design-System-Modul mit sicherem Token-Editor, Benutzerpräferenzen und Accessibility-Prüfungen.",
|
||||
"requires": [
|
||||
"vendoo.system"
|
||||
],
|
||||
"permissions": [
|
||||
"settings.manage",
|
||||
"themes.use"
|
||||
],
|
||||
"provides": [
|
||||
"themes.tokens",
|
||||
"themes.runtime",
|
||||
"themes.profiles",
|
||||
"themes.accessibility"
|
||||
],
|
||||
"consumes": [
|
||||
"platform.events"
|
||||
],
|
||||
"owns": [
|
||||
"themes.definitions",
|
||||
"themes.user-preferences",
|
||||
"themes.system-default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { db } from '../../../lib/db.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const migrationSql = readFileSync(join(here, 'migrations', '001_theme_profiles.sql'), 'utf8');
|
||||
|
||||
export function ensureThemeSchema() {
|
||||
db.exec(migrationSql);
|
||||
}
|
||||
|
||||
function parseProfile(row) {
|
||||
if (!row) return null;
|
||||
let values = {};
|
||||
try { values = JSON.parse(row.values_json || '{}'); } catch {}
|
||||
return { ...row, values };
|
||||
}
|
||||
|
||||
export function listThemeProfiles() {
|
||||
ensureThemeSchema();
|
||||
return db.prepare('SELECT * FROM theme_profiles ORDER BY name COLLATE NOCASE, id').all().map(parseProfile);
|
||||
}
|
||||
|
||||
export function getThemeProfile(id) {
|
||||
ensureThemeSchema();
|
||||
return parseProfile(db.prepare('SELECT * FROM theme_profiles WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
export function saveThemeProfile(profile, userId) {
|
||||
ensureThemeSchema();
|
||||
db.prepare(`INSERT INTO theme_profiles (id, name, base_theme, values_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, base_theme = excluded.base_theme,
|
||||
values_json = excluded.values_json, updated_at = datetime('now')`).run(
|
||||
profile.id, profile.name, profile.base_theme, JSON.stringify(profile.values || {}), userId || null,
|
||||
);
|
||||
return getThemeProfile(profile.id);
|
||||
}
|
||||
|
||||
export function deleteThemeProfile(id) {
|
||||
ensureThemeSchema();
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare("UPDATE user_theme_preferences SET theme_id = 'inherit', updated_at = datetime('now') WHERE theme_id = ?").run(id);
|
||||
const system = db.prepare("SELECT value FROM settings WHERE key = 'theme_system_default'").get();
|
||||
if (system?.value === id) db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('theme_system_default', 'system')").run();
|
||||
return db.prepare('DELETE FROM theme_profiles WHERE id = ?').run(id).changes;
|
||||
});
|
||||
return transaction();
|
||||
}
|
||||
|
||||
export function getUserThemePreference(userId) {
|
||||
ensureThemeSchema();
|
||||
return db.prepare('SELECT theme_id, density, reduced_motion, updated_at FROM user_theme_preferences WHERE user_id = ?').get(userId) || {
|
||||
theme_id: 'inherit', density: 'comfortable', reduced_motion: 'system', updated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveUserThemePreference(userId, preference) {
|
||||
ensureThemeSchema();
|
||||
db.prepare(`INSERT INTO user_theme_preferences (user_id, theme_id, density, reduced_motion)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET theme_id = excluded.theme_id, density = excluded.density,
|
||||
reduced_motion = excluded.reduced_motion, updated_at = datetime('now')`).run(
|
||||
userId, preference.theme_id, preference.density, preference.reduced_motion,
|
||||
);
|
||||
return getUserThemePreference(userId);
|
||||
}
|
||||
|
||||
function getSetting(key, fallback) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row?.value ?? fallback;
|
||||
}
|
||||
|
||||
function setSetting(key, value) {
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, String(value));
|
||||
}
|
||||
|
||||
export function getThemeSystemDefault() {
|
||||
return {
|
||||
theme_id: getSetting('theme_system_default', 'system'),
|
||||
density: getSetting('theme_system_density', 'comfortable'),
|
||||
reduced_motion: getSetting('theme_system_reduced_motion', 'system'),
|
||||
};
|
||||
}
|
||||
|
||||
export function saveThemeSystemDefault(preference) {
|
||||
const transaction = db.transaction(() => {
|
||||
setSetting('theme_system_default', preference.theme_id);
|
||||
setSetting('theme_system_density', preference.density);
|
||||
setSetting('theme_system_reduced_motion', preference.reduced_motion);
|
||||
});
|
||||
transaction();
|
||||
return getThemeSystemDefault();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import {
|
||||
evaluateThemeAccessibility, getBaseThemeValues, getBuiltinThemes, getThemeContract,
|
||||
resolveThemeValues, validateThemeDefinition, validateThemePreference,
|
||||
} from '../../core/themes/theme-contract.mjs';
|
||||
import {
|
||||
deleteThemeProfile, ensureThemeSchema, getThemeProfile, getThemeSystemDefault,
|
||||
getUserThemePreference, listThemeProfiles, saveThemeProfile, saveThemeSystemDefault,
|
||||
saveUserThemePreference,
|
||||
} from './repository.mjs';
|
||||
|
||||
function decorate(profile) {
|
||||
return {
|
||||
...profile,
|
||||
builtin: false,
|
||||
resolved_values: resolveThemeValues({ baseTheme: profile.base_theme, values: profile.values }),
|
||||
accessibility: evaluateThemeAccessibility({ baseTheme: profile.base_theme, values: profile.values }),
|
||||
};
|
||||
}
|
||||
|
||||
function customExists(id) {
|
||||
return Boolean(getThemeProfile(id));
|
||||
}
|
||||
|
||||
function effectivePreference(userPreference, systemDefault) {
|
||||
return userPreference.theme_id === 'inherit' ? { ...systemDefault, inherited: true } : { ...userPreference, inherited: false };
|
||||
}
|
||||
|
||||
export function createThemeService() {
|
||||
ensureThemeSchema();
|
||||
return Object.freeze({
|
||||
health() {
|
||||
ensureThemeSchema();
|
||||
return { ok: true, profiles: listThemeProfiles().length, contract: getThemeContract().contractVersion };
|
||||
},
|
||||
catalog(userId) {
|
||||
const custom = listThemeProfiles().map(decorate);
|
||||
const preference = getUserThemePreference(userId);
|
||||
const systemDefault = getThemeSystemDefault();
|
||||
return {
|
||||
contract: getThemeContract(),
|
||||
builtin: getBuiltinThemes(),
|
||||
custom,
|
||||
preference,
|
||||
system_default: systemDefault,
|
||||
effective_preference: effectivePreference(preference, systemDefault),
|
||||
};
|
||||
},
|
||||
validate(input) {
|
||||
const theme = validateThemeDefinition(input, { allowExistingId: true });
|
||||
return { theme, resolved_values: resolveThemeValues({ baseTheme: theme.base_theme, values: theme.values }), accessibility: theme.accessibility };
|
||||
},
|
||||
create(input, userId) {
|
||||
const theme = validateThemeDefinition(input);
|
||||
if (getThemeProfile(theme.id)) throw new PlatformError('Theme-ID ist bereits vorhanden.', { code: 'THEME_DUPLICATE', status: 409, expose: true });
|
||||
return decorate(saveThemeProfile(theme, userId));
|
||||
},
|
||||
update(id, input, userId) {
|
||||
if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
|
||||
const theme = validateThemeDefinition({ ...input, id }, { allowExistingId: true });
|
||||
return decorate(saveThemeProfile(theme, userId));
|
||||
},
|
||||
remove(id) {
|
||||
if (!getThemeProfile(id)) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
|
||||
deleteThemeProfile(id);
|
||||
return { ok: true, id };
|
||||
},
|
||||
saveUserPreference(userId, input) {
|
||||
const preference = validateThemePreference(input, { customThemeExists: customExists });
|
||||
return saveUserThemePreference(userId, preference);
|
||||
},
|
||||
saveSystemDefault(input) {
|
||||
const preference = validateThemePreference(input, { customThemeExists: customExists });
|
||||
if (preference.theme_id === 'inherit') throw new PlatformError('Der Systemstandard kann nicht auf „Vererben“ gesetzt werden.', { code: 'THEME_SYSTEM_INHERIT_INVALID', status: 400, expose: true });
|
||||
return saveThemeSystemDefault(preference);
|
||||
},
|
||||
exportTheme(id) {
|
||||
const profile = getThemeProfile(id);
|
||||
if (!profile) throw new PlatformError('Theme wurde nicht gefunden.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
|
||||
return { schemaVersion: 1, type: 'vendoo-theme', theme: { id: profile.id, name: profile.name, base_theme: profile.base_theme, values: profile.values } };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { getBaseThemeValues };
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
|
||||
export function getUsersService() {
|
||||
if (!service) throw new PlatformError('Benutzer-Modul ist noch nicht gestartet.', { code: 'USERS_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createUsersModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = (await import('./service.mjs')).createUsersService(); },
|
||||
async stop() { service = null; },
|
||||
async health() { return getUsersService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.users",
|
||||
"name": "Users & Roles",
|
||||
"version": "2.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Native Benutzer-, Profil-, Rollen- und Passwortverwaltung mit Schutz des letzten Administrators.",
|
||||
"requires": ["vendoo.auth", "vendoo.sessions"],
|
||||
"permissions": ["users.manage"],
|
||||
"provides": ["users.directory", "users.roles"],
|
||||
"consumes": ["auth.identity", "sessions.lifecycle"],
|
||||
"owns": ["users.accounts", "users.profiles"]
|
||||
}
|
||||