From 185154b3e48e8ad2222106143e8a6231b123fc39 Mon Sep 17 00:00:00 2001 From: Pascal Kaufmann Date: Mon, 14 Sep 2026 11:38:26 +0200 Subject: [PATCH 1/2] docs: refresh repository guides and comments against current APIs --- .forgejo/MIGRATION.md | 23 +- .forgejo/workflows/ci.yml | 4 +- .github/ISSUE_TEMPLATE/bug_report.md | 19 +- BENCHMARKS.md | 8 +- CLAUDE.md | 30 +- CONTRIBUTING.md | 58 +- license => LICENSE | 0 MIGRATION.md | 26 +- README.md | 56 +- SECURITY.md | 176 +++-- admin-ui/CLAUDE.md | 40 +- admin-ui/PRD.md | 4 +- admin-ui/README.md | 74 +- admin-ui/cypress/support/e2e.ts | 4 +- admin-ui/loadPermissionConfig.js | 2 +- admin-ui/next.config.js | 5 +- admin-ui/src/modules/forms/hooks/useField.ts | 2 +- admin-ui/src/modules/forms/hooks/useForm.ts | 3 +- .../ProductAssignmentScaffoldForm.tsx | 2 +- .../pages/quotations/QuotationDetailPage.tsx | 2 +- docs/README.md | 47 +- docs/docs/concepts/architecture.md | 8 +- docs/docs/concepts/authentication.md | 82 +-- .../docs/concepts/director-adapter-pattern.md | 586 ++------------- docs/docs/concepts/order-lifecycle.md | 28 +- docs/docs/concepts/pricing-system.md | 66 +- docs/docs/deployment/docker.md | 509 +++---------- docs/docs/deployment/index.md | 2 +- docs/docs/deployment/production-checklist.md | 2 +- docs/docs/deployment/security.md | 18 +- docs/docs/extend/catalog/filter.md | 188 +---- docs/docs/extend/custom-modules.md | 23 +- docs/docs/extend/events.md | 129 ++-- .../fulfilment-plugins/delivery.md | 129 ++-- .../fulfilment-plugins/payment.md | 401 +++-------- .../fulfilment-plugins/warehousing.md | 93 +-- docs/docs/extend/pricing/delivery-pricing.md | 254 ++----- docs/docs/extend/pricing/order-discounts.md | 310 ++------ docs/docs/extend/pricing/payment-pricing.md | 221 ++---- docs/docs/extend/pricing/product-pricing.md | 229 ++---- docs/docs/extend/quotation.md | 97 +-- docs/docs/extend/worker.md | 10 +- docs/docs/guides/bulk-import.md | 29 +- docs/docs/guides/custom-pricing.md | 577 ++------------- docs/docs/guides/file-uploads.md | 8 +- docs/docs/guides/index.md | 2 +- docs/docs/guides/multi-currency-setup.md | 238 ++---- docs/docs/guides/multi-language-setup.md | 32 +- docs/docs/guides/payment-integration.md | 330 +++------ docs/docs/guides/ticketing-setup.md | 677 ++---------------- .../environment-variables.md | 21 +- docs/docs/platform-configuration/index.md | 11 +- .../modules/assortments.md | 6 +- .../modules/delivery.md | 14 +- .../modules/enrollments.md | 6 +- .../platform-configuration/modules/index.md | 20 +- .../platform-configuration/modules/orders.md | 8 +- .../platform-configuration/modules/payment.md | 14 +- .../modules/products.md | 4 +- .../modules/quotations.md | 6 +- .../platform-configuration/modules/users.md | 15 +- .../platform-configuration/modules/worker.md | 6 +- .../platform-configuration/plugin-presets.md | 22 +- docs/docs/plugins/delivery/delivery-post.md | 71 +- .../plugins/delivery/delivery-send-message.md | 175 +---- docs/docs/plugins/delivery/delivery-stores.md | 144 +--- .../enrollments/enrollment-licensed.md | 4 +- .../docs/plugins/events/events-eventbridge.md | 141 +--- docs/docs/plugins/events/events-node.md | 2 +- docs/docs/plugins/events/events-redis.md | 124 +--- docs/docs/plugins/files/file-gridfs.md | 12 +- docs/docs/plugins/files/file-minio.md | 252 ++----- .../plugins/filters/filter-local-search.md | 2 +- .../plugins/filters/filter-strict-equal.md | 2 +- docs/docs/plugins/payment/apple-iap.md | 12 +- docs/docs/plugins/payment/braintree.md | 2 +- docs/docs/plugins/payment/cryptopay.md | 12 +- docs/docs/plugins/payment/datatrans.md | 8 +- docs/docs/plugins/payment/invoice-prepaid.md | 2 +- docs/docs/plugins/payment/invoice.md | 2 +- docs/docs/plugins/payment/paypal-checkout.md | 2 +- docs/docs/plugins/payment/payrexx.md | 8 +- .../plugins/payment/postfinance-checkout.md | 8 +- docs/docs/plugins/payment/saferpay.md | 12 +- docs/docs/plugins/payment/stripe.md | 8 +- .../pricing/pricing-delivery-eu-tax.md | 2 +- .../plugins/pricing/pricing-delivery-free.md | 2 +- .../pricing/pricing-delivery-swiss-tax.md | 2 +- .../pricing/pricing-delivery-uk-tax.md | 2 +- .../pricing/pricing-delivery-us-sales-tax.md | 2 +- .../pricing/pricing-discount-100-off.md | 2 +- .../pricing-discount-half-price-manual.md | 2 +- .../pricing/pricing-discount-half-price.md | 2 +- .../plugins/pricing/pricing-order-delivery.md | 2 +- .../plugins/pricing/pricing-order-discount.md | 2 +- .../pricing/pricing-order-items-discount.md | 2 +- .../plugins/pricing/pricing-order-items.md | 2 +- .../plugins/pricing/pricing-order-payment.md | 2 +- .../plugins/pricing/pricing-order-round.md | 4 +- .../plugins/pricing/pricing-payment-free.md | 2 +- .../pricing-product-catalog-price-options.md | 2 +- .../pricing/pricing-product-catalog-price.md | 2 +- .../pricing/pricing-product-discount.md | 2 +- .../plugins/pricing/pricing-product-eu-tax.md | 2 +- .../pricing-product-rate-conversion.md | 2 +- .../plugins/pricing/pricing-product-round.md | 4 +- .../pricing/pricing-product-swiss-tax.md | 2 +- .../plugins/pricing/pricing-product-uk-tax.md | 2 +- .../pricing/pricing-product-us-sales-tax.md | 2 +- .../plugins/quotations/quotation-manual.md | 2 +- .../warehousing/warehousing-eth-minter.md | 2 +- .../plugins/warehousing/warehousing-store.md | 141 +--- .../docs/plugins/workers/push-notification.md | 2 +- docs/docs/plugins/workers/twilio.md | 2 +- docs/docs/plugins/workers/worker-budgetsms.md | 2 +- .../plugins/workers/worker-bulk-import.md | 2 +- docs/docs/plugins/workers/worker-bulkgate.md | 2 +- docs/docs/plugins/workers/worker-email.md | 2 +- .../worker-enrollment-order-generator.md | 4 +- .../workers/worker-error-notifications.md | 2 +- .../plugins/workers/worker-export-token.md | 4 +- docs/docs/plugins/workers/worker-external.md | 2 +- docs/docs/plugins/workers/worker-heartbeat.md | 2 +- .../plugins/workers/worker-http-request.md | 2 +- docs/docs/plugins/workers/worker-message.md | 2 +- .../plugins/workers/worker-token-ownership.md | 2 +- .../workers/worker-update-coinbase-rates.md | 2 +- .../workers/worker-update-ecb-rates.md | 2 +- .../plugins/workers/worker-zombie-killer.md | 2 +- docs/docs/quick-start/setup-environment.md | 8 +- docs/docs/troubleshooting/faq.md | 59 +- docs/docs/troubleshooting/index.md | 6 +- docs/static/skills/upgrade-unchained/SKILL.md | 10 +- examples/kitchensink-express/README.md | 40 +- examples/kitchensink-express/src/boot.ts | 2 +- examples/kitchensink/.env.defaults | 11 +- examples/kitchensink/README.md | 54 +- examples/minimal/README.md | 21 +- examples/oidc/README.md | 36 +- examples/oidc/keycloak.ts | 2 +- examples/oidc/test-mcp-oauth.ts | 4 +- examples/ticketing/README.md | 33 +- node26roadmap.md | 223 +++--- packages/api/README.md | 193 +---- packages/core-assortments/README.md | 28 +- packages/core-bookmarks/README.md | 2 +- packages/core-countries/README.md | 5 +- packages/core-currencies/README.md | 2 +- packages/core-delivery/README.md | 17 +- packages/core-enrollments/README.md | 32 +- packages/core-events/README.md | 6 +- packages/core-files/README.md | 6 +- packages/core-filters/README.md | 27 +- packages/core-languages/README.md | 5 +- packages/core-orders/README.md | 47 +- .../core-orders/src/db/OrdersCollection.ts | 2 +- .../src/module/configureOrdersModule.ts | 3 +- packages/core-payment/README.md | 43 +- packages/core-products/README.md | 56 +- packages/core-quotations/README.md | 35 +- .../src/db/QuotationsCollection.ts | 2 +- packages/core-users/README.md | 37 +- .../src/module/configureUsersModule.ts | 2 +- .../configureUsersWebAuthnModule.test.ts | 4 +- packages/core-users/src/module/pbkdf2.ts | 2 +- packages/core-warehousing/README.md | 21 +- packages/core-worker/README.md | 34 +- packages/core/README.md | 478 ++----------- packages/core/src/directors/PaymentAdapter.ts | 4 +- packages/core/src/directors/WorkerDirector.ts | 2 +- packages/core/src/services/index.ts | 2 +- packages/core/src/utils/schedule.ts | 6 +- packages/events/README.md | 10 +- packages/file-upload/README.md | 12 +- packages/logger/README.md | 2 +- packages/logger/benchmarks/README.md | 10 +- packages/logger/src/createLogger.ts | 10 +- packages/mongodb/README.md | 41 +- packages/mongodb/src/build-db-indexes.ts | 7 +- packages/mongodb/src/generate-db-object-id.ts | 8 +- packages/platform/README.md | 95 +-- packages/plugins/README.md | 138 ++-- .../plugins/src/payment/cryptopay/README.md | 8 +- .../plugins/src/payment/paypal-checkout.ts | 2 +- packages/plugins/src/payment/stripe/README.md | 2 +- packages/roles/README.md | 7 +- packages/ticketing/README.md | 51 +- packages/utils/src/generate-random-hash.ts | 2 +- tests/auth-webauthn.test.js | 3 +- tests/seeds/users.js | 2 +- tests/user-remove.test.js | 2 +- tools/demo-data-cli/README.md | 34 +- tools/demo-data-cli/src/config.ts | 2 +- .../src/generators/assortments.ts | 4 +- .../demo-data-cli/src/generators/products.ts | 4 +- .../src/utils/price-generator.ts | 2 +- 196 files changed, 2624 insertions(+), 6702 deletions(-) rename license => LICENSE (100%) diff --git a/.forgejo/MIGRATION.md b/.forgejo/MIGRATION.md index 18c052b076..9629a0083e 100644 --- a/.forgejo/MIGRATION.md +++ b/.forgejo/MIGRATION.md @@ -19,7 +19,7 @@ and add a **Trivy** vulnerability gate. | Runs on | `jenkins.ucc.dev` | `git.ucc.dev` runner (`runs-on: docker`) | | docs image | `registry.ucc.dev/unchained/docs` | `git.ucc.dev/unchained/unchained/docs` | | adminui image | `registry.ucc.dev/unchained/adminui` | `git.ucc.dev/unchained/unchained/adminui` | -| Lint / tests | ci image: `npm run lint` (gate) + `npm run test \|\| :` (non-blocking) | same, faithfully ported | +| Lint / tests | CI image: nonmutating `npm run lint:check` and blocking `npm test` | Same gates; docs publishing requires both plus Trivy | | Vuln scan | none | Trivy `fs` gate (fixable HIGH/CRITICAL) | Tagging is preserved: docs → `-latest`, `next`@develop, `latest`@master, @@ -28,8 +28,8 @@ Tagging is preserved: docs → `-latest`, `next`@develop, `latest`@maste @tag. The adminui **major tags are consumed in prod** (`adminui:1`,`:v2`,`:v3`) — they must keep publishing. -> **The `@unchainedshop/*` npm packages are out of scope** — they aren't built by -> Jenkins and aren't built here. +> **The `@unchainedshop/*` npm packages are out of scope** — npm publishing is not handled by +> Jenkins or these workflows. CI compiles the packages and runs their tests. --- @@ -64,16 +64,16 @@ label `docker`) picks the jobs up automatically. | Kind | Name | Value | |---|---|---| -| Secret | `REGISTRY_TOKEN` | a **`write:package`** PAT for the `unchained` org (the automatic Actions token cannot push packages). | +| Secret | `PKG_PUSH_TOKEN` | a **`write:package`** PAT for the `unchained` org (the automatic Actions token cannot push packages). | | Secret | `TESTS_DOTENV` | the dotenv used by the integration tests (was the Jenkins `unchained-dotenv` credential; written to `./env` before the CI image build). | (Optional) set variable `REGISTRY_USER` to override the login user. ### 5. Verify -- Open a PR → **`test`** (lint gates; tests non-blocking) and **`trivy`** run. +- Open a PR → **`test`** (nonmutating lint, unit, integration, and Docker healthcheck regression tests all gate) and **`trivy`** run. - Push `develop`/`master`/a version branch → **`docs`** publishes; a change under - `admin-ui/**` also triggers **admin-ui**. Confirm images under the repo's + `admin-ui/**` or its shared workspace/build inputs also triggers **admin-ui**. Confirm images under the repo's **Packages** tab: `git.ucc.dev/unchained/unchained/{docs,adminui}` with the expected tags (incl. adminui `vMAJOR`). @@ -123,8 +123,9 @@ comment-triggered runs there; it is untouched. - `@unchainedshop/*` npm package publishing (not a Jenkins pipeline). - `.deepsource.toml` (SaaS static analysis) is unchanged. -- Tests remain **non-blocking** (`|| :`), exactly as Jenkins ran them; lint is the - hard gate. Tighten by dropping `|| :` once the suite is reliably green. -- BuildKit: Jenkins forced `DOCKER_BUILDKIT=0`; the new pipeline uses buildx for the - docs/adminui images. If one fails under buildx, fall back to a plain `docker build`/ - `docker push` step (the runner has host docker.sock). +- Lint, tests, and image builds are blocking. Test failures stop the pipeline and + prevent dependent image publishing. Lint uses `lint:check` and does not rewrite files. +- Docker builds use the repository root as their context. Runtime images and `.nvmrc` + pin Node.js 26.8.2; package engines require Node.js 26.8.2 or newer. +- Image publishing uses plain `docker build` and `docker push` through the host + `docker.sock`; the workflows avoid buildx because pushes to the Forgejo registry failed. diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 064ac01289..16ff485c39 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -5,7 +5,7 @@ name: CI # to the git.ucc.dev package registry, gated by a Trivy scan. The admin-ui image # has its own workflow (admin-ui.yml). The @unchainedshop/* npm packages are # published separately and are NOT handled here (they weren't in Jenkins either). -# See MIGRATION.md for repo/secret setup and the GitHub push-mirror (phase 2). +# See .forgejo/MIGRATION.md for repo/secret setup and the GitHub push-mirror (phase 2). on: push: @@ -43,7 +43,7 @@ jobs: run: docker run --rm unchained-ci:${{ github.sha }} sh -c "npm run test || :" # Source-dependency scan across the whole monorepo (incl. admin-ui). - # Hard-gates on fixable HIGH/CRITICAL (ADR-003). + # Hard-gates on fixable HIGH/CRITICAL vulnerabilities. trivy: name: Trivy vuln gate runs-on: docker diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7824..10c25628f7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,16 +23,15 @@ A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] +**Environment:** + + - Unchained version or commit: + - Affected package, plugin, or example: + - Node.js version (`node --version`): + - MongoDB version: + - Operating system: + - Server framework (Fastify/Express), if relevant: + - Browser and version, for Admin UI/storefront issues: **Additional context** Add any other context about the problem here. diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 5ac548df6c..95788b4101 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -1,3 +1,9 @@ +# Historical Benchmarks + +These measurements compare the versions named below. They have not been rerun +for the current release and do not include a reproducible hardware or workload +configuration. Treat them as historical observations, not current performance guarantees. + Vendure v3.1.1: * node_modules size: 262M @@ -97,4 +103,4 @@ Minimal v2 without optional and without dev (production setup): Minimal v3 without optional and without dev (production setup): - 245 Packages in node_modules -- 76M \ No newline at end of file +- 76M diff --git a/CLAUDE.md b/CLAUDE.md index 2abbdcb06b..f1ad589f8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,23 +6,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Development ```bash -npm install # Install all dependencies (uses npm workspaces) +npm ci # Install locked dependencies (uses npm workspaces) +npm run build:packages # Build package imports before running examples or tests npm run dev # Start development with hot-reload (runs kitchensink example + admin-ui + watches packages) -npm run build # Clean all build artifacts and rebuild packages (excludes examples) +npm run build # Clean/rebuild TypeScript project references and build the Admin UI npm run dev:watch # Watch mode for TypeScript compilation across all packages ``` ### Testing ```bash -npm run test # Run all tests (unit + integration) +npm run test # Run unit, integration, and Docker healthcheck regression tests npm run test:run:unit # Run unit tests only (uses node --test in packages/) -npm run test:run:integration # Run integration tests (uses kitchensink example + tests/) +npm run test:run:integration # Run tests/ with its own Fastify platform and MongoDB Memory Server +npm run test:run:docker # Run healthcheck regression tests without Docker node --no-warnings --env-file .env.tests --env-file-if-exists=.env --test-isolation=none --test-force-exit --test-global-setup=tests/helpers.js --test --test-concurrency=1 path/to/test.ts # Run a single integration test file (run from monorepo root directory) node --test path/to/test.ts # Run a single unit test file ``` ### Package-Level Commands -Individual packages support these scripts: +Most packages support these scripts; check the package's `package.json`: ```bash cd packages/[package-name] npm run build # Build specific package @@ -34,7 +36,7 @@ npm run test:watch # Run tests in watch mode ### Code Quality ```bash npm run lint # Lint and fix code (ESLint + Prettier) -npm run pretest # Run ESLint without fixing +npm run lint:check # Run ESLint without fixing ``` ## Architecture Overview @@ -80,15 +82,14 @@ Example modules: core-orders, core-products, core-users, core-payment, core-deli ### Architectural Constraints **IMPORTANT**: Respect layer boundaries when working with packages: -- **DO NOT import `@unchainedshop/mongodb` outside of core-* and infrastructure packages** -- The API layer (`@unchainedshop/api`) should only use types from core packages, never direct MongoDB imports -- Database queries and MongoDB-specific logic belong exclusively in core-* modules -- Higher-level packages (api, platform) should use the module APIs exposed by core packages +- Keep domain database queries in core-* modules and use their module APIs from higher layers. +- Infrastructure concerns also use MongoDB directly: API session storage, platform migrations, and plugin-owned collections are existing examples. +- Prefer type-only imports when a higher layer only needs database types. ### TypeScript Configuration - Uses TypeScript project references (tsconfig.json) for incremental builds - All packages build to `lib/` directory with declaration files -- Run `tsc --build` from root to build all packages respecting dependencies +- Run `tsc --build` from root to build the packages and examples listed in `tsconfig.json`, respecting dependencies - Individual packages have isolated TypeScript configurations ### API Structure @@ -107,8 +108,7 @@ The API package supports multiple server frameworks: ### Environment Configuration - Use `.env` files for local configuration -- Default values in `.env.defaults` -- Integration tests use `.env.tests` with `.env` as fallback -- Node.js 22+ required (see .nvmrc) +- Example-specific defaults are in `examples/*/.env.defaults`; there is no root `.env.defaults` +- Integration tests load `.env.tests`, then optional root `.env` overrides; existing shell variables take precedence +- Use Node.js 26.8.2 from `.nvmrc` for development and tests; all package engines require `>=26.8.2` - MongoDB required (or use MongoDB Memory Server for testing) - No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54241462d9..36c58ec7d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,56 @@ # Unchained OSS Contributions -To get started, sign the Contributor License Agreement with your first PR. +Sign the Contributor License Agreement with your first PR. -1. Branch/Fork -2. Add your changes, add unit and api tests for your new features -3. Run all tests locally (npm run test) -4. Submit a PR +## Local setup + +Fork or clone the repository and create a branch from the release branch you are +targeting (`v4.8.x` for the current v4 release). + +```bash +nvm install +nvm use +npm ci +npm run build:packages +npm run dev +``` + +Use the Node.js version in `.nvmrc` for the development and test commands. The +development command starts the kitchensink example, the Admin UI, and the package +compiler in watch mode. The examples use MongoDB Memory Server when `MONGO_URL` +is unset; its first run downloads a MongoDB binary. + +## Checks before submitting + +Add unit and API integration tests for behavior changes, then run: + +```bash +npm run lint:check # Check without changing files +npm run build:packages +npm test # Unit, integration, and Docker healthcheck regression tests +``` + +`npm run lint` also applies fixes. For Admin UI changes, run +`npm run lint:check --workspace admin-ui` and `npm run build --workspace admin-ui`. + +Integration tests start their own platform through `tests/setup.js`. They load +`.env.tests` and optional root `.env` overrides; existing shell variables take +precedence. Run one integration test from the repository root with: + +```bash +node --no-warnings --env-file .env.tests --env-file-if-exists=.env --test-isolation=none --test-force-exit --test-global-setup=tests/helpers.js --test --test-concurrency=1 tests/auth-user.test.js +``` + +For a single unit test, use `node --test path/to/test.ts`. +Scheduler timing benchmarks are opt-in because their fixed millisecond budgets +depend on hardware and machine load. Scheduler correctness tests always run. +To run the timing benchmarks on an idle machine: + +```bash +UNCHAINED_SCHEDULE_BENCHMARK=1 node --test --test-name-pattern='schedule performance benchmarks' packages/core/src/utils/schedule.test.ts +``` + +See [docs/README.md](docs/README.md) for documentation development and validation. + +Submit a PR against the target release branch with a description of the change +and the checks you ran. Report vulnerabilities through [SECURITY.md](SECURITY.md). diff --git a/license b/LICENSE similarity index 100% rename from license rename to LICENSE diff --git a/MIGRATION.md b/MIGRATION.md index 21a34aa8c5..9a5a7254f4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,6 +4,30 @@ --- +## v4.8.x Node.js Baseline: 26.8.2 + +**Breaking runtime requirement:** all repository packages now declare Node.js +`>=26.8.2`. Node.js 22 and 24 are no longer supported by this release. Development, +CI, and container images pin Node.js 26.8.2; npm remains `>=10.0.0`. + +Update deployment runtimes before installing or upgrading, then rebuild with the +committed dependency lockfile: + +```bash +nvm install +nvm use +npm ci +npm run build:packages +npm run lint:check +npm test +``` + +The optional `@parse/node-apn` dependency currently declares support for Node.js +20, 22, and 24 only. Its Apple Wallet update-notification integration is therefore +not declared compatible with the Node.js 26 baseline. Verify that integration +before relying on pass update notifications; the dependency's engine metadata is +not overridden. + ## v4.8.x MCP: SDK v2, stateless `/mcp` The MCP integration migrated from the monolithic MCP TypeScript SDK v1 to the split v2 SDK. The optional peer dependency was **renamed**: `@modelcontextprotocol/sdk` is no longer supported. @@ -95,7 +119,7 @@ If you wire the cryptopay plugin directly, `configureCryptopayModule` is now `as + const { cryptopay } = await cryptopayPlugin.cryptopay.configure({ db }); ``` -And `CryptopayTransactionsCollection(db)` is now `async` as well — it builds indexes on startup. Users loading the plugin via the standard `connectDefaultPluginsTo*` helpers don't need to change anything. +And `CryptopayTransactionsCollection(db)` is now `async` as well — it builds indexes on startup. Users loading the plugin through the standard module presets passed to `startPlatform` don't need to change anything. ### Breaking: WebAuthn credential requests now carry `created` diff --git a/README.md b/README.md index 87a7ee72cc..f8ba7e5c95 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Unchained Engine is a modular, API-first e-commerce platform built as a monorepo ### Prerequisites -- Node.js >=22 (see [.nvmrc](.nvmrc)) +- Use the Node.js version in [.nvmrc](.nvmrc) for repository development (pinned to Node.js 26.8.2); Node.js >=26.8.2 and npm >=10 are required - MongoDB 4.4+ (or use MongoDB Memory Server for development) ### Create a New Project @@ -30,7 +30,9 @@ Then navigate to http://localhost:4000/ to view the welcome screen. Login with: ### Run Local AI for Copilot -A minimum of 24GB VRAM is needed for this. +Memory requirements depend on the model quantization and context size. Check the +[model's available GGUF files](https://huggingface.co/ggml-org/gpt-oss-20b-GGUF) +and adjust the context size to fit your hardware. ```bash llama-server -hf ggml-org/gpt-oss-20b-GGUF --ctx-size 0 --jinja -ub 2048 -b 2048 @@ -92,8 +94,8 @@ Foundational utilities used across all layers: | Package | Description | |---------|-------------| -| [@unchainedshop/mongodb](packages/mongodb/README.md) | MongoDB database abstraction with utilities and DocumentDB compatibility | -| [@unchainedshop/events](packages/events/README.md) | Event emitter abstraction with pluggable adapters (Redis, Kafka, etc.) | +| [@unchainedshop/mongodb](packages/mongodb/README.md) | MongoDB database abstraction, index management, and query utilities | +| [@unchainedshop/events](packages/events/README.md) | Event emitter abstraction with pluggable adapters and audit logging | | [@unchainedshop/logger](packages/logger/README.md) | High-performance logging with JSON/human-readable formats | | [@unchainedshop/utils](packages/utils/README.md) | Common utilities, locale helpers, and Director/Adapter base classes | | [@unchainedshop/roles](packages/roles/README.md) | Role-based access control (RBAC) system | @@ -172,10 +174,14 @@ The [@unchainedshop/plugins](packages/plugins/README.md) package includes: ### Commands ```bash -npm install # Install all dependencies +nvm install # Install the Node.js version from .nvmrc +nvm use +npm ci # Install locked workspace dependencies +npm run build:packages # Build package imports before starting examples or tests npm run dev # Start development with hot-reload -npm run build # Build all packages +npm run build # Build packages and the Admin UI npm test # Run all tests +npm run lint:check # Check lint without changing files npm run lint # Lint and fix code ``` @@ -187,6 +193,11 @@ npm run test:run:integration # Run integration tests node --test path/to/test.ts # Run a single test file ``` +Integration tests start their own Fastify server and MongoDB Memory Server through +`tests/setup.js`. See [CONTRIBUTING.md](CONTRIBUTING.md) for running an individual +integration test. The documentation site has a separate installation and build: +see [docs/README.md](docs/README.md). + ### Project Structure ``` @@ -212,10 +223,12 @@ See [MIGRATION.md](MIGRATION.md) for upgrade instructions between major versions ## Claude Code Integration -Unchained provides a Claude Code skill to help with upgrades: +Unchained provides a repository skill to help with upgrades. Install it in your project: ```bash -claude "skill install https://docs.unchained.shop/skills/upgrade-unchained/SKILL.md" +mkdir -p .claude/skills/upgrade-unchained +curl -fsSL https://docs.unchained.shop/skills/upgrade-unchained/SKILL.md \ + -o .claude/skills/upgrade-unchained/SKILL.md ``` This skill guides Claude through fetching the correct migration guide, changelog, and examples for your target version. @@ -228,36 +241,29 @@ Unchained Engine is designed for deployment in security-sensitive environments i | Standard | Status | Notes | |----------|--------|-------| -| **PCI DSS SAQ-A** | Eligible | Payment tokenization, no card data storage | -| **ISO 27001** | Aligned | Comprehensive security controls | -| **FIPS 140-3** | Supported | Deploy with FIPS-enabled Node.js | -| **FINMA/NIS2** | Aligned | Banking and EU requirements | +| **Payment security** | Tokenization | Payment providers handle card data; eligibility depends on the deployment | +| **Access control** | Implemented | Role and ownership checks in the API | +| **Audit logging** | Available | Optional file-based logs with integrity verification | +| **FIPS deployments** | Runtime-dependent | Review the runtime, legacy password hashes, and enabled plugins | ### Cryptographic Standards - **Password Hashing**: PBKDF2-SHA512 with 300,000 iterations - **Token Security**: SHA-256 hashing, cryptographically random generation -- **Session Encryption**: AES-256-GCM (optional) +- **Sessions**: Signed cookies with server-side MongoDB storage - **Payment Signatures**: HMAC-SHA-256/512 ### FIPS 140-3 Mode -For US federal government and regulated environments, run Unchained with FIPS-validated cryptography: - -```dockerfile -# Use Chainguard FIPS image -FROM cgr.dev/chainguard/node-fips:latest -WORKDIR /app -COPY . . -CMD ["node", "index.js"] -``` - -Or enable FIPS mode manually: +With a Node.js runtime configured with an OpenSSL FIPS provider, enable FIPS mode: ```bash node --enable-fips your-app.js ``` +See [SECURITY.md](SECURITY.md#fips-140-3-compatibility) for runtime setup and +limitations, including legacy bcrypt verification and cryptocurrency plugins. + ### API Hardening (Denial-of-Service Protection) The GraphQL API does **not** impose query-complexity, depth, alias-count, or rate limits by default. Unchained is a headless engine embedded in your own server process, so where and how these edge protections are enforced is a deployment decision that belongs to the integrator — appropriate thresholds depend on your schema extensions, traffic profile, and infrastructure (CDN, WAF, API gateway, reverse proxy). @@ -306,4 +312,4 @@ See our [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). ## License -EUPL-1.2 +[EUPL-1.2](LICENSE) diff --git a/SECURITY.md b/SECURITY.md index 7c686fe5a8..d1621f9ef0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,10 +19,10 @@ This section describes how Unchained Engine can support compliance efforts. **No |----------|---------------|-----------------| | **PCI DSS SAQ-A** | Compatible | No card data storage; uses tokenization. Eligibility depends on your full deployment. | | **ISO 27001** | Technical Controls | Implements access control, audit logging, and cryptographic standards. ISMS policies and processes are your responsibility. | -| **FIPS 140-3** | Algorithm Compatible | Uses FIPS-approved algorithms (PBKDF2, SHA-256/512, AES-256-GCM). Requires FIPS-validated runtime for full compliance. | +| **FIPS 140-3** | Deployment-dependent | Review the runtime, legacy bcrypt verification, and enabled plugins; see the limitations below. | | **SOC 2** | Audit Support | Provides tamper-evident audit logs for evidence collection. SOC 2 audits evaluate your organization's controls, not software. | | **FINMA 2023/1** | Technical Controls | Audit logging, access control, and cryptography support ICT risk management requirements. The circular is principle-based; organizational controls are your responsibility. | -| **GDPR** | Technical Measures | Audit logging supports Article 30 requirements. Data protection policies are your responsibility. | +| **Data protection** | Technical Measures | Access controls and audit logging are available; retention and data protection policies are deployment responsibilities. | ## Cryptographic Standards @@ -33,33 +33,39 @@ Unchained Engine uses modern, standards-compliant cryptography throughout: - **Algorithm**: PBKDF2 with SHA-512 - **Iterations**: 300,000 (exceeds OWASP recommendation of 210,000) - **Salt**: 16 bytes, cryptographically random -- **Key Length**: 256 bytes +- **Key Length**: 256 bits (32 bytes) - **Implementation**: Web Crypto API (`crypto.subtle`) +- **Legacy verification**: Existing bcrypt hashes are still supported; newly set passwords use PBKDF2 ```typescript // packages/core-users/src/module/pbkdf2.ts const PBKDF2_ITERATIONS = 300000; -const PBKDF2_KEY_LENGTH = 256; +const PBKDF2_KEY_LENGTH = 256; // Bits, as required by crypto.subtle.deriveBits() const PBKDF2_SALT_LENGTH = 16; // Uses SHA-512 via crypto.subtle.deriveBits() ``` ### Token Security -- **Token Generation**: `crypto.randomUUID()` (CSPRNG-based, 128 bits of entropy) +- **Token Generation**: `crypto.randomUUID()` (UUIDv4, with 122 random bits) - **Token Storage**: SHA-256 hashed before database storage -- **Token Expiration**: Time-limited (1 hour for verification tokens) -- **Single Use**: Tokens are invalidated after use +- **Email verification/password reset**: Valid for 1 hour by default and invalidated after use; configurable through `earliestValidTokenDate` +- **API access tokens**: Reusable, with no built-in expiration; creating a new token replaces the previous one for that user **Why SHA-256 for Tokens (not PBKDF2)?** -Per [OWASP guidance](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html), slow hashing algorithms (bcrypt, PBKDF2, Argon2) are designed for low-entropy user passwords. API access tokens generated with CSPRNG have high entropy (128+ bits), making brute-force computationally infeasible regardless of hash speed. Using SHA-256 for high-entropy tokens is both secure and performant for stateless API authentication where every request must be verified. +Password hashes use PBKDF2 to slow guessing of user-chosen passwords. API access +tokens are generated with a cryptographic random generator and stored as SHA-256 +hashes for lookup on each authenticated request. Node's +[`crypto.randomUUID()`](https://nodejs.org/api/crypto.html#cryptorandomuuidoptions) +generates UUIDv4 tokens. ```typescript // packages/core-users/src/module/configureUsersModule.ts // Preferred: Server generates high-entropy token const result = await modules.users.createAccessToken('admin'); -console.log(result.token); // e.g., "550e8400-e29b-41d4-a716-446655440000" +if (!result) throw new Error('Admin user not found'); +const token = result.token; // Deliver once to the intended caller; avoid logging it ``` ### Random Number Generation @@ -68,12 +74,13 @@ console.log(result.token); // e.g., "550e8400-e29b-41d4-a716-446655440000" - **Nonces**: `crypto.randomUUID()` for WebAuthn/Web3 challenges - **No weak RNG**: `Math.random()` is never used for security-sensitive operations -### Session Encryption (Optional) +### Session Storage -- **Algorithm**: AES-256-GCM (authenticated encryption) -- **Key Size**: 32 bytes -- **IV Size**: 16 bytes -- **Implementation**: kruptein library +The Express and Fastify adapters store session data in MongoDB. The built-in +store does not initialize a session encryption provider. `UNCHAINED_TOKEN_SECRET` +signs session cookies; it does not encrypt session records. Configure encryption +at the database/storage layer or supply an application-specific session store +when encryption at rest is required. ### Payment Signature Verification @@ -86,51 +93,48 @@ Full support for passwordless authentication via the WebAuthn standard, enabling ## FIPS 140-3 Compatibility -Unchained Engine uses FIPS 140-3 approved algorithms and can run on FIPS-validated runtimes. **Note**: The software itself is not FIPS-validated; validation requires certification by a NIST-accredited lab. For true FIPS compliance, deploy on a FIPS-validated runtime. +Unchained Engine is not FIPS-validated. Its new password hashes and token hashes +use Node.js cryptographic APIs, but legacy bcrypt verification and cryptocurrency +plugins also use JavaScript cryptography outside the OpenSSL provider. Assess the +enabled authentication and plugin paths for your deployment. ### FIPS-Approved Algorithms Used -All cryptographic operations in Unchained use FIPS 140-3 approved algorithms: +The core Node.js cryptographic operations include: | Operation | Algorithm | FIPS Status | |-----------|-----------|-------------| | Password Hashing | PBKDF2-SHA-512 | Approved | | Token Hashing | SHA-256 | Approved | -| Session Encryption | AES-256-GCM | Approved | | Payment Signatures | HMAC-SHA-256/512 | Approved | | Random Generation | CSPRNG | Approved | ### Running in FIPS Mode -#### Option 1: Chainguard FIPS Image (Recommended) +#### Option 1: A FIPS Runtime Image -Use the [Chainguard node-fips](https://images.chainguard.dev/directory/image/node-fips/overview) container image which includes a FIPS-validated OpenSSL module: - -```dockerfile -FROM cgr.dev/chainguard/node-fips:latest - -WORKDIR /app -COPY package*.json ./ -RUN npm ci --only=production -COPY . . - -# FIPS mode is enabled by default in this image -CMD ["node", "index.js"] -``` +Follow the runtime provider's build and deployment instructions, such as the +[Chainguard node-fips guide](https://images.chainguard.dev/directory/image/node-fips/overview). +Use the image and registry path available to your organization and verify its +entrypoint before setting the application command. #### Option 2: Node.js with OpenSSL FIPS Provider -Build or use a Node.js binary compiled with OpenSSL 3.x FIPS provider: +Use a Node.js binary with a correctly installed OpenSSL FIPS provider. Follow the +[Node.js FIPS configuration guide](https://nodejs.org/api/crypto.html#fips-mode), +including the provider installation file and module path: ```bash -# Enable FIPS mode via environment variable +# Point Node at the installed provider configuration, then enable FIPS mode export OPENSSL_CONF=/path/to/openssl-fips.cnf +export OPENSSL_MODULES=/path/to/openssl-modules node --enable-fips your-app.js ``` Example `openssl-fips.cnf`: ```ini -openssl_conf = openssl_init +nodejs_conf = openssl_init +.include /path/to/fipsmodule.cnf [openssl_init] providers = provider_sect @@ -140,9 +144,6 @@ alg_section = algorithm_sect fips = fips_sect base = base_sect -[fips_sect] -activate = 1 - [base_sect] activate = 1 @@ -159,25 +160,13 @@ import crypto from 'crypto'; console.log('FIPS mode:', crypto.getFips() === 1 ? 'enabled' : 'disabled'); ``` -### FIPS-Approved Algorithms - -All cryptographic operations in Unchained use FIPS 140-3 approved algorithms: - -| Operation | Algorithm | FIPS Status | -|-----------|-----------|-------------| -| Password Hashing | PBKDF2-SHA-512 | Approved | -| Token Hashing | SHA-256 | Approved | -| Session Encryption | AES-256-GCM | Approved | -| Payment Signatures | HMAC-SHA-256/512 | Approved | -| Random Generation | CSPRNG | Approved | - ### FIPS Considerations -1. **Pure PBKDF2**: Unchained uses only PBKDF2-SHA512 for password hashing, ensuring full FIPS 140-3 compatibility for all password operations. +1. **Legacy passwords**: Newly set passwords use PBKDF2-SHA512, but `verifyPassword` still accepts legacy bcrypt hashes. Enabling Node.js FIPS mode does not change that JavaScript verification path. 2. **Third-Party Libraries**: Verify that any additional npm packages you add use Node.js crypto APIs or are otherwise FIPS-compliant. -3. **Cryptopay Plugin**: Uses `@noble/curves` and `@noble/hashes` for cryptocurrency operations. These implement FIPS-approved primitives but are not FIPS-certified modules. +3. **Cryptopay Plugin**: Uses `@noble/curves` and `@noble/hashes` for cryptocurrency operations outside Node.js's OpenSSL provider. FIPS mode does not validate these implementations. ## Access Control @@ -253,23 +242,23 @@ type PaymentCredentials = { ```typescript // Secure defaults -{ +const cookieOptions = { httpOnly: true, // Prevent XSS access secure: true, // HTTPS only (unless explicitly disabled) sameSite: 'none', // Configurable - maxAge: 604800, // 7 days -} + maxAge: 604800000, // 7 days in milliseconds +}; ``` ### Environment Variables | Variable | Purpose | Default | |----------|---------|---------| -| `UNCHAINED_TOKEN_SECRET` | Session encryption (min 32 chars) | Required | +| `UNCHAINED_TOKEN_SECRET` | Session cookie signing secret (min 32 chars) | Required | | `UNCHAINED_COOKIE_NAME` | Cookie name | `unchained_token` | | `UNCHAINED_COOKIE_DOMAIN` | Cookie domain restriction | - | | `UNCHAINED_COOKIE_SAMESITE` | SameSite attribute | `none` | -| `UNCHAINED_COOKIE_INSECURE` | Disable secure flag | `false` | +| `UNCHAINED_COOKIE_INSECURE` | Any non-empty value disables the secure flag | Unset (secure cookies) | ## Error Handling @@ -280,13 +269,14 @@ Errors are designed to prevent information leakage: - **Permission errors**: "Not authorized" (no action details) - **Password validation**: "Too insecure" (no requirements revealed) - **User enumeration prevention**: Password reset returns success regardless of user existence -- **Log sanitization**: Error objects are never logged directly; only message and name are captured +- **Error details**: GraphQL errors may include resolver-supplied data in `extensions`; custom resolvers and logging need to avoid exposing sensitive values ## Input Validation ### ReDoS Prevention -All user-supplied strings used in regular expressions are escaped to prevent Regular Expression Denial of Service (ReDoS) attacks: +Query builders use `escapeRegexString` when treating user input as literal text +inside a regular expression: ```typescript import { escapeRegexString } from '@unchainedshop/mongodb'; @@ -295,14 +285,17 @@ import { escapeRegexString } from '@unchainedshop/mongodb'; const regex = new RegExp(escapeRegexString(userInput), 'i'); ``` -The `escapeRegexString` function escapes all special regex characters (`[-/\\^$*+?.()|[\]{}]`) and includes: -- Type validation (throws TypeError for non-strings) -- Length limits (max 255 characters) -- Empty string rejection +The `escapeRegexString` function escapes regex metacharacters and throws +`TypeError` for non-strings. It accepts empty strings and does not limit length. +The separate `insensitiveTrimmedRegexOperator` helper trims the input, escapes it, +rejects empty results and escaped strings longer than 255 characters, and anchors +the resulting case-insensitive expression for exact matching. ### Query String Validation -All query builder functions that accept user input for text search apply proper escaping to prevent injection attacks. +Use the shared query helpers for literal matching and validate inputs in custom +query builders. Escaping regex metacharacters does not impose a query timeout or +limit how many documents a search scans. ### GraphQL Query Protection (Denial-of-Service) @@ -350,7 +343,7 @@ Unchained provides append-only, tamper-evident audit logging based on the **OCSF - **Append-only** - No update or delete operations - **Tamper-evident** - SHA-256 hash chain for integrity verification - **File-based** - No external dependencies (MongoDB-free) -- **HTTP push** - Optional push to OpenTelemetry Collector, Fluentd, or Vector +- **HTTP push** - Optional JSON batches to a collector accepting `{ events: [...] }` - **SIEM-ready** - Direct ingestion into security monitoring tools - **Event integration** - Automatic capture of authentication, orders, and payments - **E-commerce specific** - Checkout, payment, refund, and access denied events @@ -369,19 +362,21 @@ import { // Create audit log instance (file-based) const auditLog = createAuditLog('./audit-logs'); -// Or with HTTP push to collector +// Alternatively, replace the call above with HTTP push configuration: +/* const auditLog = createAuditLog({ directory: './audit-logs', - collectorUrl: 'http://otel-collector:4318/v1/logs', + collectorUrl: 'http://audit-collector:8080/events', batchSize: 10, flushIntervalMs: 5000, }); +*/ // Log authentication event await auditLog.logAuthentication({ activity: OCSF_AUTH_ACTIVITY.LOGON, userId: user._id, - userName: user.email, + userName: user.emails?.[0]?.address, success: true, remoteAddress: req.ip, sessionId: req.sessionID, @@ -409,7 +404,7 @@ await auditLog.logAccountChange({ await auditLog.logAccountChange({ activity: OCSF_ACCOUNT_ACTIVITY.CREATE, userId: newUser._id, - userName: newUser.email, + userName: newUser.emails?.[0]?.address, success: true, }); @@ -468,7 +463,7 @@ import { createAuditLog, configureAuditIntegration } from '@unchainedshop/events const auditLog = createAuditLog('./audit-logs'); // Enable automatic event capture -const cleanup = configureAuditIntegration(auditLog); +configureAuditIntegration(auditLog); // Events automatically captured: // - API_LOGIN_TOKEN_CREATED → Authentication (LOGON) @@ -483,8 +478,7 @@ const cleanup = configureAuditIntegration(auditLog); // - ORDER_PAY → API Activity (PAYMENT) // - And more... -// On shutdown -cleanup(); +// After stopping event producers on shutdown await auditLog.close(); ``` @@ -584,16 +578,9 @@ scrape_configs: user_id: user.uid ``` -**OpenTelemetry Collector (HTTP push):** -```yaml -receivers: - otlphttp: - endpoint: 0.0.0.0:4318 - -exporters: - elasticsearch: - endpoints: ["https://es:9200"] -``` +**HTTP push:** The sender posts `application/json` with an `events` array of OCSF +records. Configure a receiver for that payload. It is not an OTLP log request; +forwarding to an OpenTelemetry OTLP receiver requires a translation step. ### Configuration @@ -613,16 +600,20 @@ const auditLog = createAuditLog({ In addition to persistent audit logs, Unchained emits transient events for real-time processing: - `USER_CREATE`, `USER_UPDATE`, `USER_REMOVE` -- `USER_UPDATE_PASSWORD`, `USER_UPDATE_ROLES` +- `USER_UPDATE_PASSWORD`, `USER_ADD_ROLES`, `USER_UPDATE_ROLE` - `USER_ACCOUNT_ACTION` (reset-password, verify-email, enroll-account) ```typescript import { emit } from '@unchainedshop/events'; -// Transient events (2-day TTL in MongoDB) +// Emit to the configured event adapters await emit('USER_UPDATE_PASSWORD', { user }); ``` +When the events module is configured, it also persists event history in MongoDB. +Its retention is controlled by `EVENTS_TTL_SECONDS` (default: 172800, or 2 days). +This is separate from the file-based audit log. + ## Rate Limiting Rate limiting should be implemented at the **reverse proxy level** (nginx, Cloudflare, AWS ALB, etc.) rather than in the application layer. @@ -633,19 +624,20 @@ Rate limiting should be implemented at the **reverse proxy level** (nginx, Cloud ```nginx # Define rate limit zones -limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; server { - # Rate limit login/auth endpoints + # Rate limit all requests to the GraphQL endpoint location /graphql { - # Stricter limits for mutations (detected via POST) limit_req zone=api burst=50 nodelay; proxy_pass http://unchained:4000; } } ``` +Both queries and mutations can use POST. This example limits the whole endpoint; +per-operation login limits require a GraphQL-aware gateway or application plugin. + **Cloudflare:** - Use Rate Limiting Rules for `/graphql` endpoint @@ -674,9 +666,9 @@ server { - [ ] Set `UNCHAINED_TOKEN_SECRET` to a strong, unique value (32+ chars) - [ ] Enable HTTPS/TLS termination - [ ] Configure MongoDB with authentication and TLS -- [ ] Enable session encryption if storing sensitive data +- [ ] Configure encryption at rest for MongoDB/storage if storing sensitive data - [ ] Configure rate limiting at reverse proxy (nginx, Cloudflare, ALB) -- [ ] Enable audit logging via `modules.auditLog` (built-in, persisted indefinitely) +- [ ] Initialize `createAuditLog` and `configureAuditIntegration` from `@unchainedshop/events`; configure file retention and collection - [ ] Configure monitoring and alerting - [ ] Set up log aggregation for audit logs - [ ] Regular security updates for dependencies @@ -723,7 +715,7 @@ npm audit fix ### Trusted Dependencies -The project explicitly trusts only necessary native modules: +The root manifest includes package-manager-specific trust metadata: ```json { @@ -731,10 +723,14 @@ The project explicitly trusts only necessary native modules: } ``` +Do not treat `trustedDependencies` as an npm lifecycle-script allowlist. Check the +package manager's script policy; the manifest also contains `allowScripts` entries +for Cypress and MongoDB Memory Server. + ## Further Reading - [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - [NIST FIPS 140-3](https://csrc.nist.gov/pubs/fips/140-3/final) -- [PCI DSS SAQ-A](https://www.pcisecuritystandards.org/documents/SAQ_A_v3.pdf) +- [PCI SSC Document Library (current standards and SAQs)](https://www.pcisecuritystandards.org/document_library/) - [ISO 27001](https://www.iso.org/standard/27001) - [Chainguard FIPS Images](https://images.chainguard.dev/directory/image/node-fips/overview) diff --git a/admin-ui/CLAUDE.md b/admin-ui/CLAUDE.md index dbd6fb1f15..bdde5c4172 100644 --- a/admin-ui/CLAUDE.md +++ b/admin-ui/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands ```bash -# Development server with debugging +# Development server (run commands from admin-ui/) npm run dev # Production build (includes permission generation) @@ -14,12 +14,12 @@ npm run build # Generate GraphQL types from schema npm run codegen -# Linting and formatting -npm run lint +# Nonmutating lint check and automatic ESLint/Prettier fixes +npm run lint:check npm run format # Testing -npm run test:e2e # Open Cypress e2e tests +npm run test:e2e # Run Cypress e2e tests npm run test:component # Open Cypress component tests npm run test:e2e-record # Run e2e tests in CI with recording @@ -31,7 +31,7 @@ npm run compile-translation # Compile translations ## Architecture Overview ### Core Technologies -- **Next.js 15** with static export mode (`output: 'export'`) +- **Next.js 16** with static export mode (`output: 'export'`) - **React 19** with TypeScript - **Apollo Client** for GraphQL data management - **Tailwind CSS 4** for styling with custom `@apply` classes in `globals.css` @@ -39,15 +39,15 @@ npm run compile-translation # Compile translations - **Formik** for form management ### GraphQL Integration -- **Schema Endpoint**: `http://localhost:4010/graphql` (configurable via `NEXT_PUBLIC_GRAPHQL_ENDPOINT`) -- **Code Generation**: Auto-generates TypeScript types from GraphQL schema via `codegen.ts` +- **Runtime Endpoint**: `NEXT_PUBLIC_GRAPHQL_ENDPOINT` or same-origin `/graphql`; `.env.development` points to `http://localhost:4010/graphql` +- **Code Generation**: `npm run codegen` generates TypeScript types via `codegen.ts`, whose schema URL is set to `http://localhost:4010/graphql` independently of the runtime environment variable - **Type Prefix**: All generated types prefixed with `I` (e.g., `IUser`, `IProduct`) - **Apollo Cache**: Custom cache policies in `src/modules/apollo/utils/typepolicies.ts` ### Permission System - **Build-time Generation**: `generate-permissions.js` creates `public/admin-ui-permissions.js` -- **Dynamic Loading**: Permissions loaded via `loadPermissionConfig.js` (external dependency) -- **Role-based Access**: `useAuth` hook provides `hasRole()` function throughout components +- **Configuration Loading**: The bundled `loadPermissionConfig.js` loads `UI_PERMISSION_CONFIG` when supplied, falling back to `default-permissions.config.js` +- **Role-based Access**: `useAuth` provides `hasRole()` for interface visibility; the engine API enforces authorization ### Internationalization Architecture - **Admin UI Locales**: English (`en`) and German (`de`) in `src/i18n/` @@ -79,16 +79,14 @@ const { products } = useProducts({ limit, offset }); ```typescript const form = useForm({ submit: onSubmit, - initialValues: {...}, + initialValues: { title: "" }, successMessage: "Saved" }); ``` -**Styling System**: Semantic CSS classes in `globals.css` using Tailwind `@apply` -```css -.btn-primary { - @apply btn-base bg-slate-800 text-white hover:bg-slate-950; -} +**Styling System**: Tailwind utility classes in components, shared styles in `src/styles/globals.css`, and reusable variants in `src/modules/common/components/Button.tsx`. +```tsx + ``` **Component Composition**: Page → Detail → Form pattern @@ -98,10 +96,9 @@ const form = useForm({ ### Chat/Copilot Integration - **AI SDK React**: `@ai-sdk/react` for streaming chat -- **Current State**: Configured for external API at `localhost:4010/chat` +- **Endpoint**: `NEXT_PUBLIC_CHAT_URL` or same-origin `/chat`; `.env.development` points to `http://localhost:4010/chat` - **Storage**: Local chat history in `localStorage` -- **Components**: Modular chat system in `src/components/chat/` -- **Auto-Introduction**: Automatically sends "Introduce yourself and your tools to the user" when chat is empty +- **Components**: Modular chat system in `src/modules/copilot/` - **Welcome State**: Shows example prompts and capabilities when no messages exist ### Build Configuration @@ -117,7 +114,7 @@ const form = useForm({ ## Important Notes - **Static Export Limitation**: `output: 'export'` in `next.config.js` disables API routes -- **Permission Dependency**: Build requires external `loadPermissionConfig.js` file +- **Permission Configuration**: The permission loader and defaults are bundled; an external override is optional - **GraphQL Schema**: Development assumes Unchained Commerce backend on `localhost:4010` - **Locale Architecture**: Two separate locale systems for Admin UI vs content translation - **TypeScript Configuration**: Relaxed mode (`strict: false`) for compatibility @@ -127,6 +124,11 @@ const form = useForm({ ```bash NEXT_PUBLIC_LOGO=url_to_logo +NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:4010/graphql +NEXT_PUBLIC_CHAT_URL=http://localhost:4010/chat +NEXT_PUBLIC_TEMP_FILE_UPLOAD_URL=http://localhost:4010/temp-upload +# Optional build-time permission override: +# UI_PERMISSION_CONFIG=/absolute/path/to/permissions.cjs ``` diff --git a/admin-ui/PRD.md b/admin-ui/PRD.md index 297623cf7c..199c9c4acc 100644 --- a/admin-ui/PRD.md +++ b/admin-ui/PRD.md @@ -3,7 +3,9 @@ **Version**: 2.0 **Date**: July 2025 -**Status**: Active Development +**Status**: Historical requirements snapshot (July 2025) + +This document records requirements and targets from July 2025, including planned features. It is not a description of the current implementation. See [README.md](README.md) and [CLAUDE.md](CLAUDE.md) for current setup and architecture. --- diff --git a/admin-ui/README.md b/admin-ui/README.md index cfeee58c57..0e3a167f2c 100644 --- a/admin-ui/README.md +++ b/admin-ui/README.md @@ -3,7 +3,7 @@ # Unchained Admin UI [![npm version](https://img.shields.io/npm/v/@unchainedshop/admin-ui.svg)](https://www.npmjs.com/package/@unchainedshop/admin-ui) -[![Next.js](https://img.shields.io/badge/Next.js-15-black)](https://nextjs.org/) +[![Next.js](https://img.shields.io/badge/Next.js-16-black)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) **The open-source admin dashboard for [Unchained Commerce](https://unchained.shop) — manage your headless e-commerce with AI superpowers** @@ -28,10 +28,10 @@ | 📦 **Product Management** | Simple, Bundle, Configurable, Subscription Plans & NFT-tokenized products | | 🛒 **Order & Fulfillment** | Complete order lifecycle with configurable workflows | | 💼 **B2B Quotations** | Professional quotation management with approval workflows | -| 📊 **Inventory Control** | Multi-warehouse tracking with low-stock alerts | +| 📊 **Inventory Control** | Warehouse provider management and product warehousing configuration | | 💳 **Payment & Shipping** | Integrate any payment gateway or delivery provider | | 🌍 **Multi-language & Currency** | Full i18n support with country-specific locales | -| 🔐 **Role-Based Access** | Granular permissions with build-time security | +| 🔐 **Role-Based Access** | Configurable UI permissions with API authorization | | 🎨 **Customizable Branding** | White-label ready with custom logos | | 📱 **Responsive Design** | Works on desktop, tablet, and mobile | @@ -43,7 +43,7 @@ | | | | | | |:---:|:---:|:---:|:---:|:---:| -| **Next.js 15** | **React 19** | **Apollo GraphQL** | **Tailwind CSS 4** | **TypeScript** | +| **Next.js 16** | **React 19** | **Apollo GraphQL** | **Tailwind CSS 4** | **TypeScript** | @@ -55,24 +55,27 @@ Plus: Formik • React Intl • Headless UI • Recharts • Cypress • AI SDK ### Prerequisites -- Node.js 22+ +- Node.js 26.8.2 or newer (26.8.2 is pinned) for repository development (see [`.nvmrc`](../.nvmrc)) - [Unchained Engine](https://github.com/unchainedshop/unchained) running on `localhost:4010` ### Installation ```bash # Clone the repository -git clone git@github.com:unchainedshop/unchained.git -cd admin-ui +git clone https://github.com/unchainedshop/unchained.git +cd unchained -# Install dependencies +# Install workspace dependencies and build the packages and Admin UI npm install +npm run build -# Start development server +# Start the kitchensink backend, Admin UI, and package watchers npm run dev ``` -Open [http://localhost:3000](http://localhost:3000) in your browser. +Open [http://localhost:3000](http://localhost:3000) in your browser. To run only the Admin UI against an existing backend, use `npm run dev --workspace @unchainedshop/admin-ui`. + +The commands below run from `admin-ui/`. --- @@ -82,13 +85,18 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. | Variable | Default | Description | |----------|---------|-------------| -| `NEXT_PUBLIC_GRAPHQL_ENDPOINT` | `http://localhost:4010/graphql` | Unchained Engine GraphQL endpoint | -| `NEXT_PUBLIC_LOGO` | — | URL to your custom logo | +| `NEXT_PUBLIC_GRAPHQL_ENDPOINT` | `/graphql` | GraphQL endpoint; `.env.development` sets `http://localhost:4010/graphql` | +| `NEXT_PUBLIC_CHAT_URL` | `/chat` | Copilot endpoint; `.env.development` sets `http://localhost:4010/chat` | +| `NEXT_PUBLIC_TEMP_FILE_UPLOAD_URL` | `/temp-upload` | Copilot upload endpoint; `.env.development` sets `http://localhost:4010/temp-upload` | +| `UI_PERMISSION_CONFIG` | bundled defaults | Absolute path to a CommonJS permission configuration, read during the build | +| `NEXT_PUBLIC_LOGO` | `/logo-light.svg` | URL to your custom logo | -Create a `.env.local` file for local development: +For a custom backend, set overrides in `admin-ui/.env.local`: ```bash NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://your-engine.example.com/graphql +NEXT_PUBLIC_CHAT_URL=https://your-engine.example.com/chat +NEXT_PUBLIC_TEMP_FILE_UPLOAD_URL=https://your-engine.example.com/temp-upload NEXT_PUBLIC_LOGO=https://your-cdn.com/logo.svg ``` @@ -96,39 +104,57 @@ NEXT_PUBLIC_LOGO=https://your-cdn.com/logo.svg ## 🐳 Deployment -### Static Export (Recommended) +### Static Export ```bash npm run build -# Output in ./out/ - deploy to any CDN (Vercel, Netlify, S3, etc.) +# Output in ./out/ - deploy to a static host +npm run serve # Preview the export locally +``` + + +Public environment variables are embedded during the build. The default export expects the API on the same origin; configure the endpoint overrides before building for a separate backend. UI permissions control the interface; authorization is enforced by the engine API. + +### Docker + +Build from the repository root and serve the static export on port `3000`: + +```bash +docker build -f admin-ui/Dockerfile -t unchained-admin . +docker run --rm -p 3000:3000 unchained-admin ``` +Pass `NEXT_PUBLIC_GRAPHQL_ENDPOINT`, `NEXT_PUBLIC_CHAT_URL`, `NEXT_PUBLIC_TEMP_FILE_UPLOAD_URL`, and `NEXT_PUBLIC_LOGO` as `--build-arg` values for a custom backend or branding. See the [Docker deployment guide](../docs/docs/deployment/docker.md). ### Express / Fastify Integration ```typescript // Express -import { expressRouter } from '@unchainedshop/admin-ui/express'; -app.use('/admin', expressRouter); +import { adminUIRouter } from '@unchainedshop/api/express'; +app.use('/', adminUIRouter()); +``` +```typescript // Fastify -import { fastifyRouter } from '@unchainedshop/admin-ui/fastify'; -fastify.register(fastifyRouter, { prefix: '/admin' }); +import { adminUIRouter } from '@unchainedshop/api/fastify'; +fastify.register(adminUIRouter, { prefix: '/' }); ``` +The adapters serve the installed `@unchainedshop/admin-ui` export. Fastify also requires `@fastify/static`. For a complete platform connection, pass `adminUI: true` to the API adapter’s `connect()` function, as shown in the kitchensink example. + --- ## 📝 Development | Command | Description | |---------|-------------| -| `npm run dev` | Start dev server with debugging | +| `npm run dev` | Start Next.js dev server | | `npm run build` | Production build | -| `npm run lint` | Run ESLint | -| `npm run format` | Format with Prettier | +| `npm run lint:check` | Run ESLint without changing files | +| `npm run format` | Apply ESLint and Prettier fixes | | `npm run codegen` | Generate GraphQL types | | `npm run test:e2e` | Run Cypress E2E tests | -| `npm run test:component` | Run Cypress component tests | +| `npm run test:component` | Open Cypress component tests | | `npm run extract-translation` | Extract i18n strings | | `npm run compile-translation` | Compile translations | @@ -147,7 +173,7 @@ src/modules/ ├── token/ # NFT tokenization ├── enrollment/ # Subscriptions ├── assortment/ # Categories -├── user/ # Customer management +├── accounts/ # Customer management ├── payment-providers/ # Payment gateways ├── delivery-provider/ # Shipping ├── country/ # Multi-country diff --git a/admin-ui/cypress/support/e2e.ts b/admin-ui/cypress/support/e2e.ts index 598ab5f0d7..14ed0a07f3 100644 --- a/admin-ui/cypress/support/e2e.ts +++ b/admin-ui/cypress/support/e2e.ts @@ -1,6 +1,6 @@ // *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. +// This support file is currently disabled by e2e.supportFile: false +// in cypress.config.ts. Enable it there to load it before e2e tests. // // This is a great place to put global configuration and // behavior that modifies Cypress. diff --git a/admin-ui/loadPermissionConfig.js b/admin-ui/loadPermissionConfig.js index c7490fe5ba..5b4598d8a7 100644 --- a/admin-ui/loadPermissionConfig.js +++ b/admin-ui/loadPermissionConfig.js @@ -1,6 +1,6 @@ /* eslint-disable global-require */ /* eslint-disable import/no-dynamic-require */ -// permissionConfig.js +// Load the optional permission override or the bundled defaults. const defaultConfig = require('./default-permissions.config'); function loadPermissionConfig() { diff --git a/admin-ui/next.config.js b/admin-ui/next.config.js index 979cd1f3c0..97215c0791 100644 --- a/admin-ui/next.config.js +++ b/admin-ui/next.config.js @@ -11,10 +11,7 @@ module.exports = { basePath: '', trailingSlash: true, assetPrefix: '', - // admin-ui is a nested git repo whose deps are hoisted to the monorepo root - // node_modules. Pin the Turbopack workspace root to the monorepo root so it - // can resolve `next` (and other hoisted packages) instead of stopping at the - // nested .git boundary. + // Resolve Next.js and other hoisted workspace dependencies from the monorepo root. turbopack: { root: path.resolve(__dirname, '..'), }, diff --git a/admin-ui/src/modules/forms/hooks/useField.ts b/admin-ui/src/modules/forms/hooks/useField.ts index 015a5061f4..77bfd0945b 100644 --- a/admin-ui/src/modules/forms/hooks/useField.ts +++ b/admin-ui/src/modules/forms/hooks/useField.ts @@ -74,7 +74,7 @@ const useField = (props: FieldHookProps): ComputedProps => { (acc: string, { isValid, intlMessageDescriptor, intlMessageValues }) => { if (acc) return acc; - // Do not run validators if field is not required + // Run custom validators for every field; validateRequired is added only when required. if (!isValid(value)) return ( diff --git a/admin-ui/src/modules/forms/hooks/useForm.ts b/admin-ui/src/modules/forms/hooks/useForm.ts index 512f54f7c4..1cfba75a81 100644 --- a/admin-ui/src/modules/forms/hooks/useForm.ts +++ b/admin-ui/src/modules/forms/hooks/useForm.ts @@ -16,7 +16,8 @@ export type OnSubmitType = ( ) => Promise<{ success: boolean; data?: any; error?: any }>; /** - * @param onSubmitSuccess Return `false` to skip redirect + * Calls onSubmitSuccess after a successful submission with the result data or form values. + * The callback return value is ignored; navigation is handled by the caller. */ const useForm = ({ diff --git a/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx b/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx index e2ebcd7d42..33051df4bf 100644 --- a/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx +++ b/admin-ui/src/modules/product/components/ProductAssignmentScaffoldForm.tsx @@ -75,7 +75,7 @@ const ProductAssignmentScaffoldForm = ({ required name="type" options={Object.fromEntries( - // TODO: Fix this mapping when ProductTypes is fixed + // Use GraphQL enum values as option values and enum keys as labels. Object.entries(IProductType).map(([key, value]) => [value, key]), )} /> diff --git a/admin-ui/src/pages/quotations/QuotationDetailPage.tsx b/admin-ui/src/pages/quotations/QuotationDetailPage.tsx index 5e5d093c29..f16916578f 100644 --- a/admin-ui/src/pages/quotations/QuotationDetailPage.tsx +++ b/admin-ui/src/pages/quotations/QuotationDetailPage.tsx @@ -33,7 +33,7 @@ const QuotationDetailPage = ({ quotationId }) => { )} /> - {/* TODO change mock props data */} + {/* Render the quotation loaded by useQuotation. */} {loading ? : } ); diff --git a/docs/README.md b/docs/README.md index 0c6c2c27be..c92bad76d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,41 +1,34 @@ -# Website +# Documentation website -This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. +The documentation site uses [Docusaurus](https://docusaurus.io/). It has its own npm lockfile and is not a root npm workspace. Run the following commands from `docs/`. -### Installation +## Install and develop -``` -$ yarn -``` - -### Local Development - -``` -$ yarn start +```sh +nvm use +npm ci +npm run dev ``` -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. +`npm run dev` starts the development server with live reload. -### Build +## Build and preview -``` -$ yarn build +```sh +npm run build +npm start ``` -This command generates static content into the `build` directory and can be served using any static contents hosting service. +The build writes static files to `build/`. `npm start` serves that production build locally. Deploy the contents of `build/` with a static hosting service; this package does not define a `deploy` script. -### Deployment +## Validate GraphQL examples -Using SSH: +Start an engine with the relevant plugins enabled, then run: -``` -$ USE_SSH=true yarn deploy -``` - -Not using SSH: - -``` -$ GIT_USER= yarn deploy +```sh +npm run validate:graphql +# To use another engine: +GRAPHQL_ENDPOINT=http://localhost:4010/graphql npm run validate:graphql ``` -If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. +The validator checks fenced `graphql` and `gql` blocks in `docs/` against the running engine's introspection schema. Custom schema examples and intentionally historical migration snippets can need separate review. diff --git a/docs/docs/concepts/architecture.md b/docs/docs/concepts/architecture.md index 67f85da045..d56b9fab09 100644 --- a/docs/docs/concepts/architecture.md +++ b/docs/docs/concepts/architecture.md @@ -50,7 +50,7 @@ The platform layer (`@unchainedshop/platform` and `@unchainedshop/api`) handles: - Loading all default core modules - Defining the GraphQL schema and resolvers -- Starting the API server (Express or Fastify) +- Preparing the GraphQL handler; your application connects it to Express or Fastify and starts listening - Managing the work queue for background jobs - Orchestrating module configuration - Email templates and messaging @@ -128,16 +128,16 @@ Foundation utilities used across all layers: ## API Design Principles -1. **Stateless**: All data stored in MongoDB, no server-side sessions +1. **Shared Persistence**: Business data and HTTP sessions are stored in MongoDB; multiple server instances can use the same backing database 2. **Guest Users**: Anonymous users use `loginAsGuest` mutation for cart operations 3. **Server-side Logic**: All business logic remains server-side for omni-channel support ### Implications **Carts as Open Orders** -- Carts are stored server-side as orders with `status: null` +- Carts are stored server-side as orders with `status: null` in storage (exposed as `CART` in GraphQL) - Users can add items on one device and checkout on another -- After checkout, the cart becomes an immutable order +- After checkout, the order follows payment, confirmation, and fulfillment transitions **User Conversion** - Anonymous users can register without losing order history diff --git a/docs/docs/concepts/authentication.md b/docs/docs/concepts/authentication.md index d09295790e..4033b4294f 100644 --- a/docs/docs/concepts/authentication.md +++ b/docs/docs/concepts/authentication.md @@ -206,7 +206,7 @@ mutation GetCredentialCreationOptions { } ``` -2. Create credential with browser WebAuthn API using the returned options: +2. Convert the JSON challenge and user ID to binary values before calling the browser API. The following call assumes `creationOptions` is already a `PublicKeyCredentialCreationOptions` object: ```javascript const credential = await navigator.credentials.create({ @@ -214,7 +214,7 @@ const credential = await navigator.credentials.create({ }); ``` -3. Store the credential: +3. Serialize credential binary fields as base64url strings and store the credential: ```graphql mutation AddWebAuthnCredentials($credentials: JSON!) { @@ -237,7 +237,7 @@ mutation GetCredentialRequestOptions { } ``` -2. Authenticate with browser WebAuthn API: +2. Convert the JSON challenge and allowed credential IDs to binary values. The following call assumes `requestOptions` is already a `PublicKeyCredentialRequestOptions` object: ```javascript const credential = await navigator.credentials.get({ @@ -245,7 +245,7 @@ const credential = await navigator.credentials.get({ }); ``` -3. Verify and login: +3. Serialize the assertion binary fields as base64url strings, include the returned request ID, and verify the login: ```graphql mutation LoginWithWebAuthn($credentials: JSON!) { @@ -280,57 +280,44 @@ OIDC integration is configured through the GraphQL context. See the [OIDC Exampl ### Login Flow -OIDC authentication is implemented via custom GraphQL resolvers. The flow typically involves: +The OIDC example uses HTTP routes and a custom context resolver: -1. **Get authorization URL**: Custom query that returns the provider's OAuth URL -2. **User redirected to provider**: User authenticates with the identity provider -3. **Exchange code for token**: Custom mutation that validates the authorization code and creates a session +1. `/login` redirects the browser to the configured provider. +2. The provider redirects to `/login/keycloak/callback` or `/login/zitadel/callback`. +3. The callback exchanges the code and stores provider tokens in the server-side session. +4. The context resolver maps the provider identity and roles to an Unchained user. -See the [OIDC Example](https://github.com/unchainedshop/unchained/tree/master/examples/oidc) for a complete implementation showing how to add custom OIDC queries and mutations. +The example selects Zitadel or Keycloak from its environment variables; additional providers require an equivalent integration. ## API Token Authentication For server-to-server or automated access. -### Using Tokens +### Creating and Rotating an Access Token -Unchained users have a `tokens` field that stores authentication tokens. You can query a user's tokens: +Create a token from trusted server code for an existing username: -```graphql -query MyTokens { - me { - tokens { - _id - } - } -} +```typescript +const result = await modules.users.createAccessToken('integration-user'); +if (!result) throw new Error('User not found'); +const accessToken = result.token; ``` -### Invalidating Tokens - -To invalidate a token: - -```graphql -mutation InvalidateToken { - invalidateToken(tokenId: "token-id") { - _id - } -} -``` +Store the returned token securely. The user's `services.token` field contains its SHA-256 hash, and issuing another access token replaces the previous one. The GraphQL `User.tokens` field and `invalidateToken` mutation concern tokenized products; they do not manage API authentication credentials. ### Including Token in Requests -Include the token in the Authorization header: - ```http -Authorization: Bearer +Authorization: Bearer ``` +The standard API adapters resolve this bearer token through the user module. Browser login uses the separate cookie-backed session described below. + ## Session Management ### Token Format -Unchained uses JWT tokens for authentication. Configure the token secret via environment variable: +The standard Express and Fastify adapters use signed session-ID cookies and MongoDB-backed sessions. Login mutations return a session ID and expiry; they do not issue JWTs. `UNCHAINED_TOKEN_SECRET` signs session cookies: ```bash UNCHAINED_TOKEN_SECRET=your-32-character-minimum-secret-here @@ -384,39 +371,34 @@ Unchained uses RBAC for authorization: | Role | Description | |------|-------------| | `admin` | Full access to all operations | -| `user` | Authenticated user with standard permissions | +| `__loggedIn__` | Automatically included for authenticated users | +| `__all__` | Automatically included for every request | ### Checking Permissions ```typescript -import { checkAction } from '@unchainedshop/roles'; +import { Roles } from '@unchainedshop/roles'; -// In a resolver -if (!checkAction(context, 'manageOrders')) { +if (!(await Roles.userHasPermission(context, 'manageOrders', []))) { throw new Error('Permission denied'); } ``` ### Custom Roles +`Role` registers itself during construction. Define it after platform initialization has configured the built-in roles, or use `rolesOptions.additionalRoles` at startup. + ```typescript -import { Roles, Role } from '@unchainedshop/roles'; +import { Role } from '@unchainedshop/roles'; -// Define custom role const supportRole = new Role('support'); +supportRole.allow('viewOrders', async () => true); -supportRole.allow('viewOrders', () => true); -supportRole.allow('updateOrderStatus', (context, { order }) => { - // Only pending orders - return order.status === 'PENDING'; -}); - -Roles.registerRole(supportRole); - -// Assign role to user await modules.users.updateRoles(userId, ['support']); ``` +Permission callbacks receive `(root, parameters, context)`. Use an existing action name from the API or register your custom action before checking it. + ## Security Best Practices For comprehensive security documentation, see the [Security Guide](../deployment/security). @@ -430,7 +412,7 @@ Unchained uses industry-standard cryptography for authentication: | Password Hashing | PBKDF2-SHA512 | 300,000 iterations, 16-byte salt | | Token Storage | SHA-256 | Tokens hashed before database storage | | Token Generation | CSPRNG | `crypto.randomUUID()` | -| Session Encryption | AES-256-GCM | Optional, via kruptein | +| Session Cookies | HMAC signatures | Integrity protection for session IDs; session data is stored in MongoDB without application-level encryption by the default adapters | ### 1. Token Secret diff --git a/docs/docs/concepts/director-adapter-pattern.md b/docs/docs/concepts/director-adapter-pattern.md index d9cfc0b0f2..cc88e903fd 100644 --- a/docs/docs/concepts/director-adapter-pattern.md +++ b/docs/docs/concepts/director-adapter-pattern.md @@ -43,581 +43,135 @@ flowchart LR | `QuotationDirector` | RFQ processing | Manual quotes, Auto quotes | | `EnrollmentDirector` | Subscriptions | Recurring billing | -## Base Classes +## Adapter Objects -All adapters extend from base classes provided by `@unchainedshop/utils`: +Adapters are objects composed from base implementations. `BaseDirector` is a factory and `BaseAdapter` supplies shared logging and metadata utilities in `@unchainedshop/utils`. Domain bases such as `PaymentAdapter` and `ProductPricingAdapter` are exported by `@unchainedshop/core`. -```typescript -import { BaseAdapter, BaseDirector } from '@unchainedshop/utils'; -``` - -- **BaseDirector**: Factory function creating a director with adapter management -- **BaseAdapter**: Base implementation with logging and utility methods - -## Creating a Custom Adapter - -All adapters share a common structure: - -```typescript -const MyAdapter = { - key: 'my-adapter', // Unique identifier - label: 'My Custom Adapter', // Human-readable label - version: '1.0.0', // Adapter version - - // Optional: order of execution (lower = first) - orderIndex: 10, - - // Adapter-specific methods... -}; - -// Register with the appropriate director -SomeDirector.registerAdapter(MyAdapter); -``` +Spread the domain base into your adapter, then spread its action methods before overriding the behavior you need. This retains required defaults and keeps the example compatible with the domain interface. ## Payment Director -Manages payment processing and orchestrates payment adapters. - -```typescript -import { PaymentDirector, type IPaymentAdapter } from '@unchainedshop/core'; - -const MyPaymentAdapter: IPaymentAdapter = { - key: 'my-payment', - label: 'My Payment Gateway', - version: '1.0.0', - - // Which payment types this adapter supports - typeSupported(type) { - return type === 'CARD'; // CARD, INVOICE, or GENERIC - }, - - actions(config, context) { - return { - // Return configuration errors (e.g., missing API key) - configurationError() { - if (!process.env.PAYMENT_API_KEY) { - return { code: 'MISSING_API_KEY' }; - } - return null; - }, - - // Is this adapter active for the current context? - isActive() { return true; }, - - // Can order be confirmed before payment completes? - isPayLaterAllowed() { return false; }, - - // Process payment charge - async charge() { - // Return { transactionId } on success - // Return false if payment not yet complete - // Throw error to abort checkout - return { transactionId: '...' }; - }, - - // Confirm a previously authorized payment - async confirm() { - return { transactionId: '...' }; - }, - - // Cancel/refund a payment - async cancel() { - return true; - }, - - // Register a payment method (e.g., save card) - async register() { - return { token: '...' }; - }, - - // Sign payment request for client-side SDK - async sign() { - return '...'; - }, - - // Validate a payment token - async validate(token) { - return true; - }, - }; - }, -}; - -PaymentDirector.registerAdapter(MyPaymentAdapter); -``` - -## Delivery Director - -Manages delivery operations and coordinates shipping adapters. - -```typescript -import { DeliveryDirector, type IDeliveryAdapter } from '@unchainedshop/core'; - -const MyDeliveryAdapter: IDeliveryAdapter = { - key: 'my-delivery', - label: 'My Shipping Provider', - version: '1.0.0', - - // Which delivery types this adapter supports - typeSupported(type) { - return type === 'SHIPPING'; // SHIPPING, PICKUP, or DELIVERY - }, - - actions(config, context) { - return { - configurationError() { return null; }, - isActive() { return true; }, - - // Can order be auto-released for delivery? - isAutoReleaseAllowed() { return false; }, - - // Trigger delivery - async send() { - return { trackingNumber: '...' }; - }, - - // Estimated delivery time in milliseconds - estimatedDeliveryThroughput(warehousingTime) { - return 3 * 24 * 60 * 60 * 1000; // 3 days - }, - - // For PICKUP type: available locations - async pickUpLocations() { - return []; - }, - - async pickUpLocationById(locationId) { - return null; - }, - }; - }, -}; - -DeliveryDirector.registerAdapter(MyDeliveryAdapter); -``` - -## Warehousing Director - -Manages inventory and stock operations, including NFT/token support. +Payment adapters use `actions(configuration, context)`. The context supplies the order, order payment, payment provider, and modules. Payment types are `GENERIC` and `INVOICE`. ```typescript -import { WarehousingDirector, type IWarehousingAdapter } from '@unchainedshop/core'; +import { PaymentAdapter, PaymentDirector, type IPaymentAdapter } from '@unchainedshop/core'; -const MyWarehousingAdapter: IWarehousingAdapter = { - key: 'my-warehouse', - label: 'My Inventory System', +const ManualPayment: IPaymentAdapter = { + ...PaymentAdapter, + key: 'com.example.payment.manual', + label: 'Manual payment', version: '1.0.0', - - typeSupported(type) { - return type === 'PHYSICAL'; - }, - - actions(config, context) { + typeSupported: (type) => type === 'INVOICE', + actions(configuration, context) { + const baseActions = PaymentAdapter.actions(configuration, context); return { - configurationError() { return null; }, - isActive() { return true; }, - - // Current stock quantity - async stock(referenceDate) { - return 100; - }, - - // Production time in ms (for made-to-order) - async productionTime(quantity) { - return 0; - }, - - // Time to prepare for shipping in ms - async commissioningTime(quantity) { - return 24 * 60 * 60 * 1000; // 1 day - }, - - async estimatedStock() { - return 100; - }, - - async estimatedDispatch() { - return new Date(); - }, - - // For tokenized products (NFTs): - async tokenize() { return []; }, - async tokenMetadata(serial, date) { return {}; }, - async isInvalidateable(serial, date) { return false; }, + ...baseActions, + configurationError: () => null, + isActive: () => true, + isPayLaterAllowed: () => true, + charge: async () => false, }; }, }; -WarehousingDirector.registerAdapter(MyWarehousingAdapter); +PaymentDirector.registerAdapter(ManualPayment); ``` -## Worker Director +A successful `charge()` returns payment information such as `{ transactionId }`. Returning `false` leaves the payment unpaid; throwing aborts checkout. `confirm()` and `cancel()` return booleans. `configurationError()` returns a `PaymentError` code or `null`. -Manages background job processing and scheduled tasks. +## Delivery and Warehousing Directors -```typescript -import { WorkerDirector, type IWorkerAdapter } from '@unchainedshop/core'; - -interface MyInput { email: string; subject: string; } -interface MyOutput { messageId: string; } - -const MyWorkerAdapter: IWorkerAdapter = { - key: 'my-worker', - label: 'My Background Worker', - version: '1.0.0', - type: 'MY_WORK_TYPE', // Work type identifier - external: false, // Runs in-process - maxParallelAllocations: 10, // Max concurrent executions +Both use `actions(configuration, context)` and provider configuration selected in the Admin UI. - async doWork(input, unchainedAPI, workId) { - const { email, subject } = input; +| Base | Provider types | Main actions | +|------|----------------|--------------| +| `DeliveryAdapter` | `SHIPPING`, `PICKUP` | `send`, `isAutoReleaseAllowed`, `estimatedDeliveryThroughput`, `pickUpLocations` | +| `WarehousingAdapter` | `PHYSICAL`, `VIRTUAL` | `stock`, `productionTime`, `commissioningTime`, `tokenize`, `tokenMetadata`, `isInvalidateable` | - // Process the work item - // ... - - return { - success: true, - result: { messageId: 'msg-123' }, - }; - }, -}; - -WorkerDirector.registerAdapter(MyWorkerAdapter); -``` - -### Scheduling Recurring Work - -```typescript -WorkerDirector.configureAutoscheduling({ - type: 'MY_WORK_TYPE', - input: { email: 'test@example.com', subject: 'Test' }, - schedule: '0 * * * *', // Every hour (cron syntax) -}); -``` +`send()` resolves to a boolean or a queued work item. Delivery and warehousing throughput methods are asynchronous and return durations in milliseconds. Spread the relevant base actions to retain defaults for operations you do not override. ## Pricing Directors -Pricing directors calculate prices using a **chain of adapters**. Each adapter can add, modify, or discount prices. Adapters execute in order of their `orderIndex`. +Pricing adapters use `actions(params)`, where `params` contains `context`, `calculationSheet` (the accumulated rows), and discount configurations. The base actions expose a separate `resultSheet()` for the rows contributed by this adapter. ```typescript -import { ProductPricingDirector, type IProductPricingAdapter } from '@unchainedshop/core'; +import { + ProductPricingAdapter, + ProductPricingDirector, + type IProductPricingAdapter, +} from '@unchainedshop/core'; -const MyPricingAdapter: IProductPricingAdapter = { - key: 'my-pricing', - label: 'My Pricing Logic', +const ExamplePrice: IProductPricingAdapter = { + ...ProductPricingAdapter, + key: 'com.example.pricing.example', + label: 'Example product price', version: '1.0.0', - orderIndex: 10, // Lower numbers run first - - // Should this adapter run for the current context? - isActivatedFor(context) { - return true; - }, - - actions(params, pricingAdapter) { + orderIndex: 0, + isActivatedFor: ({ product, currencyCode }) => + Boolean(product.tags?.includes('example-price')) && currencyCode === 'CHF', + actions(params) { + const baseActions = ProductPricingAdapter.actions(params); return { - calculate() { - // Access existing calculations - const { calculation } = pricingAdapter; - - // Add price item - pricingAdapter.resultSheet().addItem({ - category: 'BASE', - amount: 1000, // in smallest currency unit (cents) - isTaxable: true, - isNetPrice: true, - }); - - // Continue chain - return pricingAdapter.calculate(); + ...baseActions, + async calculate() { + if (!params.calculationSheet.calculation.length) { + baseActions.resultSheet().addItem({ + amount: 1000 * params.context.quantity, + isTaxable: false, + isNetPrice: true, + meta: { adapter: ExamplePrice.key }, + }); + } + return baseActions.calculate(); }, }; }, }; -ProductPricingDirector.registerAdapter(MyPricingAdapter); +ProductPricingDirector.registerAdapter(ExamplePrice); ``` -### Pricing Categories - -| Category | Description | -|----------|-------------| -| `BASE` | Base product price | -| `TAX` | Tax amount | -| `DISCOUNT` | Discount amount (negative) | -| `DELIVERY` | Delivery fee | -| `PAYMENT` | Payment fee | - -## Discount Directors +The director executes active adapters in ascending `orderIndex`, appends each returned array, and invokes the next adapter. Returning `null` aborts the calculation; returning an empty array contributes no rows. `baseActions.calculate()` returns this adapter's rows, rather than invoking the next adapter itself. -Discount directors manage coupon codes and automatic discounts. +Product rows use `ITEM`, `DISCOUNT`, and `TAX`. Delivery and payment fee sheets add `DELIVERY` and `PAYMENT` respectively. Order aggregation uses `ITEMS`, `DISCOUNTS`, `TAXES`, `DELIVERY`, and `PAYMENT`. Use the sheet helpers (`addItem`, `addFee`, `addTax`, `addDiscount`) to assign the appropriate category. -```typescript -import { OrderDiscountDirector, type IDiscountAdapter } from '@unchainedshop/core'; - -const MyDiscountAdapter: IDiscountAdapter = { - key: 'my-discount', - label: 'My Discount System', - version: '1.0.0', - orderIndex: 10, +Choose order indexes relative to the plugins you enable; the indexes are not fixed category ranges. See [Pricing System](./pricing-system.md). - // Allow manual code entry starting with 'PROMO' - isManualAdditionAllowed(code) { - return code.startsWith('PROMO'); - }, - - isManualRemovalAllowed() { - return true; - }, - - actions(context) { - return { - // Auto-apply discount without code? - isValidForSystemTriggering() { - return false; - }, - - // Apply when specific code entered? - isValidForCodeTriggering(code) { - return code === 'PROMO10'; - }, - - // Return discount configuration - discountForPricingAdapterKey(params) { - return { - isNetPrice: false, - rate: 0.1, // 10% off - }; - }, - - // Reserve discount (e.g., decrement coupon balance) - async reserve(code) {}, +## Discount Directors - // Release reservation on order cancellation - async release() {}, - }; - }, -}; +`OrderDiscountAdapter` and `ProductDiscountAdapter` provide asynchronous trigger and reservation methods. Their `actions({ context })` method is asynchronous. `discountForPricingAdapterKey({ pricingAdapterKey, calculationSheet })` returns configuration for a matching pricing adapter or `null`. The pricing adapter applies the discount to its result sheet. -OrderDiscountDirector.registerAdapter(MyDiscountAdapter); -``` +See [Order Discounts](../extend/pricing/order-discounts.md) for a complete implementation. ## Filter Director -Manages product filtering and search functionality. - -```typescript -import { FilterDirector, type IFilterAdapter } from '@unchainedshop/core'; - -const MyFilterAdapter: IFilterAdapter = { - key: 'my-filter', - label: 'My Search Filter', - version: '1.0.0', - orderIndex: 10, +`FilterAdapter.actions(context)` supplies search and selector transformations. `aggregateProductIds({ productIds })` returns an array synchronously. `searchProducts` and `searchAssortments` resolve to ID arrays or `undefined`; they do not return paginated result objects. Selector and sort transformations are asynchronous. - actions(context) { - return { - // Return product IDs matching filter - async aggregateProductIds(params) { - return ['product-1', 'product-2']; - }, +See [Filters](../extend/catalog/filter.md) for custom filtering. - // Search products - async searchProducts(params, options) { - return { productIds: [], totalCount: 0 }; - }, - - // Search assortments - async searchAssortments(params, options) { - return { assortmentIds: [], totalCount: 0 }; - }, +## Worker Director - // Modify MongoDB product selector - transformProductSelector(selector, options) { - return selector; - }, +Workers compose `WorkerAdapter` and implement `doWork(input, unchainedAPI, workId)`. The generic interface is `IWorkerAdapter`. Return `{ success: true, result }` or `{ success: false, error }`. Use `modules.worker.addWork()` to queue a task and `WorkerDirector.configureAutoscheduling()` with a parsed schedule for recurring work. - // Modify MongoDB filter selector - transformFilterSelector(selector, options) { - return selector; - }, - - // Modify MongoDB sort stage - transformSortStage(sort, options) { - return sort; - }, - }; - }, -}; - -FilterDirector.registerAdapter(MyFilterAdapter); -``` +See [Worker](../extend/worker.md) for registration and scheduling. ## Messaging Director -The Messaging Director uses a template resolver pattern for notifications. - -```typescript -import { MessagingDirector } from '@unchainedshop/core'; - -// Register a message template -MessagingDirector.registerTemplate('ORDER_CONFIRMATION', async (context) => { - const { order, user } = context; - - return [ - { - type: 'EMAIL', - input: { - to: user.email, - subject: `Order Confirmation #${order.orderNumber}`, - html: '

Thank you for your order!

', - }, - }, - { - type: 'SMS', - input: { - to: user.phone, - text: `Order #${order.orderNumber} confirmed!`, - }, - }, - ]; -}); -``` +Messaging uses template resolvers rather than an adapter action object. `MessagingDirector.registerTemplate(type, resolver)` registers a resolver that produces work items such as email or SMS tasks. Read recipients from the current user/profile or order data through module APIs; users do not have generic `email` and `phone` properties. ## Quotation Director -Handles quotation/RFQ (Request for Quote) operations. - -```typescript -import { QuotationDirector, type IQuotationAdapter } from '@unchainedshop/core'; - -const MyQuotationAdapter: IQuotationAdapter = { - key: 'my-quotation', - label: 'My Quote System', - version: '1.0.0', - - isActivatedFor(quotationContext, unchainedAPI) { - return true; - }, - - actions(context) { - return { - configurationError() { return null; }, - - // Require manual quote creation? - isManualProposalRequired() { return true; }, - - // Require manual request verification? - isManualRequestVerificationRequired() { return false; }, - - // Generate quote - async quote() { - return { price: 1000, currency: 'CHF' }; - }, - - async submitRequest(quotationContext) {}, - async verifyRequest(quotationContext) {}, - async rejectRequest(quotationContext) {}, - - transformItemConfiguration(params) { - return params.configuration; - }, - }; - }, -}; - -QuotationDirector.registerAdapter(MyQuotationAdapter); -``` +Compose `QuotationAdapter` and override `isActivatedFor(quotationContext, unchainedAPI)` plus the methods returned by `actions(quotationContext)`. Proposal, request verification, and item-configuration methods are asynchronous. `quote()` returns a `QuotationProposal`, not a product price object. ## Enrollment Director -Manages subscription/enrollment plans and recurring billing. - -```typescript -import { EnrollmentDirector, type IEnrollmentAdapter } from '@unchainedshop/core'; - -const MyEnrollmentAdapter: IEnrollmentAdapter = { - key: 'my-enrollment', - label: 'My Subscription System', - version: '1.0.0', +Compose `EnrollmentAdapter`. `isActivatedFor()` receives the product's plan configuration; `transformOrderItemToEnrollmentPlan()` is asynchronous and returns a plan containing the product ID and quantity. The action context contains the enrollment and product. `nextPeriod()` returns a period including `isTrial`, and `configurationForOrder({ period })` returns order-position templates or `null`. - isActivatedFor(productPlan) { - return productPlan.type === 'PLAN_PRODUCT'; - }, +## Registration and Configuration - transformOrderItemToEnrollmentPlan(orderPosition, unchainedAPI) { - return { - configuration: orderPosition.configuration, - }; - }, - - actions(context) { - return { - // Calculate next billing period - async nextPeriod() { - return { - start: new Date(), - end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - }; - }, - - isValidForActivation() { - return true; - }, - - isOverdue() { - return false; - }, - - // Order configuration for billing period - async configurationForOrder(period) { - return {}; - }, - }; - }, -}; - -EnrollmentDirector.registerAdapter(MyEnrollmentAdapter); -``` - -## Best Practices - -### 1. Unique Keys -Always use unique, namespaced keys for your adapters: -```typescript -key: 'com.mycompany.payment.stripe-custom' -``` - -### 2. Error Handling -Return `configurationError()` for missing configuration rather than throwing: -```typescript -configurationError() { - if (!process.env.API_KEY) { - return { code: 'MISSING_API_KEY', message: 'API key required' }; - } - return null; -} -``` - -### 3. Async Operations in Workers -For long-running operations, use the Worker system instead of blocking adapters: -```typescript -// In delivery adapter -async send() { - // Queue work instead of blocking - await context.modules.worker.addWork({ - type: 'EXTERNAL_SHIPPING_API', - input: { orderId: order._id }, - }); - return false; // Not complete yet -} -``` +Use unique, namespaced keys, register adapters before platform initialization, and import plugin files using their exported path including `.js` (or `/index.js` for directory modules). Importing built-in plugins registers them. -### 4. Order Index -Use appropriate `orderIndex` values for pricing adapters: -- 0-10: Base price calculation -- 10-20: Discounts -- 20-30: Tax calculation -- 30+: Final adjustments +Keep gateway-specific configuration and long-running background jobs in their respective adapters. Return the domain's configuration-error code for incomplete configuration, and use worker tasks when delivery or other external work must complete asynchronously. ## Related diff --git a/docs/docs/concepts/order-lifecycle.md b/docs/docs/concepts/order-lifecycle.md index b47e691ad7..b706508173 100644 --- a/docs/docs/concepts/order-lifecycle.md +++ b/docs/docs/concepts/order-lifecycle.md @@ -22,10 +22,10 @@ stateDiagram-v2 | Status | Description | |--------|-------------| -| `null` (OPEN) | Cart - actively being modified | +| `null` in storage / `CART` in GraphQL (OPEN) | Cart - actively being modified | | `PENDING` | Checkout initiated, awaiting payment confirmation | -| `CONFIRMED` | Payment confirmed, awaiting delivery | -| `FULFILLED` | Delivery complete, order finished | +| `CONFIRMED` | Confirmation allowed by payment/delivery rules; may still await payment or delivery | +| `FULFILLED` | Delivery complete and payment paid | | `REJECTED` | Order cancelled/rejected | ## Distributed Locking @@ -40,7 +40,7 @@ Every time the order processor persists an order status in the database (think " ## OPEN (Cart) -An order starts its life with a status of `null`, indicating it's a cart. +An order starts with `status: null` in storage, exposed as `CART` by GraphQL. ### Key Characteristics @@ -80,13 +80,14 @@ Checkout is typically triggered server-to-server from payment plugin webhooks. I When checkout is initiated, the order is validated: -1. **Payment Provider**: Order must have a payment provider set -2. **Delivery Provider**: Order must have a delivery provider set -3. **Cart Items**: At least one order position must be present -4. **Position Validation**: Each position is validated via `validateOrderPosition`: +1. **Contact and Billing Address**: Both are required +2. **Payment Provider**: Order must have a payment provider set +3. **Delivery Provider**: Order must have a delivery provider set +4. **Cart Items**: At least one order position must be present +5. **Position Validation**: Each position is validated via `validateOrderPosition`: - By default, checks if the product is still active - Can be customized via platform settings -5. **Quotation Check**: If position is a quotation proposal, the Quotation plugin verifies it's still valid +6. **Quotation Check**: If position is a quotation proposal, the Quotation plugin verifies it's still valid :::warning No Recalculation The order validation step **DOES NOT** recalculate the order. Prices and delivery dates may have changed since the last cart mutation. If you need such validation, throw an error in `validateOrderPosition` and let the client application fix the problem. @@ -183,11 +184,12 @@ The system proceeds with delivery via the `DeliveryDirector`. 1. **Delivery Initiation**: `DeliveryDirector` calls `send()` on the delivery adapter - If throws: Process interrupted, order stays `CONFIRMED` - If returns `false`: Delivery not complete yet - - If returns `{ trackingNumber }`: Delivery initiated + - If returns `true`: Delivery can be marked delivered + - If returns a queued work item: Completion is handled through the worker flow 2. **Position Processing**: For each order position: - **TokenizedProduct**: `WarehousingDirector.tokenize()` creates digital tokens/NFTs - - **PlanProduct**: `EnrollmentDirector.transformOrderItemToEnrollment()` creates subscriptions + - **PlanProduct**: the enrollment service uses `EnrollmentDirector.transformOrderItemToEnrollmentPlan()` to initialize subscriptions - **Quotation**: `QuotationDirector` marks linked quotations as fulfilled 3. **Final Status Check**: @@ -200,14 +202,14 @@ If any position processing throws (e.g., in a `WarehousingAdapter`), the process **Best Practice**: Build these actions to be asynchronous and forgiving: ```typescript -async send() { +const send = async () => { // Queue work instead of blocking await context.modules.worker.addWork({ type: 'EXTERNAL_ERP_SYNC', input: { orderId: order._id }, }); return false; // Not complete yet - will be updated by worker -} +}; ``` This approach also makes checkouts faster! diff --git a/docs/docs/concepts/pricing-system.md b/docs/docs/concepts/pricing-system.md index 2b72c94a58..17da18ca71 100644 --- a/docs/docs/concepts/pricing-system.md +++ b/docs/docs/concepts/pricing-system.md @@ -33,7 +33,7 @@ flowchart TD ## Pricing Chain -Adapters execute in order of their `orderIndex` (ascending). Lower numbers run first. +Adapters execute in order of their `orderIndex` (ascending). Lower numbers run first. The example below is illustrative; built-in adapter indexes vary, so inspect the plugins you enable before assigning an index. ```mermaid flowchart LR @@ -43,9 +43,9 @@ flowchart LR Each adapter: 1. Receives the current calculation state 2. Can add items to the calculation -3. Passes control to the next adapter via `super.calculate()` +3. Returns its contributed rows; the director appends them and invokes the next adapter -### Order Index Guidelines +### Order Index Examples | Range | Purpose | Examples | |-------|---------|----------| @@ -58,9 +58,10 @@ Each adapter: | Category | Description | Typical Use | |----------|-------------|-------------| -| `BASE` | Base product/service price | Initial price calculation | -| `DISCOUNT` | Price reduction (negative amount) | Coupons, promotions | -| `TAX` | Tax amount | VAT, sales tax | +| `ITEM` | Base product price | Product pricing | +| `ITEMS` | Aggregated item prices | Order pricing | +| `DISCOUNT` / `DISCOUNTS` | Price reduction | Component / order pricing | +| `TAX` / `TAXES` | Tax amount | Component / order pricing | | `DELIVERY` | Shipping fees | Delivery pricing | | `PAYMENT` | Payment processing fees | Card fees, invoice fees | @@ -73,7 +74,7 @@ When adding items to the calculation, each item has: | `amount` | number | Price in smallest currency unit (cents) | | `isTaxable` | boolean | Should tax be calculated on this amount? | | `isNetPrice` | boolean | Is this a net price (excluding tax)? | -| `category` | string | Price category (BASE, TAX, DISCOUNT, etc.) | +| `category` | string | Assigned by the sheet helper; categories differ between component and order sheets | | `meta` | object | Additional metadata | ## Pricing Sheet @@ -81,22 +82,22 @@ When adding items to the calculation, each item has: Access calculated prices via the pricing sheet: ```typescript -const pricingSheet = await modules.orders.pricingSheet(order); - -// Get totals -const total = pricingSheet.total(); // { amount, currency } -const gross = pricingSheet.gross(); // Before discounts -const net = pricingSheet.net(); // After discounts, before tax -const taxes = pricingSheet.taxes(); // Tax breakdown - -// Get items by category -const discounts = pricingSheet.discounts(); -const delivery = pricingSheet.delivery(); -const payment = pricingSheet.payment(); - -// Sum specific items -const taxableAmount = pricingSheet.sum({ isTaxable: true }); -const baseAmount = pricingSheet.sum({ category: 'BASE' }); +import { OrderPricingSheet } from '@unchainedshop/core'; + +// Wrap the calculation already stored on the order. +const pricingSheet = OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, +}); + +const total = pricingSheet.total(); // { amount, currencyCode } +const gross = pricingSheet.gross(); // Amount including tax and discounts +const net = pricingSheet.net(); // Amount excluding tax, including discounts +const taxes = pricingSheet.taxSum(); // Numeric tax sum +const discounts = pricingSheet.discountPrices(); +const delivery = pricingSheet.total({ category: 'DELIVERY' }); +const payment = pricingSheet.total({ category: 'PAYMENT' }); +const items = pricingSheet.total({ category: 'ITEMS' }); ``` ## GraphQL Price Fields @@ -159,18 +160,11 @@ query CartPricing { ## Best Practices -### 1. Always Call super.calculate() +### 1. Return the Adapter's Result Sheet -```typescript -async calculate() { - // Your logic here - - // IMPORTANT: Continue the chain - return super.calculate(); -} -``` +Compose the appropriate base adapter, create base actions with `ProductPricingAdapter.actions(params)` (or the delivery/payment/order equivalent), and return `baseActions.calculate()` after adding your rows. The director, not the adapter, advances the chain. Returning `null` aborts the current calculation; returning `[]` allows the next adapter to run. -Returning without calling `super.calculate()` stops the pricing chain. +See the [complete object-adapter example](./director-adapter-pattern.md#pricing-directors). ### 2. Handle Currency Properly @@ -189,11 +183,11 @@ const amount = 19.99; // Floating point issues Add metadata for debugging and reporting: ```typescript -this.result.addItem({ +baseActions.resultSheet().addTax({ amount: 100, - category: 'TAX', + rate: 0.081, meta: { - adapter: this.constructor.key, + adapter: MyPricingAdapter.key, rate: 0.081, }, }); diff --git a/docs/docs/deployment/docker.md b/docs/docs/deployment/docker.md index e205e62dd5..23e3adf5fe 100644 --- a/docs/docs/deployment/docker.md +++ b/docs/docs/deployment/docker.md @@ -2,492 +2,159 @@ sidebar_position: 2 title: Docker Deployment sidebar_label: Docker -description: Deploying Unchained Engine with Docker +description: Build and run Unchained Engine, Admin UI, and documentation images --- # Docker Deployment -This guide covers deploying Unchained Engine using Docker containers. +The checked-in Dockerfiles build from the **repository root**. Engine examples and Admin UI use the root npm lockfile with workspace paths preserved. The documentation site has its own lockfile under `docs/`. -## Dockerfile +## Build Images -Create a `Dockerfile` in your project root: +Run these commands from the repository root: -```dockerfile -# Build stage -FROM node:22-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package*.json ./ -COPY packages/*/package*.json ./packages/ - -# Install dependencies -RUN npm ci --only=production - -# Copy source code -COPY . . - -# Build TypeScript -RUN npm run build - -# Production stage -FROM node:22-alpine AS production - -WORKDIR /app - -# Create non-root user -RUN addgroup -g 1001 -S unchained && \ - adduser -S unchained -u 1001 - -# Copy built files -COPY --from=builder --chown=unchained:unchained /app/node_modules ./node_modules -COPY --from=builder --chown=unchained:unchained /app/lib ./lib -COPY --from=builder --chown=unchained:unchained /app/package.json ./ - -# Set environment -ENV NODE_ENV=production -ENV PORT=4010 - -# Switch to non-root user -USER unchained - -# Expose port -EXPOSE 4010 +```bash +# Fastify engine with the built-in Admin UI +docker build -f examples/kitchensink/Dockerfile -t unchained-kitchensink . -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4010/graphql || exit 1 +# Other engine examples +docker build -f examples/kitchensink-express/Dockerfile -t unchained-express . +docker build -f examples/minimal/Dockerfile -t unchained-minimal . +docker build -f examples/ticketing/Dockerfile -t unchained-ticketing . +docker build -f examples/oidc/Dockerfile -t unchained-oidc . -# Start server -CMD ["node", "lib/index.js"] +# Standalone static sites +docker build -f admin-ui/Dockerfile -t unchained-admin . +docker build -f docs/Dockerfile -t unchained-docs . ``` -## Docker Compose +All application images expose container port `3000`. Node build and engine runtime stages use Node.js `26.8.2`. Static Admin UI and documentation images serve compiled files with nginx; they do not run Next.js or the Docusaurus development server. -For local development or simple deployments: +The root `Dockerfile` builds the separate CI image, including MongoDB for integration tests. It is not the production engine entry point: -```yaml -# docker-compose.yml -version: '3.8' - -services: - engine: - build: . - ports: - - "4010:4010" - environment: - - NODE_ENV=production - - ROOT_URL=http://localhost:4010 - - MONGO_URL=mongodb://mongo:27017/unchained - - UNCHAINED_TOKEN_SECRET=${UNCHAINED_TOKEN_SECRET} - depends_on: - - mongo - restart: unless-stopped - - mongo: - image: mongo:7 - volumes: - - mongo_data:/data/db - restart: unless-stopped +```bash +docker build -t unchained-ci . +docker run --rm unchained-ci npm run lint:check +docker run --rm unchained-ci npm test +``` - admin-ui: - image: unchainedshop/admin-ui:latest - ports: - - "4011:3000" - environment: - - UNCHAINED_ENDPOINT=http://engine:4010/graphql - depends_on: - - engine +## Run an Engine with MongoDB -volumes: - mongo_data: -``` +The engine images retain the example's `.env.defaults`; override development credentials and public URLs for your deployment. MongoDB must be reachable through `MONGO_URL` in production. -### With Redis and MinIO +This Compose example runs the Fastify kitchensink on host port `4010`, with its Admin UI at `/` and GraphQL at `/graphql`: ```yaml -# docker-compose.production.yml -version: '3.8' - services: engine: - build: . + build: + context: . + dockerfile: examples/kitchensink/Dockerfile ports: - - "4010:4010" + - "4010:3000" environment: - - NODE_ENV=production - - ROOT_URL=https://api.myshop.com - - MONGO_URL=mongodb://mongo:27017/unchained - - REDIS_URL=redis://redis:6379 - - UNCHAINED_TOKEN_SECRET=${UNCHAINED_TOKEN_SECRET} - - MINIO_ENDPOINT=minio - - MINIO_PORT=9000 - - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} - - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} - - MINIO_BUCKET=unchained-files + ROOT_URL: http://localhost:4010 + MONGO_URL: mongodb://mongo:27017/unchained + UNCHAINED_TOKEN_SECRET: ${UNCHAINED_TOKEN_SECRET:?Set a session secret of at least 32 characters} + UNCHAINED_SECRET: ${UNCHAINED_SECRET:?Set an application secret} + UNCHAINED_SEED_PASSWORD: ${UNCHAINED_SEED_PASSWORD:?Set an initial administrator password} + EMAIL_WEBSITE_URL: http://localhost:4010 + EMAIL_WEBSITE_NAME: My Shop + EMAIL_FROM: shop@example.com depends_on: - - mongo - - redis - - minio + mongo: + condition: service_healthy restart: unless-stopped mongo: - image: mongo:7 + image: mongo:8.2.12 volumes: - mongo_data:/data/db - restart: unless-stopped - - redis: - image: redis:7-alpine - volumes: - - redis_data:/data - restart: unless-stopped - - minio: - image: minio/minio - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio_data:/data - environment: - - MINIO_ROOT_USER=${MINIO_ACCESS_KEY} - - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} - command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand('ping').ok ? 0 : 1)"] + interval: 10s + timeout: 5s + retries: 5 restart: unless-stopped volumes: mongo_data: - redis_data: - minio_data: -``` - -## Building and Running - -### Build Image - -```bash -# Build the image -docker build -t my-shop:latest . - -# Build with build args -docker build \ - --build-arg NODE_ENV=production \ - -t my-shop:latest . -``` - -### Run Container - -```bash -# Run with environment variables -docker run -d \ - --name my-shop \ - -p 4010:4010 \ - -e NODE_ENV=production \ - -e ROOT_URL=https://api.myshop.com \ - -e MONGO_URL=mongodb://... \ - -e UNCHAINED_TOKEN_SECRET=your-secret \ - my-shop:latest ``` -### Docker Compose Commands +Supply the variables in your shell or a local `.env` file, then run: ```bash -# Start all services -docker-compose up -d - -# View logs -docker-compose logs -f engine - -# Stop all services -docker-compose down - -# Rebuild and restart -docker-compose up -d --build -``` - -## Kubernetes - -### Deployment - -```yaml -# k8s/deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: unchained-engine - labels: - app: unchained-engine -spec: - replicas: 2 - selector: - matchLabels: - app: unchained-engine - template: - metadata: - labels: - app: unchained-engine - spec: - containers: - - name: engine - image: my-shop:latest - ports: - - containerPort: 4010 - envFrom: - - secretRef: - name: unchained-secrets - - configMapRef: - name: unchained-config - resources: - requests: - memory: "256Mi" - cpu: "200m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: /graphql - port: 4010 - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /graphql - port: 4010 - initialDelaySeconds: 5 - periodSeconds: 5 +docker compose up --build -d +docker compose logs -f engine +docker compose down ``` -### Service - -```yaml -# k8s/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: unchained-engine -spec: - selector: - app: unchained-engine - ports: - - protocol: TCP - port: 80 - targetPort: 4010 - type: ClusterIP -``` - -### Ingress - -```yaml -# k8s/ingress.yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: unchained-ingress - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod -spec: - tls: - - hosts: - - api.myshop.com - secretName: unchained-tls - rules: - - host: api.myshop.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: unchained-engine - port: - number: 80 -``` - -### ConfigMap and Secrets - -```yaml -# k8s/configmap.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: unchained-config -data: - NODE_ENV: "production" - ROOT_URL: "https://api.myshop.com" - EMAIL_FROM: "noreply@myshop.com" - EMAIL_WEBSITE_NAME: "My Shop" -``` - -```yaml -# k8s/secrets.yaml -apiVersion: v1 -kind: Secret -metadata: - name: unchained-secrets -type: Opaque -stringData: - MONGO_URL: "mongodb+srv://..." - UNCHAINED_TOKEN_SECRET: "your-secret-here" - STRIPE_SECRET_KEY: "sk_live_..." -``` - -## Multi-Stage Builds - -Optimize your Docker image with multi-stage builds: - -```dockerfile -# syntax=docker/dockerfile:1 - -# Dependencies stage -FROM node:22-alpine AS deps -WORKDIR /app -COPY package*.json ./ -RUN npm ci --only=production && npm cache clean --force - -# Build stage -FROM node:22-alpine AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npm run build +The examples are starting points: kitchensink, Express, ticketing, and OIDC generate and log an administrator access token during boot. Review their boot and seed scripts before exposing a production service. Ticketing callbacks are placeholders, and OIDC additionally requires a configured identity provider. -# Production stage -FROM node:22-alpine AS runner -WORKDIR /app +The engine runs as the image's `node` user. Give that user access to any mounted files your adapters need, including certificates and uploaded-file storage. -ENV NODE_ENV=production +## Standalone Admin UI -RUN addgroup -g 1001 -S nodejs && \ - adduser -S unchained -u 1001 - -COPY --from=builder --chown=unchained:nodejs /app/lib ./lib -COPY --from=deps --chown=unchained:nodejs /app/node_modules ./node_modules -COPY --chown=unchained:nodejs package.json ./ - -USER unchained - -EXPOSE 4010 - -CMD ["node", "lib/index.js"] -``` - -## Environment Variables - -Create a `.env` file for Docker Compose: +The exported Admin UI defaults to same-origin API paths. If you serve it on a separate origin, set its public URLs **during the Docker build**: ```bash -# .env -NODE_ENV=production -ROOT_URL=https://api.myshop.com -UNCHAINED_TOKEN_SECRET=your-32-character-secret-here -MINIO_ACCESS_KEY=minioadmin -MINIO_SECRET_KEY=minioadmin +docker build -f admin-ui/Dockerfile -t unchained-admin \ + --build-arg NEXT_PUBLIC_GRAPHQL_ENDPOINT=https://engine.example.com/graphql \ + --build-arg NEXT_PUBLIC_CHAT_URL=https://engine.example.com/chat \ + --build-arg NEXT_PUBLIC_TEMP_FILE_UPLOAD_URL=https://engine.example.com/temp-upload \ + --build-arg NEXT_PUBLIC_LOGO=https://cdn.example.com/logo.svg \ + . +docker run --rm -p 4011:3000 unchained-admin ``` -## Health Checks +The browser must be able to reach these URLs. A Compose service name such as `engine` is normally only resolvable inside the Docker network. Configure the API's CORS and cookie settings for your frontend origin. -### Simple Health Check +Setting `NEXT_PUBLIC_*` with `docker run -e` does not change already compiled assets. To use the default same-origin paths, route `/graphql`, `/chat`, and `/temp-upload` to the engine in your external reverse proxy. The image serves the routes produced by `next.config.js`, including directory indexes and static assets. -```typescript -// src/health.ts -import express from 'express'; +## Documentation Site -const app = express(); - -app.get('/health', (req, res) => { - res.json({ status: 'ok' }); -}); - -app.get('/ready', async (req, res) => { - try { - // Check database connection - await mongoose.connection.db.admin().ping(); - res.json({ status: 'ready' }); - } catch (error) { - res.status(503).json({ status: 'not ready', error: error.message }); - } -}); -``` - -### Docker Health Check - -```dockerfile -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD node -e "require('http').get('http://localhost:4010/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" +```bash +docker build -f docs/Dockerfile -t unchained-docs \ + --build-arg GIT_COMMIT="$(git rev-parse HEAD)" . +docker run --rm -p 4012:3000 unchained-docs ``` -## Logging +The static site is available at `http://localhost:4012`. Its `/version` file contains the supplied commit identifier. `docs/Dockerfile.dockerignore` includes the docs source and shared nginx configuration while excluding local dependencies and generated artifacts. -Configure logging for containers: +## Health Checks and Smoke Tests -```typescript -// Use JSON logging in production -import { createLogger } from '@unchainedshop/logger'; +Engine health checks POST `{ shopInfo { _id } }` to the configured `GRAPHQL_API_PATH` (default `/graphql`) on `PORT` (default `3000`). Both HTTP errors and GraphQL errors make the check fail. Static-site checks request the site's root page. -const logger = createLogger('app'); - -// Logs will be JSON formatted -logger.info('Server started', { port: 4010 }); -``` +The normal `npm test` command includes healthcheck regression tests. Run them separately without Docker: ```bash -# View container logs -docker logs -f my-shop - -# With timestamps -docker logs -f --timestamps my-shop +npm run test:run:docker ``` -## Best Practices - -### 1. Use Non-Root User +After building the static images, run their container smoke tests: -```dockerfile -RUN adduser -S unchained -USER unchained +```bash +node docker/smoke-static.mjs unchained-admin /products/ +node docker/smoke-static.mjs unchained-docs /concepts/architecture ``` -### 2. Pin Versions - -```dockerfile -FROM node:22.0.0-alpine3.19 -``` +The smoke tests wait for Docker health status, check root and nested HTML routes, load JavaScript assets, and verify that unknown paths return HTTP 404. They remove their temporary containers when finished. -### 3. Use .dockerignore +Inspect a running engine's health and logs with: +```bash +docker inspect --format '{{json .State.Health}}' CONTAINER +docker logs CONTAINER ``` -# .dockerignore -node_modules -.git -.env -*.log -tests -docs -``` - -### 4. Cache Dependencies -```dockerfile -# Copy package files first -COPY package*.json ./ -RUN npm ci +## Build Context and Dependencies -# Then copy source (changes don't invalidate npm cache) -COPY . . -``` - -### 5. Minimize Image Size +The root `.dockerignore` excludes local dependencies, workspace build output, local environment overrides, and `.context/`. Keep the tracked example defaults and the Admin UI's public defaults in the build context. -```dockerfile -FROM node:22-alpine # Alpine is smaller -RUN npm ci --only=production # No dev dependencies -``` +Engine builds install development dependencies for compilation, then prune them before copying the runtime dependencies and workspaces into the final image. Build and lint failures stop the image build. Admin UI uses the monorepo dependency tree only in its builder stage; documentation uses its own locked dependency tree. Their nginx runtime stages contain only static assets and server configuration. -## Related Documentation +## Related -- [Production Checklist](./production-checklist) - Pre-launch checklist -- [Environment Variables](../platform-configuration/environment-variables) - Configuration +- [Environment Variables](../platform-configuration/environment-variables.md) +- [Security](./security.md) +- [Admin UI](../admin-ui/overview.md) diff --git a/docs/docs/deployment/index.md b/docs/docs/deployment/index.md index 5d5c721bc3..0f8977004b 100644 --- a/docs/docs/deployment/index.md +++ b/docs/docs/deployment/index.md @@ -44,7 +44,7 @@ See [Docker Deployment](./docker) for details. ### Infrastructure -- **Node.js 22+** - Runtime environment +- **Node.js 26.8.2 or newer** - Runtime environment - **MongoDB 6+** - Primary database - **File Storage** - S3, MinIO, or GridFS for media - **Redis** (optional) - For distributed events and caching diff --git a/docs/docs/deployment/production-checklist.md b/docs/docs/deployment/production-checklist.md index 53fdbf0131..6ac090b310 100644 --- a/docs/docs/deployment/production-checklist.md +++ b/docs/docs/deployment/production-checklist.md @@ -283,7 +283,7 @@ EMAIL_PREVIEW=false # or just don't set it ```bash # Check Node.js version -node --version # Should be 22+ +node --version # Should be 26.8.2 or newer # Test MongoDB connection mongosh "$MONGO_URL" --eval "db.adminCommand('ping')" diff --git a/docs/docs/deployment/security.md b/docs/docs/deployment/security.md index ad899be559..1f4f567577 100644 --- a/docs/docs/deployment/security.md +++ b/docs/docs/deployment/security.md @@ -19,7 +19,7 @@ For detailed security documentation including compliance matrices, FIPS 140-3 co |----------|---------------|-----------------| | **PCI DSS SAQ-A** | Compatible | No card data storage; uses tokenization | | **ISO 27001** | Technical Controls | Access control, audit logging, cryptographic standards | -| **FIPS 140-3** | Algorithm Compatible | Uses FIPS-approved algorithms (PBKDF2, SHA-256/512, AES-256-GCM) | +| **FIPS 140-3** | Algorithm Compatible | Uses FIPS-approved algorithms (PBKDF2 and SHA-256/512) | | **SOC 2** | Audit Support | Tamper-evident audit logs for evidence collection | | **GDPR** | Technical Measures | Audit logging supports Article 30 requirements | @@ -32,7 +32,7 @@ Unchained uses PBKDF2 with industry-leading parameters: - **Algorithm**: PBKDF2 with SHA-512 - **Iterations**: 300,000 (exceeds OWASP recommendation of 210,000) - **Salt**: 16 bytes, cryptographically random -- **Key Length**: 256 bytes +- **Key Length**: 256 bits (32 bytes) - **Implementation**: Web Crypto API (`crypto.subtle`) ### Token Security @@ -42,11 +42,9 @@ Unchained uses PBKDF2 with industry-leading parameters: - **Expiration**: Time-limited (1 hour for verification tokens) - **Single Use**: Tokens invalidated after use -### Session Encryption +### Session Storage -- **Algorithm**: AES-256-GCM (authenticated encryption) -- **Key Size**: 32 bytes -- **Implementation**: kruptein library +Session data is stored in MongoDB. The configured session store does not initialize an encryption provider. `UNCHAINED_TOKEN_SECRET` signs the session cookie; it does not encrypt stored session data. ## Payment Security (PCI DSS) @@ -148,19 +146,19 @@ if (await timingSafeStringEqual(providedToken, expectedToken)) { ```typescript // Secure defaults -{ +const cookieOptions = { httpOnly: true, // Prevent XSS access secure: true, // HTTPS only sameSite: 'none', // Configurable - maxAge: 604800, // 7 days -} + maxAge: 604800000, // 7 days in milliseconds +}; ``` ### Environment Variables | Variable | Purpose | Default | |----------|---------|---------| -| `UNCHAINED_TOKEN_SECRET` | Session encryption (min 32 chars) | Required | +| `UNCHAINED_TOKEN_SECRET` | Session cookie signing secret (min 32 chars) | Required | | `UNCHAINED_COOKIE_NAME` | Cookie name | `unchained_token` | | `UNCHAINED_COOKIE_DOMAIN` | Cookie domain restriction | - | | `UNCHAINED_COOKIE_SAMESITE` | SameSite attribute | `none` | diff --git a/docs/docs/extend/catalog/filter.md b/docs/docs/extend/catalog/filter.md index 5d9a5b936c..096acf39c1 100644 --- a/docs/docs/extend/catalog/filter.md +++ b/docs/docs/extend/catalog/filter.md @@ -7,184 +7,64 @@ description: Customize filter and search # Custom Filter Plugins -Filter plugins are useful when you want to have a tailored filter functionality based on some requirements. you can have more than one FilterAdapter implementations and all of them will be executed sequentially based on there `orderIndex` index. Filter adapter with lower `orderIndex` will be first on the execution order and any modifications made on the previous Filter adapter will be available to filter that are executed after it. This is useful when you want to modularize your business logic. +Filter adapters transform search selectors, sorting, and matching IDs. `FilterDirector` runs them in ascending `orderIndex` order, passing previous results to subsequent adapters. Give every adapter a unique key. -When creating a filter make sure you don't use the same key for different filters. - -To implement a custom filter plugin you need to implement `IFilterAdapter` -and register it on the `FilterDirector` - - -Below is a simple filter plugin that will filter products based on there attribute values. +Compose your adapter from `FilterAdapter`, implement the actions you need, and register it with `FilterDirector`. The adapter, director, and their interfaces are exported by `@unchainedshop/core`. ```typescript - -import { IFilterAdapter, FilterAdapterActions, FilterContext } from '@unchainedshop/core-filters'; -import { Context } from '../../../context.ts'; +import { FilterAdapter, FilterDirector, type IFilterAdapter } from '@unchainedshop/core'; const ShopAttributeFilter: IFilterAdapter = { + ...FilterAdapter, key: 'ch.shop.filter', - label: 'Filters products by metadata attributes', + label: 'Filter products by custom metadata attributes', version: '1.0.0', orderIndex: 10, - actions: (params: FilterContext & Context): FilterAdapterActions => { + actions(params) { return { - aggregateProductIds(params: { productIds: Array }) { - return productIds; - }, - searchAssortments( - params: { - assortmentIds: Array; - }, - options?: { - filterSelector: Filter; - assortmentSelector: Filter; - sortStage: FindOptions['sort']; - }, - ) { - return assortmentIds; - }, - searchProducts( - params: { - productIds: Array; - }, - options?: { - filterSelector: Query; - productSelector: Query; - sortStage: FindOptions['sort']; - }, - ) { - return productIds; - }, - transformProductSelector( - query: Query, - options?: { key?: string; value?: any }, - ) { - return {...query, inStock: true}; - }, - async transformSortStage( - sort: FindOptions['sort'], - options?: { key: string; value?: any }, - ) { - return {...sort, created: -1 }; - }, - async transformFilterSelector( - query: Query, - options?: any, - ): Promise { - if (!last || Object.keys(last).length === 0) { - return null; - } - return last; - }, - - async transformProductSelector( - query: Query, - options?: { key?: string; value?: any }, - ): Promise { - if (!key) return last; + ...FilterAdapter.actions(params), + async transformProductSelector(query, { key, value } = {}) { + if (!key) return query; return { - status: 'ACTIVE', - 'shop.attributes': { - $elemMatch: { - key, - value: value !== undefined ? value : { $exists: true }, + $and: [ + query, + { + 'meta.attributes': { + $elemMatch: { + key, + value: value !== undefined ? value : { $exists: true }, + }, + }, }, - }, + ], }; }, }; }, }; - -``` - -### Breakdown -Lets look into each `field` & `function` defined by `IFilterAdapter` and what the function of each. -- `key`: Unique identification of adapter, identical to ID. -- `label`: Human readable label of the filter. -- `version` -- `orderIndex`: defines the execution order of a particular filter adapter. filter adapters lower value will be executed first - -- `transformProductSelector`: modifies selectors that are going to be used to filter products list. it gets filters that are added by filter adapters with lowe `orderIndex` as it's argument and is expected to return valid mongodb selector expression. -In the above example we are adding `status: 'ACTIVE'` selector if there is any filter configuration key provided on the filter, also expect the attribute value to match the configuration key: value. -Note: filters with higher `orderIndex` will get this value as there argument - default value: `{ status: { '$in': [ 'ACTIVE', null ] } }` -- `transformFilterSelector`: This transform fn allows you to customize the selector that returns the filters that should show up for a given search query. By default it uses the assortment's filter links to return those filters. Sometimes there is no assortment scope (global search for products) or you want to make a specific filter appear just everywhere. In those cases this can be helpful by returning additional filters through the selector. -- `transformSortStage`: Used to modify sort options that will to be applied for filter. default sort option value is `{ index: 1 }` which is for mongodb automatically assigned index value, but it can be changed to use any field in a collection. -in the example above we are adding a sort `{ created: -1 }` to the previous sort option and filter adapters with higher `orderIndex` will get this value as there parameter. - - default value: `{ _id: { '$in': [] }, isActive: true }` - -- `searchAssortments`: Triggered when searching for assortments, It is required to return array of assortment Ids that pass the filter checks or empty array if none is found. it gets `assortmentIds` that have been matched so far by filters with lower `orderIndex`. -- `searchProducts`: Triggered when searching for products, It is required to return array of product Ids that pass the filter checks or empty array if none is found. it gets `productIds` that have been matched so far by filters with lower `orderIndex`. -- `aggregateProductIds`: Executed when searching products, it holds array of the final matching productIds found so far. It is required to return array of product ids. - - - -### Order of execution - -1. `transformFilterSelector` -2. `transformProductSelector` if current operation is search product, else, skip to step 3 -3. `transformSortStage` -4. `searchProducts` or `searchAssortments` depending on the operation. i.e when searching for products or searching for assortments. -5. `aggregateProductIds` if current operation is search product - - - -### Final Step - -In order to make use of the filter we need to register it on the FilterDirector. - - -```typescript - -import { FilterDirector } from '@unchainedshop/core-filters'; -... FilterDirector.registerAdapter(ShopAttributeFilter); - ``` +This example assumes your application stores `{ key, value }` entries in `product.meta.attributes`. It preserves the incoming selector, including status and assortment restrictions. Import the adapter before starting the platform. -### Shorthand - -Incase you only want to change implementation of only few functions and keep the other default implementation, you can do so by importing `FilterAdapter` from `@unchainedshop/core-filters` and override the that specific functions implementation. - -Below is a simplified implementation of the `ShopAttributeFilter` above, this time it will use the default implantation and override `transformProductSelector` function only. - -```typescript -import { IFilterAdapter, FilterAdapterActions, FilterContext } from '@unchainedshop/core-filters'; -import { Context } from '../../../context.ts'; +## Available Actions -const ShopAttributeFilter: IFilterAdapter = { - ...FilterAdapter, - key: 'ch.shop.filter', - label: 'Filters products by metadata attributes', - version: '1.0.0', - orderIndex: 10, +| Action | Purpose | +|--------|---------| +| `transformFilterSelector(query, options)` | Changes which filters are available for a search | +| `transformProductSelector(query, options)` | Adds or changes product search restrictions | +| `transformSortStage(sort, options)` | Changes the requested MongoDB sort | +| `searchProducts({ productIds }, options)` | Resolves or narrows product IDs | +| `searchAssortments({ assortmentIds }, options)` | Resolves or narrows assortment IDs | +| `aggregateProductIds({ productIds })` | Combines the matched product IDs | - actions: (params: FilterContext & Context): FilterAdapterActions => { - return { - ...FilterAdapter.actions(params), - async transformProductSelector(query, options) { - if (!key) return last; - return { - status: 'ACTIVE', - 'shop.attributes': { - $elemMatch: { - key, - value: value !== undefined ? value : { $exists: true }, - }, - }, - }; - }, - } - } +Selector transformations and search actions return promises. `aggregateProductIds` returns an array synchronously. Search actions may return `undefined` when they do not supply an ID restriction; an empty array means there are no matches. -} +`actions` receives the filter and search query together with `modules`. Spread `FilterAdapter.actions(params)` to retain the default implementation for actions you do not override. -FilterDirector.registerAdapter(ShopAttributeFilter); +## Related -``` \ No newline at end of file +- [Search Behavior](./search-behavior.md) +- [Search and Filtering](../../guides/search-and-filtering.md) diff --git a/docs/docs/extend/custom-modules.md b/docs/docs/extend/custom-modules.md index 91b8c4d950..f9c814e434 100644 --- a/docs/docs/extend/custom-modules.md +++ b/docs/docs/extend/custom-modules.md @@ -13,24 +13,22 @@ Custom Modules enables the developer to add additional functionality to the core In many cases this goes together with [extending the API](./graphql) to include additional mutations and queries that access the module's functions. -Below is an example of a custom module that will be used to change currency of a cart. +Below is a low-level module example that updates an order's currency field. Application code must validate the cart and currency and recalculate its pricing before checkout. ```typescript -import { OrdersCollection, Order } from '@unchainedshop/core-orders' -import { generateDbFilterById } from '@unchainedshop/mongodb' -import { ModuleInput } from '@unchainedshop/core'; +import { OrdersCollection } from '@unchainedshop/core-orders' +import { generateDbFilterById, type ModuleInput } from '@unchainedshop/mongodb'; const myModule = { configure: async ({ db }: ModuleInput>) => { const Orders = await OrdersCollection(db) return { - async changeCartCurrency(currency: string, cartId: string) { + async changeCartCurrency(currencyCode: string, cartId: string) { const selector = generateDbFilterById(cartId) - Orders.updateOne(selector, { + await Orders.updateOne(selector, { $set: { currencyCode, - context: { currency }, }, }) @@ -74,21 +72,20 @@ Read more about unchained context and how to access it in **Accessing Unchained #### Custom Service -Services allow you to add utility functions that can be used throughout the engine context in a similar fashion to modules. The difference between a service and a module usually is that a module doesn't have direct DB access but composes multiple module calls through the unchained context. +Services allow you to add utility functions that can be used throughout the engine context in a similar fashion to modules. The difference between a service and a module is that a service does not access the database directly but composes multiple module calls through the unchained context. You can access built in or custom services from unchained context anywhere in the application like so: ```typescript -unchainedAPIContext.services.serviceName.[function name] +unchainedAPIContext.services.custom.findOrder(orderId) ``` It is possible to create a custom service for your need and have it available throughout the engine context like the built-in services. Custom services function are bound to the core modules and have access to those through this. ```typescript -import { Modules } from '@unchainedshop/core'; +import type { UnchainedCore } from '@unchainedshop/core'; -function serviceFunc(this: Modules, ...myParams: any) { - ... - this.orders.findOrder(...) +async function findOrder(this: UnchainedCore['modules'], orderId: string) { + return this.orders.findOrder({ orderId }); } ``` \ No newline at end of file diff --git a/docs/docs/extend/events.md b/docs/docs/extend/events.md index eb8d01eaf5..1f88fc4623 100644 --- a/docs/docs/extend/events.md +++ b/docs/docs/extend/events.md @@ -33,14 +33,11 @@ const allEvents = getRegisteredEvents(); ### Event Names -Events are registered as strings. You can query available events via GraphQL: +Events are registered as strings. Query registered names via GraphQL (subject to API permissions): ```graphql query { - events { - _id - type - } + registeredEventTypes } ``` @@ -72,8 +69,15 @@ Each module emits events for tracking and integration. See the module documentat ## Subscribing to Events +Register subscribers after the platform has registered its built-in events. The +examples below assume an application-provided `analytics` client. Subscriber +callbacks receive `{ payload }`; they do not receive the GraphQL context. +The emitter does not await asynchronous subscribers, so handle failures or enqueue +work when delivery and retry guarantees matter. + ```typescript import { subscribe } from '@unchainedshop/events'; +import { OrderPricingSheet } from '@unchainedshop/core'; // Track order confirmations subscribe('ORDER_CONFIRMED', async ({ payload }) => { @@ -82,13 +86,16 @@ subscribe('ORDER_CONFIRMED', async ({ payload }) => { // Send to analytics await analytics.track('purchase', { orderId: order._id, - total: order.total, + total: OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, + }).total({ useNetPrice: false }), }); }); -// Track product views -subscribe('PRODUCT_VIEW', async ({ payload }) => { - await analytics.track('product_view', { +// Track product changes +subscribe('PRODUCT_UPDATE', async ({ payload }) => { + await analytics.track('product_update', { productId: payload.productId, }); }); @@ -123,57 +130,40 @@ emit('INVENTORY_LOW', { ## Custom Event Adapter -Replace the default EventEmitter with a distributed queue like Redis: +An adapter implements `publish(eventName, { payload })` and +`subscribe(eventName, callback)`. For example, this adapter uses a local emitter: ```typescript -import { createClient } from '@redis/client'; -import { EmitAdapter, setEmitAdapter } from '@unchainedshop/events'; - -const { REDIS_PORT = 6379, REDIS_HOST = '127.0.0.1' } = process.env; - -const subscribedEvents = new Set(); - -const RedisEventEmitter = (): EmitAdapter => { - const redisPublisher = createClient({ - url: `redis://${REDIS_HOST}:${REDIS_PORT}`, - }); +import { EventEmitter } from 'node:events'; +import { setEmitAdapter, type EmitAdapter } from '@unchainedshop/events'; - const redisSubscriber = createClient({ - url: `redis://${REDIS_HOST}:${REDIS_PORT}`, - }); - - return { - publish: (eventName, payload) => { - redisPublisher.publish(eventName, JSON.stringify(payload)); - }, - subscribe: (eventName, callback) => { - if (!subscribedEvents.has(eventName)) { - redisSubscriber.subscribe(eventName, (payload) => { - callback(JSON.parse(payload)); - }); - subscribedEvents.add(eventName); - } - }, - }; +const emitter = new EventEmitter(); +const adapter: EmitAdapter = { + publish: (eventName, message) => { + emitter.emit(eventName, message); + }, + subscribe: (eventName, callback) => { + emitter.on(eventName, callback); + }, }; -// Set the adapter before starting the platform -setEmitAdapter(RedisEventEmitter()); +// Configure after importing presets and before starting the platform. +setEmitAdapter(adapter); ``` +For a remote transport, connect clients before use, support all subscribers, and +handle publish/subscribe errors and shutdown in your integration. The event +interface itself does not provide connection or unsubscribe hooks. See the +[Redis adapter](../plugins/events/events-redis.md) for the bundled adapter's current limitations. + ## Use Cases ### Analytics Integration -```typescript -subscribe('ORDER_CHECKOUT', async ({ payload }) => { - await gtag('event', 'purchase', { - transaction_id: payload.order._id, - value: payload.order.total / 100, - currency: payload.order.currency, - }); -}); -``` +Build totals with `OrderPricingSheet` as in the subscription example above. +The result contains `amount` and `currencyCode`. Convert minor units according +to the currency's precision if the analytics destination expects major units; +do not assume that every currency has two decimal places. ### Webhook Triggers @@ -189,20 +179,12 @@ subscribe('ORDER_CONFIRMED', async ({ payload }) => { ### Inventory Alerts -```typescript -subscribe('ORDER_ADD_PRODUCT', async ({ payload, context }) => { - const product = await context.modules.products.findProduct({ - productId: payload.orderPosition.productId, - }); - - if (product.stock < 10) { - emit('INVENTORY_LOW', { - productId: product._id, - currentStock: product.stock, - }); - } -}); -``` +`ORDER_ADD_PRODUCT` includes `payload.orderPosition`. Capture the initialized +platform's `modules` and `services` in your subscriber closure, load the product +with `modules.products.findProduct({ productId })`, and inspect the per-provider +quantities from `services.products.simulateProductInventory({ product })`. +Product records do not have a `stock` field. Emit your registered `INVENTORY_LOW` +event when your application's threshold and warehouse-selection rules require it. ### Audit Logging @@ -220,7 +202,7 @@ const auditEvents = [ auditEvents.forEach(eventName => { subscribe(eventName, async ({ payload }) => { - await db.auditLog.insertOne({ + await db.collection('audit_log').insertOne({ event: eventName, payload, timestamp: new Date(), @@ -231,14 +213,11 @@ auditEvents.forEach(eventName => { ## Querying Registered Events -Use GraphQL to list all registered events: +Use `registeredEventTypes` to list registered names. The separate `events` query returns persisted event history, not the registration list: ```graphql query { - events { - _id - type - } + registeredEventTypes } ``` @@ -253,7 +232,7 @@ Unchained provides enterprise-grade audit logging based on the **OCSF (Open Cybe - **Append-only** - No update or delete operations - **JSON Lines format** - Easy parsing and integration - **SIEM-ready** - Direct ingestion into security monitoring tools -- **HTTP push** - Optional push to OpenTelemetry Collector, Fluentd, or Vector +- **HTTP push** - Optional JSON batches to a receiver accepting `{ events: [...] }` ### Quick Start @@ -263,7 +242,7 @@ import { createAuditLog, configureAuditIntegration } from '@unchainedshop/events // Create audit log instance const auditLog = createAuditLog('./audit-logs'); -// Enable automatic event capture for all security-relevant events +// Enable capture for the built-in AUDITED_EVENTS after platform initialization configureAuditIntegration(auditLog); // Events automatically captured: @@ -297,7 +276,7 @@ const auditLog = createAuditLog('./audit-logs'); await auditLog.logAuthentication({ activity: OCSF_AUTH_ACTIVITY.LOGON, userId: user._id, - userName: user.email, + userName: user.emails?.[0]?.address, success: true, remoteAddress: req.ip, sessionId: req.sessionID, @@ -341,12 +320,12 @@ await auditLog.logApiActivity({ ### HTTP Collector Push -Push audit logs to OpenTelemetry Collector, Fluentd, or Vector: +Push audit logs to an HTTP receiver that accepts `application/json` with an `events` array. An OTLP receiver needs a translation step; this payload is not OTLP: ```typescript const auditLog = createAuditLog({ directory: './audit-logs', - collectorUrl: 'http://otel-collector:4318/v1/logs', + collectorUrl: 'http://audit-collector:8080/events', collectorHeaders: { 'Authorization': 'Bearer ', }, @@ -415,7 +394,7 @@ scrape_configs: ### Shutdown -Always close the audit log on shutdown to flush pending events: +Stop event producers before closing the audit log on shutdown to flush pending events. `configureAuditIntegration` does not return an unsubscribe function: ```typescript process.on('SIGTERM', async () => { diff --git a/docs/docs/extend/order-fulfilment/fulfilment-plugins/delivery.md b/docs/docs/extend/order-fulfilment/fulfilment-plugins/delivery.md index e1d8647ca2..c905e98f2c 100644 --- a/docs/docs/extend/order-fulfilment/fulfilment-plugins/delivery.md +++ b/docs/docs/extend/order-fulfilment/fulfilment-plugins/delivery.md @@ -7,95 +7,68 @@ description: Customize delivery # Delivery Provider Plugins -In order to register available delivery options, you either have to use the builtin ones or have to add a plugin to the supported delivery provider by implementing the `IDeliveryAdapter` interface and registering the adapter on the global `DeliveryDirector` +A delivery provider selects an adapter through its `adapterKey`. Implement `IDeliveryAdapter` and register it with `DeliveryDirector`, both exported by `@unchainedshop/core`. -There can be multiple delivery adapter implementation for a shop and all of them will be executed based on their `orderIndex` value. Delivery adapters with lowe `orderIndex` are executed first. - -Below we have sample delivery adapter +## Pickup Adapter ```typescript -import { - DeliveryDirector, - DeliveryProviderType, -} from "@unchainedshop/core-delivery"; - +import { DeliveryAdapter, DeliveryDirector, type IDeliveryAdapter } from '@unchainedshop/core'; +import { DeliveryProviderType, type DeliveryLocation } from '@unchainedshop/core-delivery'; + +const locations: DeliveryLocation[] = [{ + _id: 'zurich-store', + name: 'Zurich store', + address: { + addressLine: 'Example Street 1', + postalCode: '8000', + countryCode: 'CH', + city: 'Zurich', + }, + geoPoint: { latitude: 47.3769, longitude: 8.5417 }, +}]; -const ShopPickUp: IDeliveryAdapter = { - key: 'ch.shop.delivery.pickup', - label: 'Pickup at Clerk', +const ShopPickup: IDeliveryAdapter = { + ...DeliveryAdapter, + key: 'my-shop.delivery.pickup', + label: 'Pickup at the store', version: '1.0.0', - orderIndex: 1 - initialConfiguration: (DeliveryConfiguration = []), + initialConfiguration: [], - typeSupported: (type: DeliveryProviderType): boolean => { - return type === DeliveryProviderType.PICKUP; - }, + typeSupported: (type) => type === DeliveryProviderType.PICKUP, - actions: (config: DeliveryConfiguration, context: DeliveryAdapterContext, unchainedAPI: UnchainedCore): DeliveryAdapterActions => { + actions(config, context) { return { - isAutoReleaseAllowed(): boolean { - return false; - }, - - isActive(): boolean { - return true; - }, + ...DeliveryAdapter.actions(config, context), + isActive: () => true, + configurationError: () => null, + isAutoReleaseAllowed: () => false, + pickUpLocations: async () => locations, + pickUpLocationById: async (locationId) => + locations.find(({ _id }) => _id === locationId) || null, + estimatedDeliveryThroughput: async () => 0, + send: async () => true, + }; + }, +}; - configurationError(transactionContext?: any) { - return null; - }, +DeliveryDirector.registerAdapter(ShopPickup); +``` - pickUpLocationById(locationId: string): Promise { - return this.pickUpLocations().filter(({ _id }) => _id === locationId); - }, +This adapter requires manual order confirmation before automatic checkout fulfilment. Its `send` action marks delivery complete when invoked; call it when the pickup is ready to be recorded as delivered, or implement your own fulfilment workflow. Import the adapter before startup and select it when creating a pickup provider. - estimatedDeliveryThroughput: (warehousingThroughputTime: number) : Promise => { - return 0; - }, +## Actions - pickUpLocations(): Promise> { - return [ - { - _id: 'first-location-id', - name: 'first-location', - address: { - addressLine: 'address-line', - postalCode: '1234', - countryCode: 'CH', - city: 'Zurich', - }, - geoPoint: { - latitude: 123456789, - longitude: 987654321, - }, - }, - ]; - }, - send: async (): Promise => { - const { modules, order } = context as typeof context; - await modules.worker.addWork( - { - type: 'MARK_ORDER_DELIVERED', - retries: 0, - scheduled: new Date(new Date().getTime() + 1000 * (24 * 60 * 60)), - input: { - orderDeliveryId: order.deliveryId, - }, - }, - ); +| Action | Purpose | +|--------|---------| +| `typeSupported(type)` | Selects supported provider types | +| `configurationError(transactionContext)` | Returns an adapter error or `null` | +| `isActive()` | Indicates whether the provider is usable | +| `isAutoReleaseAllowed()` | Allows checkout to release the order without manual confirmation | +| `estimatedDeliveryThroughput(warehousingThroughputTime)` | Returns estimated delivery time in milliseconds | +| `pickUpLocations()` | Returns pickup locations | +| `pickUpLocationById(locationId)` | Returns one location or `null` | +| `send()` | Performs fulfilment and indicates whether delivery completed | - return false; - }, - }; - }, -}; -``` +`send` may return a boolean or a work record. A truthy result marks the delivery as delivered; `false` leaves it open. Throwing propagates an error to the caller; it does not automatically cancel the order. -- **typeSupported(type: DeliveryProviderType)**: Defines which type of delivery providers this adapter support. -- **configurationError(transactionContext: any): DeliveryError**: returns any issue found with the delivery adapter configuration. its passed current transaction object that lets you check if everything is working for proper functioning of the adapter. -- **estimatedDeliveryThroughput(warehousingThroughputTime: number)**: Used to send an estimation delivery time of the adapter. -- **isActive**: Used to enable or disable the adapter. -- **isAutoReleaseAllowed**: Determined if the delivery provider should change status automatically or if manual confirmation of delivery is required. -- **pickUpLocationById(locationId: string): DeliveryLocation**: returns a delivery location with the specified ID from the list of locations returned from `pickUpLocations`. -- **pickUpLocations: DeliveryLocation[]** returns list of delivery locations available with a particular delivery adapter -- **send: any**: Determines the if an order is delivered or not. if this function returns a trueish value the order delivery status will be changed to **DELIVERED**, if it returns false order delivery status stays the same (PENDING) but the order status can be changed but if it throws an error the order will be canceled. \ No newline at end of file +The second `actions` argument includes the order, delivery record, provider, transaction context, and module APIs when available. There is no third `unchainedAPI` argument. diff --git a/docs/docs/extend/order-fulfilment/fulfilment-plugins/payment.md b/docs/docs/extend/order-fulfilment/fulfilment-plugins/payment.md index bde3d937d2..f3649cab00 100644 --- a/docs/docs/extend/order-fulfilment/fulfilment-plugins/payment.md +++ b/docs/docs/extend/order-fulfilment/fulfilment-plugins/payment.md @@ -6,363 +6,144 @@ title: Write a Payment Provider Plugin # Payment Provider Plugins -Payment adapters handle payment processing for orders. Unchained supports multiple payment types (`CARD`, `INVOICE`, `GENERIC`) and you can implement custom adapters for any payment gateway. - -For an overview of how payment fits into the order lifecycle, see [Order Lifecycle](../../../concepts/order-lifecycle). +Payment adapters process order payments and expose provider-specific signing, +registration, and validation operations. See [Order Lifecycle](../../../concepts/order-lifecycle) +for how payment and delivery determine order status. ## Payment Types -| Type | Description | Use Cases | -|------|-------------|-----------| -| `CARD` | Credit/debit card payments | Stripe, PayPal, Braintree | -| `INVOICE` | Invoice-based payments | Pre-paid or post-paid invoices | -| `GENERIC` | Other payment methods | Crypto, bank transfer, cash | +| Type | Description | Examples | +|------|-------------|----------| +| `INVOICE` | Invoice-based payments | Pre-paid and post-paid invoices | +| `GENERIC` | Payments with provider-specific context | Stripe, PayPal, Braintree, cryptocurrency | + +There is no `CARD` provider type. Card integrations use `GENERIC`. ## Creating a Payment Adapter -Implement the `IPaymentAdapter` interface and register it with the `PaymentDirector`. +Implement `IPaymentAdapter`, inherit the base defaults, and register the object +with `PaymentDirector`. The factory receives two arguments: +`actions(configuration, context)`. The context contains `modules` and +`paymentProvider`, plus optional `order`, `orderPayment`, `userId`, and transaction +data. Configuration checks can run without an order, so guard order-specific access. ### Example: Pre-Paid Invoice -This example shows a pre-paid invoice provider that blocks order confirmation until payment is received: +This adapter leaves the payment unpaid during checkout and disables automatic +confirmation before payment. It follows the built-in invoice-prepaid adapter: ```typescript -import { - PaymentDirector, - type IPaymentAdapter, - type PaymentChargeActionResult, -} from '@unchainedshop/core'; +import { PaymentAdapter, PaymentDirector, type IPaymentAdapter } from '@unchainedshop/core'; +import { PaymentProviderType } from '@unchainedshop/core-payment'; const PrePaidInvoice: IPaymentAdapter = { + ...PaymentAdapter, key: 'shop.example.payment.prepaid-invoice', label: 'Pre-Paid Invoice', version: '1.0.0', - - // Initial configuration (optional) initialConfiguration: [], - // Which payment types this adapter supports - typeSupported(type) { - return type === 'INVOICE'; - }, - - actions(params) { - const { context, paymentContext } = params; - const { order } = paymentContext; - const { modules } = context; - - return { - // Return configuration errors (e.g., missing API keys) - configurationError() { - return null; - }, - - // Is this adapter active for the current context? - isActive() { - return true; - }, - - // Can the order be confirmed before payment? - // false = payment must complete first (pre-paid) - // true = order can proceed without payment (post-paid) - isPayLaterAllowed() { - return false; - }, - - // Process payment charge - async charge(): Promise { - // For pre-paid invoice: - // - Return false: payment not yet received, stay in PENDING - // - Return { transactionId }: payment received, proceed - // - Throw error: abort checkout entirely - return false; - }, - - // Register a payment method (e.g., save card for future use) - async register() { - return { token: '' }; - }, - - // Sign a payment request (e.g., for client-side SDK initialization) - async sign() { - return ''; - }, - - // Validate a payment token - async validate(token) { - return true; - }, - - // Cancel/refund payment - async cancel() { - return true; - }, - - // Confirm a previously authorized payment - async confirm() { - return { transactionId: '' }; - }, - }; - }, + typeSupported: (type) => type === PaymentProviderType.INVOICE, + + actions: (configuration, context) => ({ + ...PaymentAdapter.actions(configuration, context), + configurationError: () => null, + isActive: () => true, + isPayLaterAllowed: () => false, + charge: async () => false, + }), }; -// Register the adapter PaymentDirector.registerAdapter(PrePaidInvoice); ``` -## Adapter Methods Reference - -### `typeSupported(type)` - -Determines which payment types this adapter handles. - -```typescript -typeSupported(type) { - return type === 'CARD'; -} -``` - -### `configurationError()` - -Return any configuration errors. Called when validating the provider setup. - -```typescript -configurationError() { - if (!process.env.PAYMENT_API_KEY) { - return { code: 'MISSING_API_KEY', message: 'Payment API key is required' }; - } - return null; -} -``` - -### `isActive()` - -Determines if the adapter is active for the current transaction context. - -```typescript -isActive() { - // Disable for specific countries - const { order } = this.paymentContext; - return order.countryCode !== 'BLOCKED_COUNTRY'; -} -``` - -### `isPayLaterAllowed()` +Use a provider integration or authorized administrative workflow to record the +later payment. Returning `false` from `charge` does not schedule a payment retry. -Controls whether order confirmation can proceed before payment completes. +## Action Contracts -| Return Value | Behavior | -|--------------|----------| -| `true` | Order can be confirmed without payment (post-paid) | -| `false` | Payment must complete before order confirmation (pre-paid) | +| Action | Return value | Purpose | +|--------|--------------|---------| +| `configurationError(transactionContext?)` | `PaymentError` or `null` | Report a configuration problem using the exported error constants | +| `isActive(transactionContext?)` | `boolean` | Whether the adapter can be used in the current context | +| `isPayLaterAllowed(transactionContext?)` | `boolean` | Whether automatic confirmation may proceed while payment is unpaid | +| `charge(transactionContext?)` | Promise of a result object or `false` | Attempt payment; the director marks an object result as paid | +| `register(transactionContext?)` | Promise of provider-specific data | Register reusable payment credentials | +| `sign(transactionContext?)` | Promise of a string or `null` | Generate client initialization data | +| `validate(token?)` | Promise of a boolean | Validate provider-specific credential data | +| `cancel(transactionContext?)` | Promise of a boolean | Cancel a payment through the provider | +| `confirm(transactionContext?)` | Promise of a boolean | Confirm a payment through the provider | -```typescript -isPayLaterAllowed() { - // Post-paid invoice: allow order to proceed - return true; -} -``` +`PaymentError.INCOMPLETE_CONFIGURATION`, for example, is a string constant, +not an object with `code` and `message`. A charge result may contain a +`transactionId`, provider data, and optional `credentials`. The director stores +`transactionId` on the order payment and records other response data in its status log. +`cancel` and `confirm` return booleans, not charge-result objects. Implement their +provider-specific behavior; the base defaults return `false`. -### `charge()` +The director passes transaction data as the action argument. It is not a +`this.paymentContext` field. The optional `order` and `orderPayment` are available +from the factory's context closure. -Process the payment charge. This is called during checkout. +### Charge Outcomes -| Return Value | Behavior | -|--------------|----------| -| `{ transactionId }` | Payment successful, proceed with checkout | -| `false` | Payment not complete yet, order stays in PENDING | -| Throws error | Abort checkout, order stays in OPEN (cart) | +| Outcome | Effect | +|---------|--------| +| Result object | The payment is marked paid; order processing continues | +| `false` | The payment status is unchanged; order status depends on payment and delivery rules | +| Thrown error | The current operation rejects; already completed external effects are not automatically rolled back | -```typescript -async charge() { - try { - const result = await paymentGateway.charge({ - amount: order.pricing().total().amount, - currency: order.currency, - }); - return { transactionId: result.id }; - } catch (error) { - // Throw to abort checkout - throw new Error('Payment failed: ' + error.message); - } -} -``` +An unpaid pre-paid order normally remains `PENDING`. Allowing payment later does +not alone guarantee confirmation: delivery must also permit automatic release. -### `register()` +## Reading the Order Total -Register a payment method for future use (e.g., save a credit card). +Orders are plain data records. Build an `OrderPricingSheet` from their calculation +and `currencyCode` to read the total: ```typescript -async register() { - const token = await paymentGateway.createCustomer(user); - return { token }; -} -``` - -### `sign()` +import { OrderPricingSheet } from '@unchainedshop/core'; +import type { Order } from '@unchainedshop/core-orders'; -Sign a payment request for client-side SDK initialization. - -```typescript -async sign() { - // Create a client token for Stripe Elements, PayPal buttons, etc. - const clientSecret = await paymentGateway.createPaymentIntent({ - amount: order.pricing().total().amount, +function paymentAmount(order: Order) { + const pricing = OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, }); - return clientSecret; -} -``` - -### `validate(token)` - -Validate a payment token. - -```typescript -async validate(token) { - const isValid = await paymentGateway.validateToken(token); - return isValid; + return pricing.total({ useNetPrice: false }); } ``` -### `cancel()` +Amounts use the currency's minor units. Apply the gateway's currency and amount +formatting at the provider boundary. -Cancel or refund a payment. Called when an order is rejected. +## Stripe and Webhooks -```typescript -async cancel() { - const { orderPayment } = this.paymentContext; - if (orderPayment.transactionId) { - await paymentGateway.refund(orderPayment.transactionId); - } - return true; -} -``` - -### `confirm()` - -Confirm a previously authorized payment. Called when order transitions to CONFIRMED. +To use the bundled Stripe adapter, import it and configure a `GENERIC` provider +with adapter key `shop.unchained.payment.stripe`: ```typescript -async confirm() { - const { orderPayment } = this.paymentContext; - const result = await paymentGateway.capturePayment(orderPayment.transactionId); - return { transactionId: result.id }; -} +import '@unchainedshop/plugins/payment/stripe/index.js'; ``` -## Webhook Integration +Set `STRIPE_SECRET` for API calls and `STRIPE_ENDPOINT_SECRET` for webhook +verification. The default Express/Fastify plugin middleware presets mount the +Stripe webhook route. Follow the [Stripe plugin guide](../../../plugins/payment/stripe) +for setup and the supported client flow. -Most payment gateways require webhooks for async payment confirmations. Create an endpoint to handle these: - -```typescript -import express from 'express'; +The bundled webhook handlers verify the signature against the raw request body, +resolve the payment using `orderPaymentId` metadata, and pass the payment intent +to `services.orders.checkoutOrder(orderPayment.orderId, { + paymentContext: { paymentIntentId } +})`. This is a core service, not `modules.orders.checkout`. The adapter verifies +the intent's amount, currency, and payment association before accepting it. -const app = express(); - -app.post('/webhooks/payment', async (req, res) => { - const event = req.body; - - if (event.type === 'payment_intent.succeeded') { - const { orderId } = event.data.metadata; - - // Confirm the order - await modules.orders.checkout(orderId, { - transactionId: event.data.id, - }); - } - - res.json({ received: true }); -}); -``` - -## Example: Card Payment with Stripe - -```typescript -import Stripe from 'stripe'; -import { PaymentDirector, type IPaymentAdapter } from '@unchainedshop/core'; - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - -const StripePayment: IPaymentAdapter = { - key: 'shop.example.payment.stripe', - label: 'Stripe Card Payment', - version: '1.0.0', - - typeSupported(type) { - return type === 'CARD'; - }, - - actions(params) { - const { paymentContext } = params; - const { order, orderPayment } = paymentContext; - - return { - configurationError() { - if (!process.env.STRIPE_SECRET_KEY) { - return { code: 'STRIPE_KEY_MISSING' }; - } - return null; - }, - - isActive() { - return true; - }, - - isPayLaterAllowed() { - return false; - }, - - async sign() { - const paymentIntent = await stripe.paymentIntents.create({ - amount: order.pricing().total().amount, - currency: order.currency.toLowerCase(), - metadata: { orderId: order._id }, - }); - return paymentIntent.client_secret; - }, - - async charge() { - // Payment is confirmed via webhook - if (orderPayment.context?.paymentIntentId) { - const intent = await stripe.paymentIntents.retrieve( - orderPayment.context.paymentIntentId - ); - if (intent.status === 'succeeded') { - return { transactionId: intent.id }; - } - } - return false; - }, - - async cancel() { - if (orderPayment.transactionId) { - await stripe.refunds.create({ - payment_intent: orderPayment.transactionId, - }); - } - return true; - }, - - async confirm() { - return { transactionId: orderPayment.transactionId }; - }, - - async register() { - return { token: '' }; - }, - - async validate() { - return true; - }, - }; - }, -}; - -PaymentDirector.registerAdapter(StripePayment); -``` +For a custom gateway, implement its verification and payment-state handling in +your route. Preserve the raw body when required for signature verification and +make repeated webhook delivery safe for the operations you invoke. ## Related -- [Director/Adapter Pattern](../../../concepts/director-adapter-pattern) - Understanding the plugin architecture -- [Order Lifecycle](../../../concepts/order-lifecycle) - How payment fits into checkout -- [Stripe Plugin](../../../plugins/payment/stripe) - Stripe payment adapter +- [Director/Adapter Pattern](../../../concepts/director-adapter-pattern) +- [Order Lifecycle](../../../concepts/order-lifecycle) +- [Stripe Plugin](../../../plugins/payment/stripe) diff --git a/docs/docs/extend/order-fulfilment/fulfilment-plugins/warehousing.md b/docs/docs/extend/order-fulfilment/fulfilment-plugins/warehousing.md index 6fe5883ae7..a4874fa5ff 100644 --- a/docs/docs/extend/order-fulfilment/fulfilment-plugins/warehousing.md +++ b/docs/docs/extend/order-fulfilment/fulfilment-plugins/warehousing.md @@ -7,82 +7,53 @@ description: Customize warehousing # Warehousing Provider Plugins -## WarehousingAdapter +Warehousing adapters provide stock and lead-time information for configured warehousing providers. Import adapters and directors from `@unchainedshop/core`, and provider data types from `@unchainedshop/core-warehousing`. -You can define a custom Warehousing adapter to simulate the stock availability. In order to define a warehousing adapter you should implement the -`IWarehousingAdapter` and register it to the global warehousing director that implements the `IWarehousingDirector` interface. - -A store can have multiple Warehousing adapters configured and all of them are executed ordered by there `orderIndex` value. Warehousing adapters with lower `orderIndex` are executed first. - -Below is a simple warehousing adapter implementation that will always show a stock is always available for all products. +## Creating an Adapter ```typescript +import { WarehousingAdapter, WarehousingDirector, type IWarehousingAdapter } from '@unchainedshop/core'; +import { WarehousingProviderType } from '@unchainedshop/core-warehousing'; -import { WarehousingAdapter, WarehousingProviderType } from '@unchainedshop/core-warehousing'; -import { - IWarehousingAdapter, - WarehousingError, - WarehousingAdapterActions, - WarehousingContext, - WarehousingProviderType, -} from '@unchainedshop/core-warehousing'; -import { Context } from '../../../context.ts'; - -const Store: IWarehousingAdapter = { - key: 'shop.unchained.warehousing.store', +const AlwaysAvailable: IWarehousingAdapter = { + ...WarehousingAdapter, + key: 'my-shop.warehousing.always-available', version: '1.0.0', - label: 'Store', + label: 'Always available', orderIndex: 0, - initialConfiguration = [{ key: 'name', value: 'Flagship Store' }], + initialConfiguration: [{ key: 'name', value: 'Flagship Store' }], - typeSupported: (type: WarehousingProviderType): boolean => { - return type === WarehousingProviderType.PHYSICAL; - }, + typeSupported: (type) => type === WarehousingProviderType.PHYSICAL, - actions: ( - config: WarehousingConfiguration, - context: WarehousingContext & Context, - ): WarehousingAdapterActions => { + actions(config, context) { return { - isActive: async (): boolean => { - return true; - }, - - configurationError: async () => { - return null; - }, - - stock: async (referenceDate: Date): Promise => { - return 99999; - }, - - productionTime: async (quantityToProduce: number): Promise => { - return 0; - }, - - commissioningTime: async (quantity: number): Promise => { - return 0; - }, + ...WarehousingAdapter.actions(config, context), + isActive: () => true, + configurationError: () => null, + stock: async () => 99999, + productionTime: async () => 0, + commissioningTime: async () => 0, }; }, }; - +WarehousingDirector.registerAdapter(AlwaysAvailable); ``` -- **typeSupported(type: WarehousingProviderType)**: Defines the warehousing provider type an adapter is valid for. -- **isActive**: Defines if the adapter is valid or not based any conditions you set. -- **configurationError(): WarehousingError**: Any error that occurred during the initialization of an adapter. it can be a missing env or any value missing for a proper functioning of the adapter. -- **stock(referenceDate: Date)**: It should return the available stock of a product for the provided reference date. in the example above we are simply returning `99999` as stock count. -- **productionTime(quantityToProduct: number)**: Returns an estimate to produce number of product passed as an argument. -- **commissioningTime(quantity: number)**: number of days required to product a quantity passed as an argument +The example simulates availability; it does not track or decrement physical stock. Import it before startup and select its adapter key when creating a warehousing provider. +## Actions +| Action | Purpose | +|--------|---------| +| `typeSupported(type)` | Selects supported provider types | +| `isActive()` | Indicates whether this configured provider is usable | +| `configurationError()` | Returns a configuration error or `null` | +| `stock(referenceDate)` | Returns available quantity for the requested date | +| `productionTime(quantityToProduce)` | Estimates production time in milliseconds | +| `commissioningTime(quantity)` | Estimates preparation time in milliseconds | +| `tokenize()` | Creates token surrogates for tokenized products | +| `tokenMetadata(serialNumber, referenceDate)` | Resolves token metadata | +| `isInvalidateable(serialNumber, referenceDate)` | Controls token invalidation | -## Register warehousing adapter - -```typescript -import { WarehousingDirector } from '@unchainedshop/core-warehousing'; - -WarehousingDirector.registerAdapter(Store); -``` \ No newline at end of file +`isActive` and `configurationError` are synchronous; stock, timing, and token actions return promises. Spread the base actions to retain defaults for methods you do not override. diff --git a/docs/docs/extend/pricing/delivery-pricing.md b/docs/docs/extend/pricing/delivery-pricing.md index 7cab83f3a8..dc26c3eb63 100644 --- a/docs/docs/extend/pricing/delivery-pricing.md +++ b/docs/docs/extend/pricing/delivery-pricing.md @@ -7,213 +7,85 @@ description: Custom delivery pricing adapters # Delivery Pricing -Delivery pricing adapters calculate shipping and handling fees based on order contents, delivery method, and destination. +Delivery pricing adapters calculate shipping and handling fees. Compose an object from `DeliveryPricingAdapter` and register it with `DeliveryPricingDirector`, both exported by `@unchainedshop/core`. -For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). - -## Creating an Adapter - -Extend `DeliveryPricingAdapter` and register it with `DeliveryPricingDirector`: +## Weight-Based Shipping ```typescript import { DeliveryPricingAdapter, DeliveryPricingDirector, -} from '@unchainedshop/core-pricing'; - -class MyDeliveryPricing extends DeliveryPricingAdapter { - static key = 'my-shop.pricing.delivery'; - static version = '1.0.0'; - static label = 'Custom Delivery Pricing'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'SHIPPING'; - } - - async calculate() { - this.result.addItem({ - amount: 800, // 8.00 flat rate - isTaxable: true, - isNetPrice: true, - category: 'DELIVERY', - meta: { adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} - -DeliveryPricingDirector.registerAdapter(MyDeliveryPricing); -``` - -## Examples - -### Weight-Based Shipping - -```typescript -class WeightBasedShipping extends DeliveryPricingAdapter { - static key = 'my-shop.pricing.weight-shipping'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'SHIPPING'; - } - - async calculate() { - const { order, modules } = this.context; - - const items = await modules.orders.positions.findOrderPositions({ - orderId: order._id, - }); - - // Calculate total weight - let totalWeight = 0; - for (const item of items) { - const product = await modules.products.findProduct({ productId: item.productId }); - totalWeight += (product?.warehousing?.weight || 0) * item.quantity; - } - - // Price: base + per kg - const basePrice = 500; // 5.00 base - const pricePerKg = 200; // 2.00 per kg - - this.result.addItem({ - amount: basePrice + Math.round(totalWeight * pricePerKg), - isTaxable: true, - isNetPrice: true, - category: 'DELIVERY', - meta: { weight: totalWeight, adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} -``` - -### Zone-Based Pricing - -```typescript -class ZoneBasedShipping extends DeliveryPricingAdapter { - static key = 'my-shop.pricing.zone-shipping'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'SHIPPING'; - } - - async calculate() { - const { order } = this.context; - const countryCode = order.delivery?.address?.countryCode; - - const zoneRates = { - CH: 800, // 8.00 domestic - DE: 1500, // 15.00 EU neighbor - AT: 1500, - FR: 1500, - IT: 1500, - default: 2500, // 25.00 international - }; - - const amount = zoneRates[countryCode] || zoneRates.default; - - this.result.addItem({ - amount, - isTaxable: true, - isNetPrice: true, - category: 'DELIVERY', - meta: { zone: countryCode, adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} -``` - -### Free Shipping Threshold - -```typescript -class FreeShippingThreshold extends DeliveryPricingAdapter { - static key = 'my-shop.pricing.free-shipping'; - static orderIndex = 10; // After base shipping - - async calculate() { - const { order, modules } = this.context; - const threshold = 10000; // Free shipping over 100.00 - - // Calculate product total - const items = await modules.orders.positions.findOrderPositions({ - orderId: order._id, - }); - const productTotal = items.reduce((sum, item) => { - return sum + (item.calculation?.find(c => c.category === 'BASE')?.amount || 0); - }, 0); - - if (productTotal >= threshold) { - const deliveryTotal = this.calculation.sum({ category: 'DELIVERY' }); - - if (deliveryTotal > 0) { - this.result.addItem({ - amount: -deliveryTotal, + type IDeliveryPricingAdapter, +} from '@unchainedshop/core'; +import { DeliveryProviderType } from '@unchainedshop/core-delivery'; + +const WeightBasedShipping: IDeliveryPricingAdapter = { + ...DeliveryPricingAdapter, + key: 'my-shop.pricing.weight-shipping', + version: '1.0.0', + label: 'Weight-based shipping', + orderIndex: 10, + + isActivatedFor: ({ provider, currencyCode }) => + provider.type === DeliveryProviderType.SHIPPING && currencyCode === 'CHF', + + actions(params) { + const pricingAdapter = DeliveryPricingAdapter.actions(params); + return { + ...pricingAdapter, + async calculate() { + const { order, modules } = params.context; + if (!order) return pricingAdapter.calculate(); + + const positions = await modules.orders.positions.findOrderPositions({ + orderId: order._id, + }); + let totalWeightGrams = 0; + for (const position of positions) { + const product = await modules.products.findProduct({ productId: position.productId }); + totalWeightGrams += (product?.supply?.weightInGram || 0) * position.quantity; + } + + pricingAdapter.resultSheet().addFee({ + amount: 500 + Math.round((totalWeightGrams / 1000) * 200), isTaxable: true, isNetPrice: true, - category: 'DISCOUNT', - meta: { type: 'free-shipping', threshold, adapter: this.constructor.key }, + meta: { adapter: WeightBasedShipping.key, totalWeightGrams }, }); - } - } + return pricingAdapter.calculate(); + }, + }; + }, +}; - return super.calculate(); - } -} +DeliveryPricingDirector.registerAdapter(WeightBasedShipping); ``` -### Express Shipping Option +The example adds CHF 5.00 plus CHF 2.00 per kilogram. `addFee` assigns the `DELIVERY` category automatically. Position quantities and weights come from the module APIs; order records do not contain an `items` array. -```typescript -class ExpressShipping extends DeliveryPricingAdapter { - static key = 'my-shop.pricing.express'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - // Only for express delivery provider - return provider.adapterKey === 'my-shop.delivery.express'; - } - - async calculate() { - const { order } = this.context; - - // Express: 2x standard rate - const standardRate = 800; - const expressMultiplier = 2; - - this.result.addItem({ - amount: standardRate * expressMultiplier, - isTaxable: true, - isNetPrice: true, - category: 'DELIVERY', - meta: { type: 'express', adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} -``` +## Other Shipping Rules -## Context Properties +- **Zones:** Read the delivery address from `params.context.orderDelivery?.context?.address` during checkout, or `params.context.providerContext` for provider simulation. Do not read a nested delivery object from the order record. +- **Express delivery:** Select a provider by `provider.adapterKey` in `isActivatedFor` and calculate its fee with `resultSheet().addFee()`. +- **Free shipping:** Read order positions with `modules.orders.positions.findOrderPositions`, calculate their totals with the pricing services, and skip or replace the delivery fee when the threshold is reached. Avoid counting only the first calculation row; discounts and taxes can span multiple rows. + +`params.calculationSheet` contains earlier delivery pricing rows. To replace them, call `pricingAdapter.resultSheet().resetCalculation(params.calculationSheet)` before adding the replacement fee. Use this deliberately because it also offsets prior tax and discount rows. -Available in `this.context`: +## Context Properties -| Property | Description | -|----------|-------------| -| `provider` | The delivery provider | -| `order` | The current order | -| `modules` | Access to all modules | -| `currency` | Currency code | +| Property in `params.context` | Description | +|-----------------------------|-------------| +| `provider` | Delivery provider | +| `order` | Current order, when available | +| `orderDelivery` | Delivery record when pricing an order delivery | +| `providerContext` | Context supplied for provider simulation | +| `countryCode`, `currencyCode` | Country and currency codes | +| `user` | User | +| `modules`, `services` | Module and service APIs | ## Related -- [Pricing System](../../concepts/pricing-system.md) - Conceptual overview -- [Product Pricing](./product-pricing.md) - Product prices -- [Payment Pricing](./payment-pricing.md) - Payment fees -- [Delivery Plugins](../order-fulfilment/fulfilment-plugins/delivery.md) - Delivery adapters +- [Pricing System](../../concepts/pricing-system.md) +- [Product Pricing](./product-pricing.md) +- [Payment Pricing](./payment-pricing.md) +- [Delivery Plugins](../order-fulfilment/fulfilment-plugins/delivery.md) diff --git a/docs/docs/extend/pricing/order-discounts.md b/docs/docs/extend/pricing/order-discounts.md index 57b145152c..eaa2bae913 100644 --- a/docs/docs/extend/pricing/order-discounts.md +++ b/docs/docs/extend/pricing/order-discounts.md @@ -7,295 +7,81 @@ description: Custom discount adapters for orders # Order Discounts -Order discount adapters handle coupon codes, promotional discounts, and automatic order-level discounts. +Order discount adapters validate coupon codes and automatic promotions. They return configuration for specific pricing adapters, which calculate the monetary adjustment. -For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). - -## Creating an Adapter - -Register a discount adapter with `OrderDiscountDirector`: +## Coupon Example ```typescript -import { OrderDiscountDirector, type IDiscountAdapter } from '@unchainedshop/core'; - -const MyDiscount: IDiscountAdapter = { - key: 'my-shop.discount.custom', - label: 'Custom Discount', +import { + OrderDiscountAdapter, + OrderDiscountDirector, + type IDiscountAdapter, + type ProductDiscountConfiguration, +} from '@unchainedshop/core'; + +const SaveTen: IDiscountAdapter = { + ...OrderDiscountAdapter, + key: 'my-shop.discount.save-ten', + label: 'Save ten percent', version: '1.0.0', - orderIndex: 0, + orderIndex: 10, - isManualAdditionAllowed(code) { - return true; // Allow users to enter this discount code - }, + isManualAdditionAllowed: async () => true, + isManualRemovalAllowed: async () => true, - isManualRemovalAllowed() { - return true; // Allow users to remove this discount - }, - - actions(context) { + async actions({ context }) { return { - isValidForSystemTriggering() { - return false; // Don't auto-apply - }, - - isValidForCodeTriggering(code) { - return code === 'SAVE10'; - }, - + ...(await OrderDiscountAdapter.actions({ context })), + isValidForSystemTriggering: async () => false, + isValidForCodeTriggering: async ({ code }) => code.toUpperCase() === 'SAVE10', discountForPricingAdapterKey({ pricingAdapterKey }) { - return { rate: 0.1 }; // 10% off - }, - - async reserve(code) { - // Optional: Track usage - }, - - async release() { - // Optional: Release reservation on cancellation - }, - }; - }, -}; - -OrderDiscountDirector.registerAdapter(MyDiscount); -``` - -## Examples - -### Coupon Code Discount - -```typescript -const CouponDiscount: IDiscountAdapter = { - key: 'my-shop.discount.coupon', - label: 'Coupon Code', - version: '1.0.0', - orderIndex: 0, - - isManualAdditionAllowed(code) { - // Accept codes starting with 'SAVE' or 'DISCOUNT' - return code?.startsWith('SAVE') || code?.startsWith('DISCOUNT'); - }, - - isManualRemovalAllowed() { - return true; - }, - - actions(context) { - const validCodes = { - SAVE10: { rate: 0.1 }, - SAVE20: { rate: 0.2 }, - DISCOUNT50: { fixedRate: 5000 }, // 50.00 off - }; - - return { - isValidForSystemTriggering() { - return false; - }, - - isValidForCodeTriggering(code) { - return code in validCodes; - }, - - discountForPricingAdapterKey({ code }) { - return validCodes[code] || null; - }, - - async reserve(code) { - // Decrement coupon usage count - await db.collection('coupons').updateOne( - { code }, - { $inc: { usageCount: 1 } } - ); - }, - - async release() { - // Increment back on cancellation - const { code } = context.orderDiscount; - await db.collection('coupons').updateOne( - { code }, - { $inc: { usageCount: -1 } } - ); - }, - }; - }, -}; -``` - -### Automatic First-Order Discount - -```typescript -const FirstOrderDiscount: IDiscountAdapter = { - key: 'my-shop.discount.first-order', - label: 'First Order Discount', - version: '1.0.0', - orderIndex: 1, - - isManualAdditionAllowed() { - return false; // Auto-applied only - }, - - isManualRemovalAllowed() { - return false; - }, - - actions(context) { - const { order, modules } = context; - - return { - async isValidForSystemTriggering() { - // Check if this is the user's first order - const previousOrders = await modules.orders.count({ - userId: order.userId, - status: { $ne: null }, // Exclude carts - }); - return previousOrders === 0; - }, - - isValidForCodeTriggering() { - return false; - }, - - discountForPricingAdapterKey() { - return { rate: 0.15 }; // 15% off first order - }, - - async reserve() {}, - async release() {}, - }; - }, -}; -``` - -### Minimum Order Value Discount - -```typescript -const MinimumOrderDiscount: IDiscountAdapter = { - key: 'my-shop.discount.minimum-order', - label: 'Spend More Save More', - version: '1.0.0', - orderIndex: 2, - - isManualAdditionAllowed() { - return false; - }, - - isManualRemovalAllowed() { - return false; - }, - - actions(context) { - const { order } = context; - - return { - async isValidForSystemTriggering() { - const total = order.pricing().total().amount; - return total >= 10000; // Minimum 100.00 - }, - - isValidForCodeTriggering() { - return false; - }, - - discountForPricingAdapterKey() { - const total = order.pricing().total().amount; - - // Tiered discounts - if (total >= 50000) { - return { rate: 0.15 }; // 15% off for 500+ - } else if (total >= 25000) { - return { rate: 0.1 }; // 10% off for 250+ - } else if (total >= 10000) { - return { rate: 0.05 }; // 5% off for 100+ + if (pricingAdapterKey === 'shop.unchained.pricing.product-discount') { + return { rate: 0.1 }; } - return null; }, - - async reserve() {}, - async release() {}, }; }, }; -``` - -### Limited-Use Coupon -```typescript -const LimitedCoupon: IDiscountAdapter = { - key: 'my-shop.discount.limited', - label: 'Limited Coupon', - version: '1.0.0', - orderIndex: 0, +OrderDiscountDirector.registerAdapter(SaveTen); +``` - isManualAdditionAllowed(code) { - return code?.startsWith('LIMITED'); - }, +Load the product discount pricing adapter, either through the base preset or by importing `@unchainedshop/plugins/pricing/product-discount.js`. Return `null` for unrelated pricing adapter keys so the same discount is not applied to products, payment, delivery, and the order independently. - isManualRemovalAllowed() { - return true; - }, +## Automatic Discounts - actions(context) { - return { - isValidForSystemTriggering() { - return false; - }, +Return `false` from `isManualAdditionAllowed` and implement `isValidForSystemTriggering` to select eligible orders. The action context is available as `params.context`, including `order`, optional `code` and `orderDiscount`, and `modules`. - async isValidForCodeTriggering(code) { - // Check if coupon exists and has remaining uses - const coupon = await db.collection('coupons').findOne({ code }); - if (!coupon) return false; - return coupon.usageCount < coupon.maxUsage; - }, +For customer-specific promotions, query the user with `context.modules.users.findUserById(context.order.userId)`. The built-in [half-price adapter](../../plugins/pricing/pricing-discount-half-price.md) provides an example based on user tags. - discountForPricingAdapterKey({ code }) { - return { rate: 0.25 }; // 25% off - }, +## Minimum Order Values - async reserve(code) { - await db.collection('coupons').updateOne( - { code }, - { $inc: { usageCount: 1 } } - ); - }, +`discountForPricingAdapterKey` receives the current `calculationSheet` as well as the pricing adapter key. Use that sheet to determine thresholds when returning the discount configuration. The sheet represents the calculation for that particular adapter; a product sheet is not an order total. - async release() { - const { code } = context.orderDiscount; - await db.collection('coupons').updateOne( - { code }, - { $inc: { usageCount: -1 } } - ); - }, - }; - }, -}; -``` +For an order-wide fixed amount, target `shop.unchained.pricing.order-discount` and return an `OrderDiscountConfiguration`, such as `{ fixedRate: 10000 }`. The built-in [100-off adapter](../../plugins/pricing/pricing-discount-100-off.md) demonstrates this. Define the currencies for which a fixed amount is valid. -## Adapter Methods +## Usage Reservations -| Method | Description | -|--------|-------------| -| `isManualAdditionAllowed(code)` | Can users add this discount with a code? | -| `isManualRemovalAllowed()` | Can users remove this discount? | -| `isValidForSystemTriggering()` | Should this discount auto-apply? | -| `isValidForCodeTriggering(code)` | Is this code valid? | -| `discountForPricingAdapterKey()` | Return discount configuration | -| `reserve(code)` | Called when discount is applied | -| `release()` | Called when order is cancelled | +`reserve({ code })` can reserve coupon usage through an application-owned module and return reservation data. `release()` frees that reservation when the discount is removed. Spread the base actions to keep no-op implementations when reservations are unnecessary. -## Discount Configuration +For limited-use coupons, make the reservation atomic in the storage layer. Checking usage and incrementing it in separate operations can allow concurrent checkouts to exceed the limit. -Return from `discountForPricingAdapterKey`: +## Adapter Methods -| Property | Description | -|----------|-------------| -| `rate` | Percentage discount (0.1 = 10%) | -| `fixedRate` | Fixed amount in cents (5000 = 50.00) | +| Method | Contract | +|--------|----------| +| `isManualAdditionAllowed(code?)` | Promise indicating whether a user may add the discount | +| `isManualRemovalAllowed()` | Promise indicating whether a user may remove it | +| `actions({ context })` | Promise of actions for this order | +| `isValidForSystemTriggering()` | Promise indicating automatic eligibility | +| `isValidForCodeTriggering({ code })` | Promise indicating coupon validity | +| `discountForPricingAdapterKey({ pricingAdapterKey, calculationSheet })` | Synchronous configuration or `null` | +| `reserve({ code })` | Promise of reservation data | +| `release()` | Promise resolving after reservation cleanup | ## GraphQL -Apply discount: - ```graphql mutation ApplyDiscount($code: String!) { addCartDiscount(code: $code) { @@ -309,8 +95,6 @@ mutation ApplyDiscount($code: String!) { } ``` -Remove discount: - ```graphql mutation RemoveDiscount($discountId: ID!) { removeCartDiscount(discountId: $discountId) { @@ -321,5 +105,5 @@ mutation RemoveDiscount($discountId: ID!) { ## Related -- [Pricing System](../../concepts/pricing-system.md) - Conceptual overview -- [Product Pricing](./product-pricing.md) - Product-level discounts +- [Pricing System](../../concepts/pricing-system.md) +- [Product Pricing](./product-pricing.md) diff --git a/docs/docs/extend/pricing/payment-pricing.md b/docs/docs/extend/pricing/payment-pricing.md index acf405b6f4..bf14322bd9 100644 --- a/docs/docs/extend/pricing/payment-pricing.md +++ b/docs/docs/extend/pricing/payment-pricing.md @@ -7,190 +7,73 @@ description: Custom payment pricing adapters # Payment Pricing -Payment pricing adapters calculate fees for different payment methods, such as credit card processing fees or invoice handling charges. +Payment pricing adapters calculate payment fees and adjustments. Compose an object from `PaymentPricingAdapter` and register it with `PaymentPricingDirector`, both exported by `@unchainedshop/core`. -For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). - -## Creating an Adapter - -Extend `PaymentPricingAdapter` and register it with `PaymentPricingDirector`: +## Invoice Fee ```typescript import { PaymentPricingAdapter, PaymentPricingDirector, -} from '@unchainedshop/core-pricing'; - -class MyPaymentPricing extends PaymentPricingAdapter { - static key = 'my-shop.pricing.payment'; - static version = '1.0.0'; - static label = 'Custom Payment Pricing'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return true; // Activate for all payment providers - } - - async calculate() { - this.result.addItem({ - amount: 0, // No fee - isTaxable: false, - isNetPrice: true, - category: 'PAYMENT', - meta: { adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} - -PaymentPricingDirector.registerAdapter(MyPaymentPricing); + type IPaymentPricingAdapter, +} from '@unchainedshop/core'; +import { PaymentProviderType } from '@unchainedshop/core-payment'; + +const InvoiceFee: IPaymentPricingAdapter = { + ...PaymentPricingAdapter, + key: 'my-shop.pricing.invoice-fee', + version: '1.0.0', + label: 'Invoice handling fee', + orderIndex: 10, + + isActivatedFor: ({ provider, currencyCode }) => + provider.type === PaymentProviderType.INVOICE && currencyCode === 'CHF', + + actions(params) { + const pricingAdapter = PaymentPricingAdapter.actions(params); + return { + ...pricingAdapter, + async calculate() { + pricingAdapter.resultSheet().addFee({ + amount: 500, // CHF 5.00 + isTaxable: true, + isNetPrice: true, + meta: { adapter: InvoiceFee.key }, + }); + return pricingAdapter.calculate(); + }, + }; + }, +}; + +PaymentPricingDirector.registerAdapter(InvoiceFee); ``` -## Examples +`addFee` assigns the `PAYMENT` category automatically. Amounts are in the selected currency's smallest unit. -### Credit Card Fee - -```typescript -class CardFeeAdapter extends PaymentPricingAdapter { - static key = 'my-shop.pricing.card-fee'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'CARD' || provider.type === 'GENERIC'; - } - - async calculate() { - const { order } = this.context; - const orderTotal = order.pricing().total().amount; - - // 2.9% + 30 cents (typical card processing fee) - const fee = Math.round(orderTotal * 0.029 + 30); - - this.result.addItem({ - amount: fee, - isTaxable: false, - isNetPrice: true, - category: 'PAYMENT', - meta: { rate: 0.029, fixed: 30, adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} -``` +## Percentage Fees and Discounts -### Invoice Fee +For percentage-based fees, obtain the intended base amount through the module and pricing service APIs. Order records are plain data and have no `order.pricing()` method. Define whether the fee applies to products only, includes delivery, or includes tax before choosing the total. Avoid triggering a full order recalculation from a payment pricing adapter, which can recurse into payment pricing. -```typescript -class InvoiceFeeAdapter extends PaymentPricingAdapter { - static key = 'my-shop.pricing.invoice-fee'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'INVOICE'; - } - - async calculate() { - // Flat fee for invoice handling - this.result.addItem({ - amount: 500, // 5.00 invoice fee - isTaxable: true, - isNetPrice: true, - category: 'PAYMENT', - meta: { type: 'invoice', adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} -``` +Select specific gateways with `provider.adapterKey`. Card gateways typically use the `GENERIC` payment provider type; there is no `CARD` provider type. -### Discount for Bank Transfer - -```typescript -class BankTransferDiscountAdapter extends PaymentPricingAdapter { - static key = 'my-shop.pricing.bank-discount'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.adapterKey === 'my-shop.payment.bank-transfer'; - } - - async calculate() { - const { order } = this.context; - const orderTotal = order.pricing().total().amount; - - // 2% discount for bank transfer (no card fees) - const discount = Math.round(orderTotal * 0.02); - - this.result.addItem({ - amount: -discount, - isTaxable: true, - isNetPrice: true, - category: 'DISCOUNT', - meta: { type: 'bank-transfer-discount', rate: 0.02 }, - }); - - return super.calculate(); - } -} -``` - -### Tiered Processing Fees - -```typescript -class TieredFeeAdapter extends PaymentPricingAdapter { - static key = 'my-shop.pricing.tiered-fee'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'CARD'; - } - - async calculate() { - const { order } = this.context; - const orderTotal = order.pricing().total().amount; - - // Tiered rates based on order value - let rate: number; - if (orderTotal >= 50000) { - rate = 0.019; // 1.9% for orders >= 500 - } else if (orderTotal >= 10000) { - rate = 0.025; // 2.5% for orders >= 100 - } else { - rate = 0.029; // 2.9% for smaller orders - } - - const fee = Math.round(orderTotal * rate + 30); - - this.result.addItem({ - amount: fee, - isTaxable: false, - isNetPrice: true, - category: 'PAYMENT', - meta: { rate, orderTotal, adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} -``` +Use `params.calculationSheet` to inspect preceding payment rows and `pricingAdapter.resultSheet()` for this adapter's contribution. `resultSheet().addDiscount()` adds a discount row and requires a `discountId`, tax flags, and amount. Return `pricingAdapter.calculate()` to pass the accumulated contribution to the director. ## Context Properties -Available in `this.context`: - -| Property | Description | -|----------|-------------| -| `provider` | The payment provider | -| `order` | The current order | -| `modules` | Access to all modules | -| `currency` | Currency code | +| Property in `params.context` | Description | +|-----------------------------|-------------| +| `provider` | Payment provider | +| `order` | Current order, when available | +| `orderPayment` | Payment record when pricing an order payment | +| `providerContext` | Context supplied for provider simulation | +| `countryCode`, `currencyCode` | Country and currency codes | +| `user` | User, when available | +| `modules`, `services` | Module and service APIs | ## Related -- [Pricing System](../../concepts/pricing-system.md) - Conceptual overview -- [Product Pricing](./product-pricing.md) - Product prices -- [Delivery Pricing](./delivery-pricing.md) - Shipping fees -- [Payment Plugins](../order-fulfilment/fulfilment-plugins/payment.md) - Payment adapters +- [Pricing System](../../concepts/pricing-system.md) +- [Product Pricing](./product-pricing.md) +- [Delivery Pricing](./delivery-pricing.md) +- [Payment Plugins](../order-fulfilment/fulfilment-plugins/payment.md) diff --git a/docs/docs/extend/pricing/product-pricing.md b/docs/docs/extend/pricing/product-pricing.md index 556524338f..f3b7d0c6e7 100644 --- a/docs/docs/extend/pricing/product-pricing.md +++ b/docs/docs/extend/pricing/product-pricing.md @@ -7,192 +7,101 @@ description: Custom product pricing adapters # Product Pricing -Product pricing adapters calculate prices when products are queried or added to cart. Use them to implement taxes, discounts, rounding, and currency conversion. - -For conceptual overview, see [Pricing System](../../concepts/pricing-system.md). +Product pricing adapters calculate prices when products are queried or added to a cart. They can add catalog prices, adjustments, taxes, rounding, and currency conversion. ## Creating an Adapter -Extend `ProductPricingAdapter` and register it with `ProductPricingDirector`: +Compose an object from `ProductPricingAdapter` and register it with `ProductPricingDirector`. Both are exported by `@unchainedshop/core`. ```typescript import { ProductPricingAdapter, ProductPricingDirector, -} from '@unchainedshop/core-pricing'; - -class MyProductPricing extends ProductPricingAdapter { - static key = 'my-shop.pricing.custom'; - static version = '1.0.0'; - static label = 'Custom Product Pricing'; - static orderIndex = 0; - - static isActivatedFor({ product, currencyCode }) { - return true; // Activate for all products - } - - async calculate() { - const { product, quantity, currencyCode } = this.context; - - this.result.addItem({ - amount: 1000, // 10.00 in cents - isTaxable: true, - isNetPrice: true, - category: 'BASE', - meta: { adapter: this.constructor.key }, - }); - - return super.calculate(); - } -} - -ProductPricingDirector.registerAdapter(MyProductPricing); + type IProductPricingAdapter, +} from '@unchainedshop/core'; + +const ProductSurcharge: IProductPricingAdapter = { + ...ProductPricingAdapter, + key: 'my-shop.pricing.surcharge', + version: '1.0.0', + label: 'Product surcharge', + orderIndex: 10, + + isActivatedFor: ({ currencyCode }) => currencyCode === 'CHF', + + actions(params) { + const pricingAdapter = ProductPricingAdapter.actions(params); + return { + ...pricingAdapter, + async calculate() { + pricingAdapter.resultSheet().addItem({ + amount: 100 * params.context.quantity, // CHF 1.00 per unit + isTaxable: true, + isNetPrice: true, + meta: { adapter: ProductSurcharge.key }, + }); + return pricingAdapter.calculate(); + }, + }; + }, +}; + +ProductPricingDirector.registerAdapter(ProductSurcharge); ``` -## Examples +This adds to any preceding catalog price. Amounts are in the currency's smallest unit and apply to the complete requested quantity. `addItem` assigns the `ITEM` category automatically. -### Tax Calculation +## Reading and Replacing Prior Calculations -```typescript -class SwissTaxAdapter extends ProductPricingAdapter { - static key = 'my-shop.pricing.swiss-tax'; - static orderIndex = 20; // After base price and discounts - - static isActivatedFor({ country }) { - return country === 'CH'; - } - - async calculate() { - const taxRate = 0.081; // 8.1% Swiss VAT - const taxableAmount = this.calculation.sum({ isTaxable: true }); - - if (taxableAmount > 0) { - this.result.addItem({ - amount: Math.round(taxableAmount * taxRate), - isTaxable: false, - isNetPrice: false, - category: 'TAX', - meta: { rate: taxRate, adapter: this.constructor.key }, - }); - } - - return super.calculate(); - } -} -``` +`params.calculationSheet` contains the preceding adapters' rows. The adapter's `resultSheet()` holds only its own contribution. Read totals with `sum()` or `total()`; return the contribution with `pricingAdapter.calculate()`. -### Bulk Discount +To replace prior rows, add their inverse to the result sheet before adding the replacement: ```typescript -class BulkDiscountAdapter extends ProductPricingAdapter { - static key = 'my-shop.pricing.bulk-discount'; - static orderIndex = 10; // After base price, before tax - - async calculate() { - const { quantity } = this.context; - - if (quantity >= 10) { - const baseTotal = this.calculation.sum({ category: 'BASE' }); - const discountRate = 0.1; // 10% off - - this.result.addItem({ - amount: -Math.round(baseTotal * discountRate), - isTaxable: true, - isNetPrice: true, - category: 'DISCOUNT', - meta: { type: 'bulk', rate: discountRate }, - }); - } - - return super.calculate(); - } -} +// Inside actions(params), after creating pricingAdapter: +const result = pricingAdapter.resultSheet(); +result.resetCalculation(params.calculationSheet); +result.addItem({ + amount: 900 * params.context.quantity, + isTaxable: true, + isNetPrice: true, + meta: { adapter: 'my-shop.pricing.replacement' }, +}); ``` -### Price Rounding - -```typescript -class PriceRoundingAdapter extends ProductPricingAdapter { - static key = 'my-shop.pricing.rounding'; - static orderIndex = 30; // Run last - - async calculate() { - const { calculation = [] } = this; - - if (calculation.length) { - const [basePrice] = calculation; - const rounded = this.roundToNext(basePrice.amount, 50); - - this.resetCalculation(); - this.result.addItem({ - amount: rounded, - isTaxable: basePrice.isTaxable, - isNetPrice: basePrice.isNetPrice, - meta: { adapter: this.constructor.key }, - }); - } - - return super.calculate(); - } - - roundToNext(value: number, precision: number) { - const remainder = value % precision; - return remainder === 0 ? value : value + (precision - remainder); - } -} -``` - -### Currency Conversion - -```typescript -class CurrencyConversionAdapter extends ProductPricingAdapter { - static key = 'my-shop.pricing.currency'; - static orderIndex = 1; - - async calculate() { - const { currencyCode, baseCurrencyCode } = this.context; - - if (currencyCode !== baseCurrencyCode) { - const rate = await this.getExchangeRate(baseCurrencyCode, currencyCode); - - for (const item of this.calculation) { - item.amount = Math.round(item.amount * rate); - } - } - - return super.calculate(); - } - - async getExchangeRate(from: string, to: string) { - // Fetch from your exchange rate service - return 1.1; - } -} -``` +For tax-aware rounding and conversion, follow the built-in [product rounding](../../plugins/pricing/pricing-product-round.md) and [rate conversion](../../plugins/pricing/pricing-product-rate-conversion.md) adapters. They preserve each row's tax information when replacing prices. Use the country tax presets for destination-specific tax calculation. ## Adapter Properties -| Property | Type | Description | -|----------|------|-------------| -| `key` | string | Unique identifier | -| `version` | string | Version for tracking | -| `label` | string | Human-readable name | -| `orderIndex` | number | Execution order (lower = earlier) | +| Property | Description | +|----------|-------------| +| `key` | Unique identifier | +| `version` | Adapter version | +| `label` | Human-readable name | +| `orderIndex` | Execution order, lower values first | +| `isActivatedFor(context)` | Selects the pricing contexts this adapter handles | +| `actions(params)` | Creates the calculation actions for a single invocation | ## Context Properties -Available in `this.context`: +Available in `params.context`: | Property | Description | |----------|-------------| -| `product` | The product being priced | -| `quantity` | Quantity requested | -| `currencyCode` | Target currency | -| `country` | Country code | +| `product` | Product being priced | +| `quantity` | Requested quantity | +| `currencyCode` | Target currency code | +| `countryCode` | Target country code | +| `configuration` | Product configuration | +| `order`, `user` | Order and user when available | +| `modules`, `services` | Unchained module and service APIs | + +Resolved discount configurations are passed separately as `params.discounts`. ## Related -- [Pricing System](../../concepts/pricing-system.md) - Conceptual overview -- [Delivery Pricing](./delivery-pricing.md) - Shipping fees -- [Payment Pricing](./payment-pricing.md) - Payment fees -- [Order Discounts](./order-discounts.md) - Order-level discounts +- [Pricing System](../../concepts/pricing-system.md) +- [Custom Pricing](../../guides/custom-pricing.md) +- [Delivery Pricing](./delivery-pricing.md) +- [Payment Pricing](./payment-pricing.md) +- [Order Discounts](./order-discounts.md) diff --git a/docs/docs/extend/quotation.md b/docs/docs/extend/quotation.md index d11a53f05d..5e0bea2937 100644 --- a/docs/docs/extend/quotation.md +++ b/docs/docs/extend/quotation.md @@ -7,88 +7,53 @@ description: Customizing quotation # Quotation Adapters -## QuotationAdapter +Quotation adapters process quote requests. Register an object implementing `IQuotationAdapter` with `QuotationDirector`. The adapter types and director are exported by `@unchainedshop/core`; quotation record types remain in `@unchainedshop/core-quotations`. -You can accept quotation requests for a shop items. For every quotation received you can setup a quotation adapter to process this request manually or automatically. In order to process quotation request you need to create a quotation adapter that implements the `IQuotationAdapter` and register the adapter on the global quotation director that implements the `IQuotationDirector`. +Adapters are evaluated in ascending `orderIndex` order. Use `isActivatedFor` to select the requests your adapter handles. -There can be multiple quotation adapters configured and active for a store and all of them will be executed for every quotation requests based on there `orderIndex` value. Quotation adapters that have smaller `orderIndex` value will be executed first. - -Below is a sample manual quotation adapter implementation that will mark every quotation request as expired after an hour of request if no quote is given in between by a user that is managing quotation requests. +## Manual Quotation Example ```typescript -import { log, LogLevel } from '@unchainedshop/logger'; - -import { IQuotationAdapter } from '@unchainedshop/core-quotations'; -import { QuotationError } from '@unchainedshop/core-quotations'; +import { QuotationAdapter, QuotationDirector, type IQuotationAdapter } from '@unchainedshop/core'; -export const ManualOffering: IQuotationAdapter = { - key: 'shop.unchained.quotations.manual', - label: 'Manual quotation' +const ManualOffering: IQuotationAdapter = { + ...QuotationAdapter, + key: 'my-shop.quotations.manual', + label: 'Manual quotation', version: '1.0.0', orderIndex: 1, - isActivatedFor: (quotationContext: QuotationContext, unchainedAPI: UnchainedCore): boolean => { - return false; - }, + isActivatedFor: () => true, - actions: (params: QuotationContext & Context): QuotationAdapterActions => { + actions(params) { return { - configurationError: () => { - return QuotationError.NOT_IMPLEMENTED; - }, - - isManualRequestVerificationRequired: async (): Promise => { - return true; - }, - - isManualProposalRequired: async (): Promise => { - return true; - }, - - quote: async (): Promise => { - return { - expires: new Date(new Date().getTime() + 3600 * 1000), - }; - }, - - rejectRequest: async (unchainedAPI?: any): Promise => { - return true; - }, - - submitRequest: async (unchainedAPI?: any): Promise => { - return true; - }, - - verifyRequest: async (unchainedAPI?: any): Promise => { - return true; - }, - - transformItemConfiguration: async (params: QuotationItemConfiguration) => { - return { quantity: params.quantity, configuration: params.configuration }; - }, + ...QuotationAdapter.actions(params), + configurationError: () => null, + isManualRequestVerificationRequired: async () => true, + isManualProposalRequired: async () => true, + quote: async () => ({ + expires: new Date(Date.now() + 60 * 60 * 1000), + }), }; }, }; +QuotationDirector.registerAdapter(ManualOffering); ``` +The example requires manual verification and proposal. When the proposal is created, `quote()` sets its expiry to one hour from that moment. It does not start an expiry timer when the original request is submitted. -- **isActivatedFor: (quotationContext: QuotationContext, unchainedAPI: UnchainedCore)**: Determines for which type of quotation request an adapter is active for. it can be based on the actual quotation in question or any condition you can think of. -- **configurationError: QuotationError**: Returns any error that occurred while initializing the adapter. it can be missing environment variable or and other missing required values. -- **isManualRequestVerificationRequired**: defines if a quotation should be considered valid and ready for quote automatically or should be verified by someone manually. -- **isManualProposalRequired** Define if a user can respond to quotation request manually or not. -- **quote**: Responds with the actual quotation request. -- **rejectRequest** Will mark a quotation as rejected if returned to based on any condition check performed. -- **submitRequest**: Will approve a quotation request for processing if you return true from this function. -- **verifyRequest** It will mark the quotation as verified for a certain quotation if this function returns true. -- **transformItemConfiguration(params: QuotationItemConfiguration)**: A quotation request is submitted as a `JSON` value and there is no predefined format of quotation request. use this function to transform the submitted `JSON` from the front end into a structure that will be best to work with in an adapter. - +## Actions +| Action | Purpose | +|--------|---------| +| `configurationError()` | Returns an error or `null` | +| `isManualRequestVerificationRequired()` | Determines whether requests need manual verification | +| `isManualProposalRequired()` | Determines whether a proposal must be created manually | +| `quote()` | Returns proposal data such as price and expiry | +| `submitRequest()` | Accepts or rejects submission | +| `verifyRequest()` | Accepts or rejects verification | +| `rejectRequest()` | Allows request rejection | +| `transformItemConfiguration(params)` | Normalizes quantity and product configuration | -## Registering Quotation Adapter - -```typescript -import { QuotationDirector } from '@unchainedshop/core-quotations'; - -QuotationDirector.registerAdapter(ManualOffering); -``` \ No newline at end of file +Spread `QuotationAdapter.actions(params)` to retain default actions you do not override. Import your adapter before starting the platform. diff --git a/docs/docs/extend/worker.md b/docs/docs/extend/worker.md index 37d8e9d0a5..b549e7588f 100644 --- a/docs/docs/extend/worker.md +++ b/docs/docs/extend/worker.md @@ -11,13 +11,13 @@ description: Add custom background workers You can add different types of works to perform various task based on different input and triggers. Work can be a cron operation that run on a given interval to do a system backup or send an email to a user after a certain operation. -In order to make use of work to perform any task you need to implement the `IWorkerAdapter` interface and register it to the global WorkDirector which implements the `IWorkerDirector`. +In order to make use of work to perform any task you need to implement the `IWorkerAdapter` interface and register it to the global WorkerDirector which implements the `IWorkerDirector`. Below is an example of work adapter that checks if all works are healthy and working correctly, runs on the `wait` interval value passed as input ```typescript -import { IWorkerAdapter } from '@unchainedshop/core-worker'; +import { WorkerAdapter, type IWorkerAdapter } from '@unchainedshop/core'; const wait = async (time: number) => { return new Promise((resolve) => { @@ -35,6 +35,7 @@ type Arg = { type Result = Arg; const Heartbeat: IWorkerAdapter = { + ...WorkerAdapter, key: 'shop.unchained.worker-plugin.heartbeat', label: 'Heartbeat plugin to check if workers are working', @@ -60,14 +61,14 @@ const Heartbeat: IWorkerAdapter = { }; ``` -- **type**: type of the worker, this value is used to specify the worker you are targeting when adding a work to a work queue using `WorkerModule.addWork(data: WorkData, userId: string)` function +- **type**: type of the worker, this value is used to specify the worker you are targeting when adding a work to a work queue using `unchainedAPI.modules.worker.addWork({ type, input, ...options })` function - **doWork**: function that defines the actual work that is going to be performed by the work adapter ## Registering Work Adapter Before you can add a worker in the work queue you need to register it to the global Worker director ```typescript -import { WorkerDirector } from '@unchainedshop/core-worker'; +import { WorkerDirector } from '@unchainedshop/core'; WorkerDirector.registerAdapter(Heartbeat); ``` @@ -86,6 +87,5 @@ unchainedAPI.modules.worker.addWork( }, }, ); -} ``` \ No newline at end of file diff --git a/docs/docs/guides/bulk-import.md b/docs/docs/guides/bulk-import.md index 8c8999bb0b..a0bd99c0ad 100644 --- a/docs/docs/guides/bulk-import.md +++ b/docs/docs/guides/bulk-import.md @@ -41,7 +41,13 @@ mutation BulkImport { { entity: "PRODUCT" operation: "CREATE" - payload: "{}" + payload: { + _id: "example-product" + specification: { + type: "SIMPLE_PRODUCT" + content: { en: { title: "Example product" } } + } + } } ] } @@ -54,7 +60,7 @@ mutation BulkImport { ### REST Endpoint -For large imports (5K+ entities or >16MB), use the REST endpoint: +Use the streaming REST endpoint for large imports. The uploaded JSON must contain an `events` array; a successful response means the import was queued. Query the returned work ID for completion: ```bash curl -X POST \ @@ -106,6 +112,8 @@ Pass options as query parameters (REST) or in the input object (GraphQL): # REST with options curl -X POST \ "https://your-engine.com/bulk-import?createShouldUpsertIfIDExists=true" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ --data-binary @products.json ``` @@ -291,22 +299,23 @@ curl -X POST \ ## Custom Import Handlers -Create custom handlers for specialized import needs: +Create custom handlers for specialized import needs. Entity keys are uppercase and operation keys are lowercase. This example writes an application-owned inventory collection; a custom warehousing adapter must consume it to expose stock to Unchained: ```typescript -import { UnchainedCore } from '@unchainedshop/core'; -import { BulkImportHandler, BulkImportOperation } from '@unchainedshop/platform'; +import type { UnchainedCore, BulkImportHandler } from '@unchainedshop/core'; +import { startPlatform } from '@unchainedshop/platform'; -const customHandlers: Record = { +const customHandlers: Record> = { INVENTORY: { - UPDATE: async function updateInventory( + update: async function updateInventory( payload: { sku: string; quantity: number }, - options: { logger?: any }, + { bulk }, unchainedAPI: UnchainedCore ) { const { sku, quantity } = payload; - await unchainedAPI.modules.warehousing.updateStock(sku, quantity); + // Stage a write to an application-owned inventory collection. + bulk('inventory').find({ sku }).upsert().updateOne({ $set: { quantity } }); return { entity: 'INVENTORY', @@ -374,6 +383,8 @@ Use `createShouldUpsertIfIDExists` for safe re-runs: ```bash curl -X POST \ "https://your-engine.com/bulk-import?createShouldUpsertIfIDExists=true" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ --data-binary @products.json ``` diff --git a/docs/docs/guides/custom-pricing.md b/docs/docs/guides/custom-pricing.md index 1c3d2c8488..668bc914f2 100644 --- a/docs/docs/guides/custom-pricing.md +++ b/docs/docs/guides/custom-pricing.md @@ -7,481 +7,111 @@ description: Implement custom pricing logic with pricing adapters # Custom Pricing -This guide covers implementing custom pricing logic in Unchained Engine using pricing adapters. - -## Overview - -Unchained uses a pricing pipeline where multiple adapters can contribute to the final price: +Unchained runs registered pricing adapters in ascending `orderIndex` order. Each adapter reads the previous calculation sheet and returns its own rows. Adapters are objects composed from the base adapters exported by `@unchainedshop/core`. ```mermaid flowchart LR - BP[Base Price
Adapter] --> TA[Tax
Adapter] --> DA[Discount
Adapter] --> FP[Final Price] + BP[Catalog price] --> CP[Custom adjustment] --> DP[Discounts and tax] --> FP[Final price] ``` -## Prerequisites - -- Node.js 22+ -- Running Unchained Engine instance -- Basic TypeScript knowledge - ## Creating a Product Pricing Adapter -### Basic Structure - -```typescript -import { - ProductPricingAdapter, - ProductPricingDirector, -} from '@unchainedshop/core-pricing'; - -class CustomPricingAdapter extends ProductPricingAdapter { - // Unique identifier - static key = 'shop.example.pricing.custom'; - - // Display name in admin - static label = 'Custom Pricing Adapter'; - - // Version for tracking - static version = '1.0.0'; - - // Execution order (lower = earlier) - static orderIndex = 10; - - // When to activate this adapter - static isActivatedFor({ product, country, currency }) { - return true; // Activate for all products - } - - // Calculate pricing adjustments - async calculate() { - // Add pricing items - this.result.addItem({ - amount: 100, // Amount in cents - isTaxable: true, - isNetPrice: true, - meta: { adapter: this.constructor.key }, - }); - - // Always call super.calculate() at the end - return super.calculate(); - } -} - -// Register the adapter -ProductPricingDirector.registerAdapter(CustomPricingAdapter); -``` - -### Context Available - -The adapter has access to context information: - -```typescript -async calculate() { - const { - product, // The product being priced - quantity, // Quantity requested - currencyCode, // Target currency - countryCode, // Target country - user, // Current user (if logged in) - discounts, // Applied discount codes - modules, // All Unchained modules - } = this.context; - - // Your pricing logic here -} -``` - -## Example: Weather-Based Pricing - -A fun example that adjusts sausage prices based on outdoor temperature: +The following adapter applies a quantity-based reduction before the built-in discount and tax adapters. It assumes its preceding item prices are net prices; preserve the appropriate tax treatment when adapting it for a gross-price catalog. ```typescript import { ProductPricingAdapter, ProductPricingDirector, -} from '@unchainedshop/core-pricing'; - -const SAUSAGE_TAG = 'sausage'; -const TEMPERATURE_THRESHOLD = 20; // Celsius - -class WeatherBasedPricingAdapter extends ProductPricingAdapter { - static key = 'shop.example.pricing.weather-based'; - static label = 'Weather-Based Sausage Pricing'; - static version = '1.0.0'; - static orderIndex = 5; - - // Only activate for products tagged with "sausage" - static isActivatedFor({ product }) { - return product.tags?.includes(SAUSAGE_TAG); - } - - async calculate() { - const { quantity, currencyCode } = this.context; - - try { - // Fetch current weather - const weather = await this.fetchWeather('Zurich'); - - if (weather.temperature > TEMPERATURE_THRESHOLD) { - // BBQ season! Increase price - this.result.addItem({ - amount: 100 * quantity, // +1 CHF per item - isTaxable: true, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - reason: 'bbq-season-surcharge', - temperature: weather.temperature, - }, - }); - } - } catch (error) { - console.error('Weather pricing failed:', error); - // Gracefully continue without adjustment - } - - return super.calculate(); - } - - private async fetchWeather(city: string): Promise<{ temperature: number }> { - const response = await fetch( - `https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${city}` - ); - const data = await response.json(); - return { temperature: data.current.temp_c }; - } -} - -ProductPricingDirector.registerAdapter(WeatherBasedPricingAdapter); -``` - -## Example: Volume Discounts - -Implement quantity-based discounts: - -```typescript -import { - ProductPricingAdapter, - ProductPricingDirector, -} from '@unchainedshop/core-pricing'; + ProductPricingRowCategory, + type IProductPricingAdapter, +} from '@unchainedshop/core'; const VOLUME_TIERS = [ - { minQuantity: 100, discount: 0.20 }, // 20% off for 100+ - { minQuantity: 50, discount: 0.15 }, // 15% off for 50+ - { minQuantity: 20, discount: 0.10 }, // 10% off for 20+ - { minQuantity: 10, discount: 0.05 }, // 5% off for 10+ + { minQuantity: 100, discount: 0.20 }, + { minQuantity: 50, discount: 0.15 }, + { minQuantity: 20, discount: 0.10 }, + { minQuantity: 10, discount: 0.05 }, ]; -class VolumeDiscountAdapter extends ProductPricingAdapter { - static key = 'shop.example.pricing.volume-discount'; - static label = 'Volume Discount'; - static version = '1.0.0'; - static orderIndex = 20; // Run after base price - - static isActivatedFor({ product }) { - // Only for products marked for volume discounts - return product.meta?.allowVolumeDiscount === true; - } - - async calculate() { - const { quantity } = this.context; - - // Find applicable tier - const tier = VOLUME_TIERS.find(t => quantity >= t.minQuantity); - - if (tier) { - // Get current subtotal - const subtotal = this.calculation.sum(); - - // Apply percentage discount - const discountAmount = Math.round(subtotal * tier.discount); - - this.result.addItem({ - amount: -discountAmount, // Negative for discount - isTaxable: true, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - tier: tier.minQuantity, - discountPercent: tier.discount * 100, - }, - }); - } - - return super.calculate(); - } -} - -ProductPricingDirector.registerAdapter(VolumeDiscountAdapter); -``` - -## Example: Customer-Specific Pricing - -Different prices for B2B customers: - -```typescript -import { - ProductPricingAdapter, - ProductPricingDirector, -} from '@unchainedshop/core-pricing'; - -class B2BPricingAdapter extends ProductPricingAdapter { - static key = 'shop.example.pricing.b2b'; - static label = 'B2B Customer Pricing'; - static version = '1.0.0'; - static orderIndex = 3; // Run early - - static isActivatedFor({ product, context }) { - // Only for logged-in B2B customers - return context.user?.tags?.includes('b2b'); - } - - async calculate() { - const { product, user, modules } = this.context; - - // Check for customer-specific price - const customerPrice = await this.getCustomerPrice(product, user); - - if (customerPrice) { - // Replace base price with customer price - this.result.resetCalculation(); - this.result.addItem({ - amount: customerPrice.amount, - isTaxable: customerPrice.isTaxable, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - priceListId: customerPrice.priceListId, - }, - }); - } - - return super.calculate(); - } - - private async getCustomerPrice(product, user) { - // Look up price from customer-specific price list - const priceList = user.meta?.priceListId; - if (!priceList) return null; - - // Your price list lookup logic here - return null; - } -} - -ProductPricingDirector.registerAdapter(B2BPricingAdapter); -``` - -## Example: Time-Based Pricing - -Happy hour or flash sale pricing: - -```typescript -import { - ProductPricingAdapter, - ProductPricingDirector, -} from '@unchainedshop/core-pricing'; - -class HappyHourPricingAdapter extends ProductPricingAdapter { - static key = 'shop.example.pricing.happy-hour'; - static label = 'Happy Hour Pricing'; - static version = '1.0.0'; - static orderIndex = 15; - - static isActivatedFor({ product }) { - return product.tags?.includes('happy-hour-eligible'); - } - - async calculate() { - const now = new Date(); - const hour = now.getHours(); - - // Happy hour: 16:00 - 18:00 - if (hour >= 16 && hour < 18) { - const subtotal = this.calculation.sum(); - const discount = Math.round(subtotal * 0.25); // 25% off - - this.result.addItem({ - amount: -discount, - isTaxable: true, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - reason: 'happy-hour', - validUntil: new Date(now.setHours(18, 0, 0, 0)).toISOString(), - }, - }); - } - - return super.calculate(); - } -} - -ProductPricingDirector.registerAdapter(HappyHourPricingAdapter); -``` - -## Other Pricing Adapter Types - -### Order Pricing - -Adjust order-level pricing (shipping discounts, order minimums): - -```typescript -import { - OrderPricingAdapter, - OrderPricingDirector, -} from '@unchainedshop/core-pricing'; - -class FreeShippingAdapter extends OrderPricingAdapter { - static key = 'shop.example.pricing.free-shipping'; - static label = 'Free Shipping Over 100'; - static version = '1.0.0'; - static orderIndex = 10; - - static isActivatedFor() { - return true; - } - - async calculate() { - const { order } = this.context; - const itemsTotal = order.calculation?.items || 0; - - // Free shipping for orders over 100 - if (itemsTotal >= 10000) { // 100.00 in cents - const shippingCost = this.calculation.sum({ category: 'DELIVERY' }); - - if (shippingCost > 0) { - this.result.addItem({ - amount: -shippingCost, - category: 'DELIVERY', - isTaxable: false, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - reason: 'free-shipping-threshold', - }, - }); - } - } - - return super.calculate(); - } -} +export const VolumePricing: IProductPricingAdapter = { + ...ProductPricingAdapter, + key: 'shop.example.pricing.volume', + label: 'Volume pricing', + version: '1.0.0', + orderIndex: 20, + + isActivatedFor: ({ product }) => product.meta?.allowVolumeDiscount === true, + + actions(params) { + const pricingAdapter = ProductPricingAdapter.actions(params); + return { + ...pricingAdapter, + async calculate() { + const tier = VOLUME_TIERS.find(({ minQuantity }) => params.context.quantity >= minQuantity); + if (tier) { + const subtotal = params.calculationSheet.sum({ + category: ProductPricingRowCategory.Item, + isNetPrice: true, + isTaxable: true, + }); + pricingAdapter.resultSheet().addItem({ + amount: -Math.round(subtotal * tier.discount), + isTaxable: true, + isNetPrice: true, + meta: { adapter: VolumePricing.key, minQuantity: tier.minQuantity }, + }); + } + return pricingAdapter.calculate(); + }, + }; + }, +}; -OrderPricingDirector.registerAdapter(FreeShippingAdapter); +ProductPricingDirector.registerAdapter(VolumePricing); ``` -### Delivery Pricing +This example adjusts the item price. Use the [discount system](../extend/pricing/order-discounts.md) when you need named discounts or coupon codes with their own discount records. -Custom delivery cost calculations: +## Context and Calculation Sheets -```typescript -import { - DeliveryPricingAdapter, - DeliveryPricingDirector, -} from '@unchainedshop/core-pricing'; - -class WeightBasedDeliveryAdapter extends DeliveryPricingAdapter { - static key = 'shop.example.pricing.weight-delivery'; - static label = 'Weight-Based Delivery'; - static version = '1.0.0'; - static orderIndex = 5; - - static isActivatedFor({ provider }) { - return provider.type === 'SHIPPING'; - } +`actions(params)` receives: - async calculate() { - const { order, modules } = this.context; +- `params.context`: Product, quantity, `countryCode`, `currencyCode`, optional user and order, plus module and service APIs. +- `params.calculationSheet`: Rows contributed by preceding adapters, with methods such as `sum`, `total`, and `filterBy`. +- `params.discounts`: Resolved discount configurations for this adapter. - // Calculate total weight - let totalWeight = 0; - for (const item of order.items) { - const product = await modules.products.findProduct({ productId: item.productId }); - totalWeight += (product.warehousing?.dimensions?.weightInGram || 0) * item.quantity; - } - - // Price tiers by weight - let deliveryCost = 500; // Base 5.00 - if (totalWeight > 5000) deliveryCost = 1500; // 15.00 for 5kg+ - else if (totalWeight > 2000) deliveryCost = 1000; // 10.00 for 2kg+ - else if (totalWeight > 1000) deliveryCost = 750; // 7.50 for 1kg+ - - this.result.addItem({ - amount: deliveryCost, - isTaxable: true, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - totalWeightGrams: totalWeight, - }, - }); - - return super.calculate(); - } -} +Create the base actions with `ProductPricingAdapter.actions(params)`. Add new rows to `pricingAdapter.resultSheet()` and return `pricingAdapter.calculate()`. To replace earlier rows, call `resultSheet().resetCalculation(params.calculationSheet)`; this contributes inverse rows rather than mutating another adapter's sheet. -DeliveryPricingDirector.registerAdapter(WeightBasedDeliveryAdapter); -``` +## Other Pricing Rules -### Payment Pricing +### Customer-Specific Pricing -Payment method surcharges or discounts: +Select customers using `isActivatedFor: ({ user }) => Boolean(user?.tags?.includes('b2b'))`. Resolve the customer's price list through your own service, offset the previous sheet, and add the replacement price multiplied by `params.context.quantity`. Include currency and customer identity in any external-price cache key. -```typescript -import { - PaymentPricingAdapter, - PaymentPricingDirector, -} from '@unchainedshop/core-pricing'; - -class CashDiscountAdapter extends PaymentPricingAdapter { - static key = 'shop.example.pricing.cash-discount'; - static label = 'Cash Payment Discount'; - static version = '1.0.0'; - static orderIndex = 5; - - static isActivatedFor({ provider }) { - return provider.adapterKey === 'shop.unchained.payment.invoice'; - } +### Time or Weather Adjustments - async calculate() { - const { order } = this.context; - const orderTotal = order.calculation?.total || 0; +Select eligible product tags in `isActivatedFor`. Fetch or read cached external data inside `calculate`, then add an item adjustment through `resultSheet().addItem`. Define the time zone for time-based rules and the behavior when external data is unavailable. Refresh expired cache entries instead of retaining them indefinitely. - // 2% discount for bank transfer - const discount = Math.round(orderTotal * 0.02); +### Delivery and Payment Fees - this.result.addItem({ - amount: -discount, - isTaxable: false, - isNetPrice: true, - meta: { - adapter: this.constructor.key, - reason: 'cash-discount', - }, - }); +Use [Delivery Pricing](../extend/pricing/delivery-pricing.md) for weight, zone, and free-shipping rules and [Payment Pricing](../extend/pricing/payment-pricing.md) for payment method fees. These adapters use `resultSheet().addFee()` instead of the product adapter's `addItem()`. - return super.calculate(); - } -} +### Currency Conversion and Rounding -PaymentPricingDirector.registerAdapter(CashDiscountAdapter); -``` +The built-in [rate conversion](../plugins/pricing/pricing-product-rate-conversion.md) and [rounding](../plugins/pricing/pricing-product-round.md) adapters provide examples that preserve calculation row metadata and tax treatment. ## Registration -Register your adapter by importing it in your boot file: +Import your adapter module before starting the platform so its registration runs: ```typescript -// boot.ts -import './pricing/weather-based'; -import './pricing/volume-discount'; -import './pricing/b2b-pricing'; +// boot.ts; use the extension matching your application's build setup. +import './pricing/volume.js'; ``` ## Testing Your Adapter -Use the GraphQL playground to test: +Use the GraphQL explorer to compare quantities: ```graphql query TestPricing { @@ -498,81 +128,14 @@ query TestPricing { } ``` -## Best Practices - -### 1. Order Index Strategy - -```typescript -// Suggested order indices: -// 0-5: Base price, currency conversion -// 5-10: Customer-specific pricing -// 10-20: Product-level discounts -// 20-30: Tax calculations -// 30+: Final adjustments -``` - -### 2. Always Call Super - -```typescript -async calculate() { - // Your logic here +Test the calculation with the presets used by your application. Check quantities around each threshold, multiple currencies, tax-inclusive and tax-exclusive prices, and checkout recalculation. - return super.calculate(); // Don't forget this! -} -``` +## Ordering and Metadata -### 3. Use Meta for Transparency - -```typescript -this.result.addItem({ - amount: discountAmount, - meta: { - adapter: this.constructor.key, - reason: 'volume-discount', - appliedTier: '20+', - originalAmount: baseAmount, - }, -}); -``` - -### 4. Handle Errors Gracefully - -```typescript -async calculate() { - try { - const externalData = await fetchExternalPricing(); - // Apply pricing - } catch (error) { - console.error('External pricing failed:', error); - // Continue without failing the entire pricing - } - - return super.calculate(); -} -``` - -### 5. Cache External Calls - -```typescript -const priceCache = new Map(); - -async calculate() { - const cacheKey = `${this.context.product._id}-${this.context.currencyCode}`; - - if (!priceCache.has(cacheKey)) { - const price = await this.fetchExternalPrice(); - priceCache.set(cacheKey, { price, expires: Date.now() + 60000 }); - } - - const cached = priceCache.get(cacheKey); - if (cached.expires > Date.now()) { - // Use cached price - } -} -``` +Choose `orderIndex` relative to the adapters you load. The catalog price runs at `0`, the built-in product discount adapter at `30`, and the country tax adapters at `80`; inspect custom adapters before choosing an index. Store the adapter key and adjustment reason in each row's `meta` so calculations can be inspected. ## Related -- [Pricing System](../concepts/pricing-system) - Pricing architecture overview -- [Order Discounts](../extend/pricing/order-discounts) - Discount system -- [Product Pricing](../extend/pricing/product-pricing) - Product pricing details +- [Pricing System](../concepts/pricing-system.md) +- [Order Discounts](../extend/pricing/order-discounts.md) +- [Product Pricing](../extend/pricing/product-pricing.md) diff --git a/docs/docs/guides/file-uploads.md b/docs/docs/guides/file-uploads.md index 80ec858aae..7d8ea9662f 100644 --- a/docs/docs/guides/file-uploads.md +++ b/docs/docs/guides/file-uploads.md @@ -55,7 +55,7 @@ MINIO_SECRET_KEY=minioadmin MINIO_BUCKET_NAME=unchained-files # Webhook (for automatic confirmation) -MINIO_WEBHOOK_AUTH_TOKEN=your-secure-jwt-token +MINIO_WEBHOOK_AUTH_TOKEN=your-secure-webhook-token ``` ### Bucket Setup @@ -153,10 +153,10 @@ Import the webhook handler in your boot file: ```typescript // For Express -import { minioHandler } from '@unchainedshop/plugins/files/minio/minio-webhook-express'; +import minioHandler from '@unchainedshop/plugins/files/minio/handler-express.js'; // For Fastify -import { minioHandler } from '@unchainedshop/plugins/files/minio/minio-webhook-fastify'; +import minioHandler from '@unchainedshop/plugins/files/minio/handler-fastify.js'; ``` ### Configure MinIO Webhook @@ -167,7 +167,7 @@ import { minioHandler } from '@unchainedshop/plugins/files/minio/minio-webhook-f # Set webhook endpoint mc admin config set local notify_webhook:unchained \ endpoint="https://your-engine.com/minio" \ - auth_token="your-secure-jwt-token" + auth_token="your-secure-webhook-token" # Restart MinIO to apply mc admin service restart local diff --git a/docs/docs/guides/index.md b/docs/docs/guides/index.md index 43ec903846..dd2eb309ec 100644 --- a/docs/docs/guides/index.md +++ b/docs/docs/guides/index.md @@ -38,7 +38,7 @@ Practical, step-by-step guides for common development tasks with Unchained Engin Before following these guides, ensure you have: -1. Node.js 22+ installed +1. Node.js 26.8.2 or newer installed 2. MongoDB running (or MongoDB Memory Server for development) 3. Basic knowledge of TypeScript and GraphQL 4. An Unchained Engine project set up (see [Quick Start](../quick-start/)) diff --git a/docs/docs/guides/multi-currency-setup.md b/docs/docs/guides/multi-currency-setup.md index d2043596ee..7f44a971e1 100644 --- a/docs/docs/guides/multi-currency-setup.md +++ b/docs/docs/guides/multi-currency-setup.md @@ -11,7 +11,7 @@ This guide covers configuring multiple currencies and handling currency conversi ## Overview -Unchained Engine supports multiple currencies with automatic conversion: +Unchained Engine supports explicit prices in multiple currencies and conversion through the rate-conversion pricing plugin: ``` ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ @@ -30,7 +30,7 @@ Create currencies in the system: mutation CreateCurrency { createCurrency(currency: { isoCode: "EUR" - contractAddress: null # For crypto currencies + contractAddress: null # For token currencies }) { _id isoCode @@ -63,7 +63,7 @@ Set the default currency via environment variable: ```bash # .env -CURRENCY=CHF # Default currency +UNCHAINED_CURRENCY=CHF # Fallback currency used during currency resolution ``` ### 3. Set Country Defaults @@ -138,171 +138,58 @@ Unchained has a generic currency conversion system that allows you to integrate ### Product Price Rates API -To insert or update rates programmatically: +Store rates with a validity window. The numeric rate converts major units; the rate lookup accounts for currency decimals when producing a minor-unit conversion factor. ```typescript -// Insert/update rates -await modules.products.prices.rates.updateRates(productPriceRates); - -// Get a rate for a currency pair -const rate = await modules.products.prices.rates.getRate( - baseCurrency, // e.g., 'CHF' - quoteCurrency, // e.g., 'EUR' - referenceDate // Maximum age of rate -); -``` - -The `timestamp` field in rate entries determines freshness: -- When set to a UNIX timestamp, only rates within the specified maximum age are returned -- When set to `null`, the rate is always returned regardless of age - -The system automatically handles inverse rates - if you have `CHF/EUR`, querying `EUR/CHF` returns the inverse. - -### Rate Conversion Plugin - -The built-in `shop.unchained.pricing.rate-conversion` plugin consumes these rates. Configure the maximum rate age: - -```bash -# Maximum age in seconds (default: 600 = 10 minutes) -CRYPTOPAY_MAX_RATE_AGE=600 -``` - -### Manual Exchange Rates - -Set exchange rates manually via the API: - -```typescript -// Update exchange rates programmatically await modules.products.prices.rates.updateRates([ { baseCurrency: 'CHF', quoteCurrency: 'EUR', - rate: 0.92, - timestamp: Date.now(), + rate: 0.92, // Illustrative fixture rate + timestamp: new Date(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), }, ]); + +const baseCurrency = await modules.currencies.findCurrency({ isoCode: 'CHF' }); +const quoteCurrency = await modules.currencies.findCurrency({ isoCode: 'EUR' }); +if (!baseCurrency || !quoteCurrency) throw new Error('Configure both currencies first'); +const rateData = await modules.products.prices.rates.getRate( + baseCurrency, + quoteCurrency, + new Date(), +); +// rateData is { rate, expiresAt } or null. ``` -### Automatic Exchange Rate Updates +`getRate()` takes currency objects and a reference date. It selects the newest rate whose `timestamp` is on or before that date and whose `expiresAt` is on or after it. Missing or expired validity windows do not match. Inverse pairs are supported automatically. -Use a worker to fetch rates periodically: +### Rate Conversion Plugin ```typescript -import '@unchainedshop/plugins/worker/update-coinbase-rates'; - -// Configure the worker -WorkerDirector.configureAutoscheduling({ - type: 'UPDATE_COINBASE_RATES', - input: { - baseCurrency: 'CHF', - }, - schedule: '0 0 * * *', // Daily at midnight -}); +import '@unchainedshop/plugins/pricing/product-catalog-price.js'; +import '@unchainedshop/plugins/pricing/product-price-rateconversion.js'; ``` -### Custom Exchange Rate Provider +The catalog adapter runs first. The rate-conversion adapter (`shop.unchained.pricing.rate-conversion`, order index 10) runs when no earlier adapter has produced a price. It finds a source price for the requested country and quantity, requires both currencies to be active, and uses `modules.products.prices.rates.getRate()` to convert it. It preserves tax flags and multiplies the converted unit price by quantity. -Create a worker to fetch rates from your preferred provider: +A direct price in another country does not bypass country selection: configure the relevant country prices as well as currencies. The conversion adapter uses the stored rate validity window; it does not read `CRYPTOPAY_MAX_RATE_AGE`. -```typescript -import { WorkerDirector, type IWorkerAdapter } from '@unchainedshop/core'; - -const ExchangeRateWorker: IWorkerAdapter = { - key: 'shop.example.worker.exchange-rates', - label: 'Custom Exchange Rate Worker', - version: '1.0.0', - type: 'UPDATE_EXCHANGE_RATES', - external: false, - maxParallelAllocations: 1, - - async doWork(input, unchainedAPI) { - const { baseCurrency } = input; - - // Fetch rates from your provider (e.g., Open Exchange Rates, Fixer.io) - const response = await fetch( - `https://api.exchangerate-api.com/v4/latest/${baseCurrency}` - ); - const data = await response.json(); - - // Convert to rate entries with timestamps - const rates = Object.entries(data.rates).map(([currency, rate]) => ({ - baseCurrency, - quoteCurrency: currency, - rate: rate as number, - timestamp: Date.now(), - })); - - // Update rates in database - await unchainedAPI.modules.products.prices.rates.updateRates(rates); - - return { success: true, result: { updated: rates.length } }; - }, -}; +### Automatic Exchange Rate Updates -WorkerDirector.registerAdapter(ExchangeRateWorker); +The built-in Coinbase worker fetches configured currencies and registers a schedule that runs every minute: -// Schedule hourly updates -WorkerDirector.configureAutoscheduling({ - type: 'UPDATE_EXCHANGE_RATES', - input: { baseCurrency: 'CHF' }, - schedule: '0 * * * *', -}); +```typescript +import '@unchainedshop/plugins/worker/update-coinbase-rates.js'; ``` -## Currency Conversion Pricing Adapter - -Create a pricing adapter for automatic conversion: +It chooses its base currency with the configured currency fallback, stores `Date` timestamps, and gives rates a five-minute validity window. It does not read a `baseCurrency` input. Keep the normal worker queue enabled so scheduled tasks execute. -```typescript -import { - ProductPricingAdapter, - ProductPricingDirector, -} from '@unchainedshop/core-pricing'; - -class CurrencyConversionAdapter extends ProductPricingAdapter { - static key = 'shop.unchained.pricing.currency-conversion'; - static orderIndex = 1; // Run early - - static isActivatedFor({ currencyCode, product }) { - // Only if product has no price in requested currency - const hasDirectPrice = product.commerce?.pricing?.some( - (p) => p.currencyCode === currencyCode - ); - return !hasDirectPrice; - } - - async calculate() { - const { product, currencyCode, modules } = this.context; - - // Get base price - const basePrice = product.commerce?.pricing?.[0]; - if (!basePrice) return super.calculate(); - - // Get exchange rate - const rate = await modules.currencies.getExchangeRate( - basePrice.currencyCode, - currencyCode - ); - - if (rate) { - this.result.addItem({ - amount: Math.round(basePrice.amount * rate), - isTaxable: basePrice.isTaxable, - isNetPrice: basePrice.isNetPrice, - meta: { - adapter: this.constructor.key, - convertedFrom: basePrice.currencyCode, - rate, - }, - }); - } +For another provider, implement a [worker adapter](../extend/worker.md) that calls `modules.products.prices.rates.updateRates()` with the same rate structure. Pass parsed schedules from `schedule.parse.cron(...)` to `WorkerDirector.configureAutoscheduling()` rather than a cron string. - return super.calculate(); - } -} +## Currency Conversion Pricing Adapter -ProductPricingDirector.registerAdapter(CurrencyConversionAdapter); -``` +The built-in conversion adapter already handles source-price selection, active currencies, decimals, validity windows, and quantity. To customize it, compose `ProductPricingAdapter` from `@unchainedshop/core` using the current [pricing adapter pattern](../concepts/director-adapter-pattern.md#pricing-directors). Read prior rows from `params.calculationSheet`, add converted rows with `baseActions.resultSheet().addItem(...)`, and return `baseActions.calculate()`. ## Querying Prices @@ -419,6 +306,7 @@ function CurrencySelector() { ### Format Currency ```typescript +// This helper is for currencies with two decimal places. export function formatPrice(amount: number, currency: string): string { const formatter = new Intl.NumberFormat(getLocale(), { style: 'currency', @@ -436,8 +324,9 @@ const formatters: Record = { USD: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }), }; +// This example handles CHF, EUR, and USD, all with two decimal places. export function formatCurrency(amount: number, currency: string): string { - const formatter = formatters[currency] || formatters.CHF; + const formatter = formatters[currency] || new Intl.NumberFormat('en', { style: 'currency', currency }); return formatter.format(amount / 100); } ``` @@ -482,7 +371,7 @@ query CartCurrency { } ``` -The cart currency is automatically determined by the user's country and its default currency setting. To change the effective currency for pricing, you would typically update the user's country or configure the order's context. +The initial cart currency is resolved from its country and configured currencies. Existing orders store `currencyCode`; changing a storefront display preference does not automatically change the cart currency. Use the cart APIs and configured checkout flow to update the order, and read its returned currency before displaying totals. ## Cryptocurrency Support @@ -505,45 +394,17 @@ mutation CreateCryptoCurrency { ### Crypto Pricing -```typescript -class CryptoPricingAdapter extends ProductPricingAdapter { - static key = 'shop.unchained.pricing.crypto'; - static orderIndex: 2; - - static isActivatedFor({ currencyCode }) { - return ['ETH', 'BTC', 'USDC'].includes(currencyCode); - } - - async calculate() { - const { currencyCode, modules } = this.context; - - // Get crypto exchange rate - const rate = await fetchCryptoRate(currencyCode); - - // Convert from base currency - const baseTotal = this.calculation.sum({ category: 'BASE' }); - - this.result.addItem({ - amount: convertToCrypto(baseTotal, rate, currencyCode), - isTaxable: false, - isNetPrice: true, - meta: { cryptoRate: rate }, - }); - - return super.calculate(); - } -} -``` +The same rate-conversion adapter can convert between configured fiat and cryptocurrency entries. Configure each currency's `decimals`, provide rates with validity windows, and enable the applicable payment adapter. `getRate()` normalizes the conversion factor for source and target decimals; applying an additional decimal multiplier would double-convert the amount. ## Best Practices ### 1. Store Amounts in Smallest Unit -Always use the smallest unit (cents, wei, etc.): +Use integer amounts in the engine’s currency representation. Fiat currencies typically use cents. Product rate normalization defaults to two decimals when unspecified and caps cryptocurrency precision at nine decimals, even when the blockchain uses more (for example ETH at 18). Convert to blockchain base units at the payment boundary: ```typescript // Good -const price = 4999; // 49.99 CHF +const price = 4999; // 49.99 CHF (2 decimals) // Bad const price = 49.99; // Floating point issues @@ -558,28 +419,9 @@ Be consistent with rounding: const converted = Math.round(basePrice * exchangeRate); ``` -### 3. Cache Exchange Rates - -Don't fetch rates on every request: - -```typescript -// Cache rates for 1 hour -const rateCache = new Map(); - -async function getExchangeRate(from: string, to: string): Promise { - const key = `${from}-${to}`; - const cached = rateCache.get(key); - - if (cached && cached.expires > Date.now()) { - return cached.rate; - } +### 3. Respect Rate Expiry - const rate = await fetchRate(from, to); - rateCache.set(key, { rate, expires: Date.now() + 3600000 }); - - return rate; -} -``` +The rate store is already shared through MongoDB. If a custom integration adds an in-memory cache, expire each entry no later than the rate's `expiresAt` value, and refresh it when the underlying rates change. A fixed one-hour cache can keep serving a rate that has already expired. ### 4. Show Original and Converted Prices diff --git a/docs/docs/guides/multi-language-setup.md b/docs/docs/guides/multi-language-setup.md index 1920e2af47..d75ff76462 100644 --- a/docs/docs/guides/multi-language-setup.md +++ b/docs/docs/guides/multi-language-setup.md @@ -325,24 +325,28 @@ export function createApolloClient(locale: string) { Unchained resolves the locale automatically from the `Accept-Language` HTTP header. The locale is available in the GraphQL context and affects how `texts` fields are resolved. -The resolution order is: -1. `Accept-Language` header from the request -2. Default language from the `LANG` environment variable -3. Fallback to `en` +The resolver matches `Accept-Language` against active languages and countries. `x-shop-country` can constrain the country. Fallback selection prefers `UNCHAINED_LANG` (default `de`) and `UNCHAINED_COUNTRY` (default `CH`) when active, then the first active language and country. If no valid configured locale can be constructed, it uses the system locale from those environment variables. ## Bulk Import with Translations ```typescript -await modules.bulkImporter.prepare({ - entity: 'PRODUCT', - data: { - _id: 'product-123', - type: 'SIMPLE', - texts: [ - { locale: 'en', title: 'T-Shirt', slug: 't-shirt' }, - { locale: 'de', title: 'T-Shirt', slug: 't-shirt-de' }, - ], - // ... other fields +await unchainedAPI.modules.worker.addWork({ + type: 'BULK_IMPORT', + input: { + events: [{ + entity: 'PRODUCT', + operation: 'CREATE', + payload: { + _id: 'product-123', + specification: { + type: 'SIMPLE_PRODUCT', + content: { + en: { title: 'T-Shirt', slug: 't-shirt' }, + de: { title: 'T-Shirt', slug: 't-shirt-de' }, + }, + }, + }, + }], }, }); ``` diff --git a/docs/docs/guides/payment-integration.md b/docs/docs/guides/payment-integration.md index d4e616346f..69e8ae5310 100644 --- a/docs/docs/guides/payment-integration.md +++ b/docs/docs/guides/payment-integration.md @@ -22,11 +22,11 @@ flowchart LR | Provider | Type | Use Case | |----------|------|----------| -| [Stripe](../plugins/payment/stripe.md) | CARD | Credit/debit cards | +| [Stripe](../plugins/payment/stripe.md) | GENERIC | Credit/debit cards | | [PayPal](../plugins/payment/paypal-checkout.md) | GENERIC | PayPal checkout | -| [Braintree](../plugins/payment/braintree.md) | CARD | Cards, PayPal | -| [Datatrans](../plugins/payment/datatrans.md) | CARD | Swiss payment gateway | -| [Saferpay](../plugins/payment/saferpay.md) | CARD | Swiss payment gateway | +| [Braintree](../plugins/payment/braintree.md) | GENERIC | Cards, PayPal | +| [Datatrans](../plugins/payment/datatrans.md) | GENERIC | Swiss payment gateway | +| [Saferpay](../plugins/payment/saferpay.md) | GENERIC | Swiss payment gateway | | [Cryptopay](../plugins/payment/cryptopay.md) | GENERIC | Cryptocurrency | | [Invoice](../plugins/payment/invoice.md) | INVOICE | Manual invoicing | @@ -40,13 +40,13 @@ npm install stripe ```typescript // boot.ts -import '@unchainedshop/plugins/payment/stripe'; +import '@unchainedshop/plugins/payment/stripe/index.js'; ``` ```bash # .env -STRIPE_SECRET_KEY=sk_test_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_SECRET=sk_test_xxx +STRIPE_ENDPOINT_SECRET=whsec_xxx ``` ### 2. Create Payment Provider @@ -71,110 +71,27 @@ mutation CreateStripeProvider { ### 3. Frontend Integration -```tsx -import { loadStripe } from '@stripe/stripe-js'; -import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; +1. Select the Stripe payment provider on the cart. +2. Call `signPaymentProviderForCheckout(orderPaymentId: ...)`. The returned string is the PaymentIntent client secret; there is no `payment.clientSecret` GraphQL field. +3. Initialize Stripe Elements with that client secret and collect payment details. +4. Confirm the payment with Stripe. The built-in webhook handler processes successful payments and advances the Unchained order. If your integration calls `checkoutCart` directly, pass the intent ID as `paymentContext: { paymentIntentId }`. +5. Query the order to display its current status. A browser redirect alone does not confirm the order. -const stripePromise = loadStripe('pk_test_xxx'); - -function CheckoutForm() { - const stripe = useStripe(); - const elements = useElements(); - const [signPayment] = useMutation(SIGN_PAYMENT); - const [checkout] = useMutation(CHECKOUT); - - const handleSubmit = async (e) => { - e.preventDefault(); - - // Get client secret from Unchained - const { data } = await signPayment({ - variables: { orderPaymentId: cart.payment._id }, - }); - - // Confirm payment with Stripe - const { error, paymentIntent } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: `${window.location.origin}/checkout/complete`, - }, - redirect: 'if_required', - }); - - if (error) { - setError(error.message); - } else if (paymentIntent.status === 'succeeded') { - // Complete checkout - await checkout({ - variables: { - paymentContext: { paymentIntentId: paymentIntent.id }, - }, - }); - } - }; - - return ( -
- - - - ); -} - -function PaymentPage() { - const { data } = useQuery(GET_CART); - const clientSecret = data?.me?.cart?.payment?.clientSecret; - - if (!clientSecret) return
Loading...
; - - return ( - - - - ); -} -``` +See the [Stripe plugin guide](../plugins/payment/stripe.md) for the provider-specific flow and options. ### 4. Webhook Handler -```typescript -// api/webhooks/stripe.ts -import Stripe from 'stripe'; -import { buffer } from 'micro'; - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - -export const config = { api: { bodyParser: false } }; - -export default async function handler(req, res) { - const sig = req.headers['stripe-signature']; - const buf = await buffer(req); - - let event; - try { - event = stripe.webhooks.constructEvent( - buf, - sig, - process.env.STRIPE_WEBHOOK_SECRET - ); - } catch (err) { - return res.status(400).send(`Webhook Error: ${err.message}`); - } +Use the built-in Stripe webhook handler through the framework plugin preset, which handles raw request bodies, signature verification, and the Unchained order transition. For the Fastify kitchensink this is configured with: - switch (event.type) { - case 'payment_intent.succeeded': - // Payment successful - order will auto-confirm - console.log('Payment succeeded:', event.data.object.id); - break; - case 'payment_intent.payment_failed': - // Payment failed - console.log('Payment failed:', event.data.object.id); - break; - } +```typescript +import initPluginMiddlewares from '@unchainedshop/plugins/presets/all-fastify.js'; +import { connect } from '@unchainedshop/api/fastify'; - res.json({ received: true }); -} +connect(fastify, platform, { initPluginMiddlewares }); ``` +The default webhook path is `/payment/stripe` (override with `STRIPE_WEBHOOK_PATH`). Set `STRIPE_ENDPOINT_SECRET` to the endpoint signing secret. The all-plugin preset also initializes routes for other plugins; see the Stripe plugin guide for an individual integration. + ## Payment Flow ### Standard Flow @@ -256,97 +173,66 @@ Most payment adapters use environment variables: ```bash # Stripe -STRIPE_SECRET_KEY=sk_xxx -STRIPE_PUBLISHABLE_KEY=pk_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_SECRET=sk_xxx +# Configure the Stripe publishable key in your storefront +STRIPE_ENDPOINT_SECRET=whsec_xxx # PayPal PAYPAL_CLIENT_ID=xxx -PAYPAL_CLIENT_SECRET=xxx -PAYPAL_ENVIRONMENT=sandbox # or production +PAYPAL_SECRET=xxx +PAYPAL_ENVIRONMENT=sandbox # or live # Datatrans DATATRANS_MERCHANT_ID=xxx -DATATRANS_PASSWORD=xxx +DATATRANS_SECRET=xxx DATATRANS_SIGN_KEY=xxx ``` ## Custom Payment Adapter -Create a custom adapter for payment gateways not covered by built-in plugins: +Create a custom adapter for payment gateways not covered by built-in plugins. This example assumes `myGateway` is your gateway client and its session/payment methods return the illustrated values: ```typescript -import { PaymentDirector, type IPaymentAdapter } from '@unchainedshop/core'; +import { + PaymentAdapter, PaymentDirector, PaymentError, OrderPricingSheet, + type IPaymentAdapter, +} from '@unchainedshop/core'; const MyPaymentAdapter: IPaymentAdapter = { + ...PaymentAdapter, key: 'com.mycompany.payment.custom', label: 'My Payment Gateway', version: '1.0.0', - - typeSupported(type) { - return type === 'CARD'; - }, - - actions(params) { - const { paymentContext, context } = params; - const { order, orderPayment } = paymentContext; - + typeSupported: (type) => type === 'GENERIC', + actions(configuration, context) { + const baseActions = PaymentAdapter.actions(configuration, context); return { - configurationError() { - if (!process.env.MY_GATEWAY_API_KEY) { - return { code: 'MISSING_API_KEY' }; - } - return null; - }, - - isActive() { - return true; - }, - - isPayLaterAllowed() { - return false; // Require payment before order confirmation - }, - + ...baseActions, + configurationError: () => process.env.MY_GATEWAY_API_KEY + ? null : PaymentError.INCOMPLETE_CONFIGURATION, + isActive: () => Boolean(process.env.MY_GATEWAY_API_KEY), + isPayLaterAllowed: () => false, async sign() { - // Create payment session with gateway + const { order } = context; + if (!order) throw new Error('Order is required'); + const pricing = OrderPricingSheet({ + calculation: order.calculation, + currencyCode: order.currencyCode, + }); + const { amount, currencyCode } = pricing.total(); const session = await myGateway.createSession({ - amount: order.pricing().total().amount, - currency: order.currency, + amount, + currency: currencyCode, orderId: order._id, }); return session.clientToken; }, - - async charge() { - // Check if payment is complete - const { transactionId } = orderPayment.context || {}; - if (transactionId) { - const payment = await myGateway.getPayment(transactionId); - if (payment.status === 'completed') { - return { transactionId }; - } - } - return false; - }, - - async cancel() { - const { transactionId } = orderPayment; - if (transactionId) { - await myGateway.refund(transactionId); - } - return true; - }, - - async confirm() { - return { transactionId: orderPayment.transactionId }; - }, - - async register() { - return { token: '' }; - }, - - async validate() { - return true; + async charge({ transactionId } = {}) { + if (!transactionId) return false; + const payment = await myGateway.getPayment(transactionId); + // Validate the gateway's order reference, amount, and currency here. + if (payment.status !== 'completed') return false; + return { transactionId }; }, }; }, @@ -355,6 +241,8 @@ const MyPaymentAdapter: IPaymentAdapter = { PaymentDirector.registerAdapter(MyPaymentAdapter); ``` +Implement gateway-specific validation before returning a successful charge. The base `cancel`, `confirm`, `register`, and `validate` methods remain available to override. `charge()` receives the transaction context passed by checkout; adapter context is the second argument to `actions(configuration, context)`. + ## Testing Payments ### Test Mode @@ -363,21 +251,16 @@ Most payment providers have test/sandbox modes: ```bash # Stripe test keys -STRIPE_SECRET_KEY=sk_test_xxx -STRIPE_PUBLISHABLE_KEY=pk_test_xxx +STRIPE_SECRET=sk_test_xxx +# Configure the Stripe test publishable key in your storefront # PayPal sandbox PAYPAL_ENVIRONMENT=sandbox ``` -### Test Card Numbers +### Test Payment Details -| Provider | Card Number | Description | -|----------|-------------|-------------| -| Stripe | 4242 4242 4242 4242 | Successful payment | -| Stripe | 4000 0000 0000 0002 | Declined | -| Stripe | 4000 0025 0000 3155 | Requires 3DS | -| PayPal | N/A | Use sandbox accounts | +Use the provider's current test-card documentation and sandbox accounts. The [Stripe plugin guide](../plugins/payment/stripe.md) describes the Unchained test configuration. ### Testing Webhooks Locally @@ -385,10 +268,10 @@ Use Stripe CLI or ngrok for local webhook testing: ```bash # Stripe CLI -stripe listen --forward-to localhost:3000/api/webhooks/stripe +stripe listen --forward-to localhost:4010/payment/stripe # ngrok -ngrok http 3000 +ngrok http 4010 # Configure webhook URL in Stripe dashboard ``` @@ -428,66 +311,49 @@ try { ## Payment Fees -Add payment processing fees to orders: +Payment pricing adapters compose `PaymentPricingAdapter` and add fees with `resultSheet().addFee()`: ```typescript -import { PaymentPricingDirector, PaymentPricingAdapter } from '@unchainedshop/core'; - -class CardFeeAdapter extends PaymentPricingAdapter { - static key = 'shop.unchained.pricing.card-fee'; - static orderIndex = 0; - - static isActivatedFor({ provider }) { - return provider.type === 'CARD'; - } - - async calculate() { - const { order } = this.context; - const total = order.pricing().total().amount; - - // 2.9% + 30 cents - const fee = Math.round(total * 0.029 + 30); - - this.result.addItem({ - amount: fee, - isTaxable: false, - isNetPrice: true, - category: 'PAYMENT', - }); - - return super.calculate(); - } -} +import { + PaymentPricingAdapter, PaymentPricingDirector, + type IPaymentPricingAdapter, +} from '@unchainedshop/core'; + +const ExamplePaymentFee: IPaymentPricingAdapter = { + ...PaymentPricingAdapter, + key: 'com.example.pricing.payment-fee', + label: 'Example payment fee', + version: '1.0.0', + orderIndex: 10, + isActivatedFor: ({ provider, currencyCode }) => + provider.type === 'GENERIC' && currencyCode === 'CHF', + actions(params) { + const baseActions = PaymentPricingAdapter.actions(params); + return { + ...baseActions, + async calculate() { + baseActions.resultSheet().addFee({ + amount: 30, // Illustrative fixed CHF 0.30 fee + isTaxable: false, + isNetPrice: true, + meta: { adapter: ExamplePaymentFee.key }, + }); + return baseActions.calculate(); + }, + }; + }, +}; -PaymentPricingDirector.registerAdapter(CardFeeAdapter); +PaymentPricingDirector.registerAdapter(ExamplePaymentFee); ``` +For a percentage fee, derive the intended subtotal from order positions and their calculation sheets. Reading a previous final order total can include the payment fee itself and create repeated recalculation errors. + ## Multi-Currency Support -Handle multiple currencies: +Orders store `currencyCode` and calculation rows. In `actions(configuration, context)`, read `context.order`, construct `OrderPricingSheet({ calculation: order.calculation, currencyCode: order.currencyCode })`, and use `total()` to obtain `{ amount, currencyCode }`. Convert the currency code or amount only as required by your gateway's API. -```typescript -async sign() { - const { order } = this.paymentContext; - - const session = await stripe.checkout.sessions.create({ - payment_method_types: ['card'], - line_items: [{ - price_data: { - currency: order.currency.toLowerCase(), - product_data: { name: `Order ${order.orderNumber}` }, - unit_amount: order.pricing().total().amount, - }, - quantity: 1, - }], - mode: 'payment', - success_url: `${process.env.ROOT_URL}/checkout/success`, - cancel_url: `${process.env.ROOT_URL}/checkout/cancel`, - }); - - return session.id; -} -``` +The built-in Stripe adapter uses this approach and verifies that the completed PaymentIntent matches the order payment, amount, and currency. ## Related diff --git a/docs/docs/guides/ticketing-setup.md b/docs/docs/guides/ticketing-setup.md index f459072b53..c29bb8940d 100644 --- a/docs/docs/guides/ticketing-setup.md +++ b/docs/docs/guides/ticketing-setup.md @@ -7,556 +7,121 @@ description: Configure event ticketing with PDF tickets, Apple Wallet, and Googl # Event Ticketing Setup -This guide covers setting up the `@unchainedshop/ticketing` extension for event ticketing functionality, including PDF ticket generation and mobile wallet passes. - -## Overview - -The Unchained Ticketing extension provides: - -- **PDF Tickets**: Generate downloadable PDF tickets for orders -- **Apple Wallet**: Create `.pkpass` files for Apple Wallet -- **Google Wallet**: Generate Google Wallet pass links -- **Magic Key Access**: Allow users to access tickets without logging in - -``` -┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐ -│ Order │────▶│ Ticketing API │────▶│ Ticket Renderers │ -│ (Tokens) │ │ (Magic Keys) │ │ (PDF, Wallet) │ -└─────────────┘ └──────────────────┘ └─────────────────────┘ -``` -By default the module provides a SVG bare bone ticket, google wallet and apple pass templates. -In order to use google wallet and apple pass templates you need to install the required dependencies `googleapis` & `jsonwebtoken` for google wallet `@walletpass/pass-js` for apple pass beforehand. +`@unchainedshop/ticketing` adds order PDF downloads, Apple Wallet and Google Wallet routes, pass storage, and order access through magic keys. Your application supplies the renderers; the package does not include default SVG/PDF templates or wallet template configurators. ## Installation ```bash npm install @unchainedshop/ticketing -npm install googleapis jsonwebtoken @walletpass/pass-js ``` -## Basic Setup +Install the rendering libraries your application uses separately. For Apple Wallet update notifications, install the optional `@parse/node-apn` peer dependency and configure `PASS_CERTIFICATE_PATH` and `PASS_CERTIFICATE_SECRET`. + +## Configure the Platform -### 1. Configure Platform with Ticketing +Implement the three renderer modules imported below according to the contracts in the next section: ```typescript import Fastify from 'fastify'; import { startPlatform } from '@unchainedshop/platform'; +import { connect } from '@unchainedshop/api/fastify'; import baseModules from '@unchainedshop/plugins/presets/base.js'; -import connectBasePluginsToFastify from '@unchainedshop/plugins/presets/base-fastify.js'; -import { connect, unchainedLogger } from '@unchainedshop/api/fastify'; -import setupTicketing, { ticketingModules, type TicketingAPI } from '@unchainedshop/ticketing'; -import connectTicketingToFastify from '@unchainedshop/ticketing/lib/fastify.js'; -import ticketingServices from '@unchainedshop/ticketing/lib/services.js'; -import configureAppleWalletPass from '@unchainedshop/ticketing/lib/pdf-tickets/configureAppleWalletPass.js'; -import configureGoogleWalletPass from '@unchainedshop/ticketing/lib/pdf-tickets/configureGoogleWalletPass.js'; - -const fastify = Fastify({ - loggerInstance: unchainedLogger('fastify'), - disableRequestLogging: true, - trustProxy: true, -}); - +import connectBasePlugins from '@unchainedshop/plugins/presets/base-fastify.js'; +import setupTicketing, { + ticketingModules, + ticketingServices, + type TicketingAPI, +} from '@unchainedshop/ticketing'; +import connectTicketing from '@unchainedshop/ticketing/lib/fastify.js'; +import renderOrderPDF from './renderers/order-pdf.js'; +import createAppleWalletPass from './renderers/apple-wallet.js'; +import createGoogleWalletPass from './renderers/google-wallet.js'; + +const app = Fastify(); const platform = await startPlatform({ modules: { ...baseModules, ...ticketingModules }, services: { ...ticketingServices }, }); -// Setup ticketing with your custom renderers setupTicketing(platform.unchainedAPI as TicketingAPI, { - renderOrderPDF: undefined, // use default SVG template - createAppleWalletPass: configureAppleWalletPass({ - templateConfig: { - description: 'Event Ticket', - organizationName: 'Unchained Commerce', - passTypeIdentifier: process.env.PASS_TYPE_IDENTIFIER || 'pass.com.example.ticket', - teamIdentifier: process.env.PASS_TEAM_ID, - backgroundColor: 'rgb(255,255,255)', - foregroundColor: 'rgb(50,50,50)', - }, - // Optional: customize field labels for localization - labels: { - eventLabel: 'Event', - locationLabel: 'Venue', - ticketNumberLabel: 'Ticket #', - infoLabel: 'Details', - slotChangeMessage: 'Event time changed: %@', - barcodeHint: 'Scan for entry', - }, - }), - createGoogleWalletPass: configureGoogleWalletPass({ - issuerName: 'Unchained Commerce', - countryCode: 'CH', - hexBackgroundColor: '#FFFFFF', - homepageUri: { - uri: 'https://unchained.shop', - description: 'Event Website', - }, - }), + renderOrderPDF, + createAppleWalletPass, + createGoogleWalletPass, }); -// Connect Unchained to Fastify -connect(fastify, platform, { +await connect(app, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== 'production', - initPluginMiddlewares: (app) => { - connectBasePluginsToFastify(app); - connectTicketingToFastify(app); - } -}); - -await fastify.listen({ host: '::', port: 3000 }); -``` - -### 2. Express Alternative - -```typescript -import express from 'express'; -import setupTicketing, { ticketingModules } from '@unchainedshop/ticketing'; -import connectTicketingToExpress from '@unchainedshop/ticketing/lib/express.js'; -import ticketingServices from '@unchainedshop/ticketing/lib/services.js'; -import configureAppleWalletPass from '@unchainedshop/ticketing/lib/pdf-tickets/configureAppleWalletPass.js'; -import configureGoogleWalletPass from '@unchainedshop/ticketing/lib/pdf-tickets/configureGoogleWalletPass.js'; - -const app = express(); - -const platform = await startPlatform({ - modules: { ...baseModules, ...ticketingModules }, - services: { ...ticketingServices }, -}); - -setupTicketing(platform.unchainedAPI, { - renderOrderPDF: undefined, // use default SVG template - createAppleWalletPass: configureAppleWalletPass({ - templateConfig: { - description: 'Event Ticket', - organizationName: 'Unchained Commerce', - passTypeIdentifier: process.env.PASS_TYPE_IDENTIFIER || 'pass.com.example.ticket', - teamIdentifier: process.env.PASS_TEAM_ID, - backgroundColor: 'rgb(255,255,255)', - foregroundColor: 'rgb(50,50,50)', - }, - // Optional: customize field labels for localization - labels: { - eventLabel: 'Event', - locationLabel: 'Venue', - ticketNumberLabel: 'Ticket #', - infoLabel: 'Details', - slotChangeMessage: 'Event time changed: %@', - barcodeHint: 'Scan for entry', - }, - }), - createGoogleWalletPass: configureGoogleWalletPass({ - issuerName: 'Unchained Commerce', - countryCode: 'CH', - hexBackgroundColor: '#FFFFFF', - homepageUri: { - uri: 'https://unchained.shop', - description: 'Event Website', - }, - }), -}); - -connectTicketingToExpress(app); -``` - -## PDF Ticket Rendering - -Create a PDF renderer using `@react-pdf/renderer`: - -```bash -npm install @react-pdf/renderer -``` - -```tsx -import React from 'react'; -import ReactPDF, { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer'; -import QRCode from 'qrcode'; - -const styles = StyleSheet.create({ - page: { - padding: 30, - fontFamily: 'Helvetica', - }, - header: { - fontSize: 24, - marginBottom: 20, - textAlign: 'center', - }, - ticketContainer: { - border: '1px solid #ccc', - padding: 20, - marginBottom: 20, - }, - qrCode: { - width: 150, - height: 150, - alignSelf: 'center', - }, - details: { - marginTop: 20, - }, - label: { - fontSize: 10, - color: '#666', - }, - value: { - fontSize: 14, - marginBottom: 10, + initPluginMiddlewares(server) { + connectBasePlugins(server); + connectTicketing(server); }, }); -interface TicketData { - tokenId: string; - eventName: string; - eventDate: string; - venue: string; - seat?: string; - qrCodeUrl: string; -} - -const TicketDocument = ({ tickets, orderNumber }: { tickets: TicketData[]; orderNumber: string }) => ( - - - Your Tickets - Order: {orderNumber} - - {tickets.map((ticket, index) => ( - - {ticket.eventName} - - - - - Date - {ticket.eventDate} - - Venue - {ticket.venue} - - {ticket.seat && ( - <> - Seat - {ticket.seat} - - )} - - Ticket ID - {ticket.tokenId} - - - ))} - - -); - -// Export the renderer function -export default async function renderOrderPDF( - { orderId, variant }: { orderId: string; variant?: string }, - unchainedAPI: TicketingAPI -) { - const { modules } = unchainedAPI; - - const order = await modules.orders.findOrder({ orderId }); - const tokens = await modules.warehousing.findTokens({ orderId }); - - // Generate QR codes and prepare ticket data - const tickets = await Promise.all( - tokens.map(async (token) => { - const qrCodeUrl = await QRCode.toDataURL(token._id, { width: 300 }); - - return { - tokenId: token._id, - eventName: token.meta?.eventName || 'Event', - eventDate: token.meta?.eventDate || '', - venue: token.meta?.venue || '', - seat: token.meta?.seat, - qrCodeUrl, - }; - }) - ); - - return ReactPDF.renderToStream( - - ); -} +await app.listen({ host: '::', port: Number(process.env.PORT || 4010) }); ``` -## Apple Wallet Pass +For Express, use `connect` from `@unchainedshop/api/express`, the `base-express.js` preset connector, and `@unchainedshop/ticketing/lib/express.js` for the ticketing connector. Register ticketing routes through `initPluginMiddlewares` so the Unchained request context is available. -### Prerequisites +Set the [required platform environment variables](../platform-configuration/environment-variables.md) and `UNCHAINED_SECRET` before startup. Ticketing uses that additional secret to derive magic keys. -1. **Apple Developer Account** with Pass Type ID capability -2. **Pass Type ID** registered at [developer.apple.com](https://developer.apple.com/account) -3. **Production Certificate** for your Pass Type ID +## Renderer Contracts -### Certificate Setup +| Renderer | Arguments | Result | +|----------|-----------|--------| +| `renderOrderPDF` | `{ orderId, variant? }`, Unchained context | Promise of a Node readable stream containing a PDF | +| `createAppleWalletPass` | Token surrogate, Unchained context | Pass with `serialNumber`, `passTypeIdentifier`, and `asBuffer()` returning the signed `.pkpass` bytes | +| `createGoogleWalletPass` | Token surrogate, Unchained context | Pass with `asURL()` returning the Google Wallet save link | -1. Create a Pass Type ID in your Apple Developer account -2. Generate and download a production certificate -3. Import into Keychain Access -4. Export as `.p12` file (include both certificate and private key) -5. Convert to PEM format: +The callback types are available in `@unchainedshop/ticketing/lib/template-registry.js`. A renderer can query orders and products through `context.modules`. Tokens associated with an order are stored with `meta.orderId`: -```bash -openssl pkcs12 -in Certificates.p12 -legacy -clcerts -out cert_and_key.pem -``` - -### Environment Variables - -```bash -PASS_CERTIFICATE_PATH=./cert_and_key.pem -PASS_CERTIFICATE_SECRET=YOUR_PEM_PASSPHRASE -PASS_TEAM_ID=YOUR_TEAM_ID +```typescript +const order = await context.modules.orders.findOrder({ orderId }); +const tokens = await context.modules.warehousing.findTokens({ + 'meta.orderId': orderId, +}); ``` -### Apple Wallet Renderer +Use `token.tokenSerialNumber` and your product metadata to build ticket contents. Your renderers are responsible for signing wallet passes and configuring provider credentials. Passing `undefined` to a renderer option does not install a fallback renderer. -```bash -npm install @walletpass/pass-js -``` +## Magic Key Order Access ```typescript -import { Template, constants } from '@walletpass/pass-js'; -import path from 'path'; - -export default async function createAppleWalletPass( - token: { _id: string; meta: any }, - unchainedAPI: TicketingAPI -) { - const template = new Template('eventTicket', { - passTypeIdentifier: 'pass.com.yourcompany.tickets', - teamIdentifier: process.env.PASS_TEAM_ID, - organizationName: 'Your Company', - description: 'Event Ticket', - foregroundColor: 'rgb(255, 255, 255)', - backgroundColor: 'rgb(60, 65, 76)', - labelColor: 'rgb(255, 255, 255)', - }); - - // Load certificate - await template.loadCertificate( - process.env.PASS_CERTIFICATE_PATH, - process.env.PASS_CERTIFICATE_SECRET - ); - - // Add images (icon, logo, strip, etc.) - await template.images.add('icon', './assets/icon.png'); - await template.images.add('logo', './assets/logo.png'); - - const pass = await template.createPass({ - serialNumber: token._id, - relevantDate: token.meta?.eventDate, - locations: token.meta?.venue ? [{ - latitude: token.meta.latitude, - longitude: token.meta.longitude, - relevantText: token.meta.venue, - }] : undefined, - }); - - // Add ticket fields - pass.primaryFields.add({ - key: 'event', - label: 'EVENT', - value: token.meta?.eventName || 'Event', - }); - - pass.secondaryFields.add({ - key: 'date', - label: 'DATE', - value: token.meta?.eventDate || '', - }); - - pass.auxiliaryFields.add({ - key: 'venue', - label: 'VENUE', - value: token.meta?.venue || '', - }); - - if (token.meta?.seat) { - pass.auxiliaryFields.add({ - key: 'seat', - label: 'SEAT', - value: token.meta.seat, - }); - } - - // Add barcode - pass.barcodes = [{ - format: constants.barcodeFormat.QR, - message: token._id, - messageEncoding: 'iso-8859-1', - }]; - - return pass; -} +const magicKey = await context.modules.passes.buildMagicKey(orderId); ``` -## Google Wallet Pass +Send the resulting key in the `x-magic-key` header for GraphQL order and token access. The ticketing role extension grants `viewOrder`, `viewToken`, and `updateToken` for the matching order, subject to the token's original owner still owning it. -### Prerequisites - -1. **Google Cloud Project** with Wallet API enabled -2. **Service Account** with Wallet Object Creator role -3. **Issuer ID** from Google Pay & Wallet Console - -### Environment Variables +Magic keys are deterministic hashes derived from the order ID and `UNCHAINED_SECRET`. They are reusable and do not expire automatically. Treat links containing them as credentials. ```bash -GOOGLE_APPLICATION_CREDENTIALS=./service-account.json -GOOGLE_WALLET_ISSUER_ID=YOUR_ISSUER_ID +UNCHAINED_SECRET=your-random-ticketing-secret ``` -### Google Wallet Renderer - -```typescript -import { GoogleAuth } from 'google-auth-library'; -import jwt from 'jsonwebtoken'; - -const issuerId = process.env.GOOGLE_WALLET_ISSUER_ID; -const baseUrl = 'https://walletobjects.googleapis.com/walletobjects/v1'; - -export default async function createGoogleWalletPass( - token: { _id: string; meta: any }, - unchainedAPI: TicketingAPI -) { - const auth = new GoogleAuth({ - scopes: ['https://www.googleapis.com/auth/wallet_object.issuer'], - }); - - const client = await auth.getClient(); - const classId = `${issuerId}.event_${token.meta?.eventId || 'default'}`; - const objectId = `${issuerId}.ticket_${token._id}`; - - // Create or update event class - const eventClass = { - id: classId, - issuerName: 'Your Company', - eventName: { - defaultValue: { - language: 'en', - value: token.meta?.eventName || 'Event', - }, - }, - venue: { - name: { - defaultValue: { - language: 'en', - value: token.meta?.venue || '', - }, - }, - }, - dateTime: { - start: token.meta?.eventDate, - }, - reviewStatus: 'UNDER_REVIEW', - }; - - try { - await client.request({ - url: `${baseUrl}/eventTicketClass/${classId}`, - method: 'GET', - }); - } catch (err) { - // Class doesn't exist, create it - await client.request({ - url: `${baseUrl}/eventTicketClass`, - method: 'POST', - data: eventClass, - }); - } - - // Create ticket object - const ticketObject = { - id: objectId, - classId: classId, - state: 'ACTIVE', - ticketHolderName: token.meta?.holderName || '', - ticketNumber: token._id, - seatInfo: token.meta?.seat ? { - seat: { - defaultValue: { - language: 'en', - value: token.meta.seat, - }, - }, - } : undefined, - barcode: { - type: 'QR_CODE', - value: token._id, - }, - }; - - // Create JWT for "Add to Google Wallet" URL - const credentials = await auth.getCredentials(); - const payload = { - iss: credentials.client_email, - aud: 'google', - typ: 'savetowallet', - iat: Math.floor(Date.now() / 1000), - origins: ['https://yoursite.com'], - payload: { - eventTicketObjects: [ticketObject], - }, - }; - - const privateKey = credentials.private_key; - const token_jwt = jwt.sign(payload, privateKey, { algorithm: 'RS256' }); - - const saveUrl = `https://pay.google.com/gp/v/save/${token_jwt}`; - - return { - asURL: async () => saveUrl, - }; -} -``` +## API Endpoints -## Magic Key Order Access +| Default endpoint | Purpose | Configuration | +|------------------|---------|---------------| +| `/rest/print_tickets?orderId=...&otp=...` | Render order PDF | `UNCHAINED_PDF_PRINT_HANDLER_PATH` | +| `/rest/apple-wallet/download/:tokenId.pkpass?hash=...` | Download Apple Wallet pass | `APPLE_WALLET_WEBSERVICE_PATH` | +| `/rest/google-wallet/download/:tokenId?hash=...` | Redirect to Google Wallet | `GOOGLE_WALLET_WEBSERVICE_PATH` | -Magic keys allow users to access their orders and tickets without logging in - perfect for email links. +The PDF handler checks the `viewOrder` action. The Fastify handler requires string `orderId` and `otp` query parameters; supply the magic key through `x-magic-key` when using magic-key authorization. The `otp` query parameter by itself does not set that header. -### Generate Magic Key +Wallet download routes use a separate token access key, created from the token's current owner: ```typescript -// In your order confirmation handler -const magicKey = await modules.passes.buildMagicKey(orderId); - -// Include in confirmation email -const ticketUrl = `https://my-shop.com/orders/${orderId}?otp=${magicKey}`; -``` - -### Use Magic Key in API Requests - -```http -GET /graphql -x-magic-key: YOUR_MAGIC_KEY +const token = await context.modules.warehousing.findToken({ tokenId }); +if (!token) throw new Error('Token not found'); +const hash = await context.modules.warehousing.buildAccessKeyFromToken(token); +const appleURL = new URL(`/rest/apple-wallet/download/${tokenId}.pkpass`, process.env.ROOT_URL); +appleURL.searchParams.set('hash', hash); +const googleURL = new URL(`/rest/google-wallet/download/${tokenId}`, process.env.ROOT_URL); +googleURL.searchParams.set('hash', hash); ``` -### Protected Actions +Apple Wallet device registration and pass update requests are also handled under `APPLE_WALLET_WEBSERVICE_PATH`. -Magic keys provide access to: -- `viewOrder` - View order details -- `updateToken` - Update token information -- `viewToken` - View individual tickets - -### Environment Configuration - -```bash -# Required for magic key encryption -UNCHAINED_SECRET=your-secret-key-at-least-32-characters -``` - -## API Endpoints - -The ticketing extension adds these REST endpoints: - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/orders/:orderId/tickets.pdf` | GET | Download PDF tickets | -| `/tokens/:tokenId.pkpass` | GET | Download Apple Wallet pass | -| `/tokens/:tokenId/google-wallet` | GET | Redirect to Google Wallet | - -## GraphQL Integration - -Query tickets through order items: +## Query Order Tickets ```graphql query OrderTickets($orderId: ID!) { @@ -574,116 +139,22 @@ query OrderTickets($orderId: ID!) { } ``` -## Frontend Implementation - -### Ticket Download Component - -```tsx -function TicketDownload({ orderId, magicKey }: { orderId: string; magicKey?: string }) { - const baseUrl = process.env.NEXT_PUBLIC_API_URL; - - const pdfUrl = magicKey - ? `${baseUrl}/orders/${orderId}/tickets.pdf?otp=${magicKey}` - : `${baseUrl}/orders/${orderId}/tickets.pdf`; - - return ( - - ); -} -``` - -### Wallet Pass Buttons - -```tsx -function WalletButtons({ tokenId, magicKey }: { tokenId: string; magicKey?: string }) { - const baseUrl = process.env.NEXT_PUBLIC_API_URL; - const queryParams = magicKey ? `?otp=${magicKey}` : ''; - - return ( -
- - Add to Apple Wallet - - - - Add to Google Wallet - -
- ); -} -``` - -## Testing +## Run the Repository Example -The ticketing example includes test files: +From the repository root: ```bash -# Clone the repository -git clone https://github.com/unchainedshop/unchained.git - -# Navigate to ticketing example -cd unchained/examples/ticketing - -# Install dependencies -npm install - -# Run the example -npm start -``` - -## Best Practices - -### 1. Unique Serial Numbers - -Always use unique token IDs for pass serial numbers to enable updates: - -```typescript -pass.serialNumber = token._id; -``` - -### 2. Relevant Dates - -Include event dates for lock-screen notifications: - -```typescript -pass.relevantDate = new Date(token.meta.eventDate); -``` - -### 3. Location-Based Notifications - -Add venue coordinates for location-based pass display: - -```typescript -pass.locations = [{ - latitude: venue.lat, - longitude: venue.lng, - relevantText: 'Your event is nearby!', -}]; +nvm use +npm ci +npm run build:packages +npm run dev --workspace @unchainedshop/example-ticketing ``` -### 4. Pass Updates - -Implement push notifications for pass updates using Apple's push service. - -## Resources - -- **Ticketing Package**: [github.com/unchainedshop/unchained/tree/master/packages/ticketing](https://github.com/unchainedshop/unchained/tree/master/packages/ticketing) -- **Example Implementation**: [github.com/unchainedshop/unchained/tree/master/examples/ticketing](https://github.com/unchainedshop/unchained/tree/master/examples/ticketing) -- **Apple Wallet Documentation**: [developer.apple.com/wallet](https://developer.apple.com/wallet/) -- **Google Wallet API**: [developers.google.com/wallet](https://developers.google.com/wallet) +The example demonstrates wiring with placeholder renderers. Replace them with your implementations to generate downloadable tickets. ## Related -- [Warehousing Module](../platform-configuration/modules/warehousing) - Token management -- [Order Lifecycle](../concepts/order-lifecycle) - Order processing -- [Worker](../extend/worker) - Background job processing +- [Ticketing package](https://github.com/unchainedshop/unchained/tree/master/packages/ticketing) +- [Ticketing example](https://github.com/unchainedshop/unchained/tree/master/examples/ticketing) +- [Warehousing Module](../platform-configuration/modules/warehousing.md) +- [Order Lifecycle](../concepts/order-lifecycle.md) diff --git a/docs/docs/platform-configuration/environment-variables.md b/docs/docs/platform-configuration/environment-variables.md index f05c05a499..fcf8c2a706 100644 --- a/docs/docs/platform-configuration/environment-variables.md +++ b/docs/docs/platform-configuration/environment-variables.md @@ -1,6 +1,6 @@ # Environment Variables -This document provides a comprehensive list of all environment variables used by Unchained Engine (excluding plugins and ticketing). Most of the plugins and extensions (like ticketing) have their own environment variables, check their docs individually. +This document lists the main environment variables used by Unchained Engine. Most of the plugins and extensions (like ticketing) have their own environment variables, check their docs individually. ## Core Configuration @@ -8,8 +8,8 @@ This document provides a comprehensive list of all environment variables used by |----------|---------|-------------| | `NODE_ENV` | - | Node environment (development, test, production). Affects caching, logging, and other behaviors | | `PORT` | - | Base port number used by the application. MongoDB memory server uses PORT+1 | -| `MONGO_URL` | - | MongoDB connection URL. If not set, uses mongodb-memory-server in development/test | -| `UNCHAINED_API_VERSION` | `packageJson.version` | API version returned in GraphQL context, defaults to package.json version | +| `MONGO_URL` | - | MongoDB connection URL. If not set, starts a local MongoDB process with mongodb-memory-server; set this explicitly for production | +| `UNCHAINED_API_VERSION` | `npm_package_version` or `n/a` | Version reported by the platform; falls back to the npm-provided package version, then `n/a` | | `UNCHAINED_LANG` | `de` | Default language code | | `UNCHAINED_COUNTRY` | `CH` | Default country code | | `UNCHAINED_CURRENCY` | `CHF` | Default currency code | @@ -21,27 +21,26 @@ This document provides a comprehensive list of all environment variables used by | Variable | Default | Required | Description | |----------|---------|----------|-------------| -| `UNCHAINED_SECRET` | - | Yes | Secret key used for signing magic keys and tokens. Must be kept secure | | `UNCHAINED_TOKEN_SECRET` | - | Yes | Secret key for session tokens. Must be at least 32 characters long and kept secret, generate randomly by using `uuidgen` | -| `UNCHAINED_COOKIE_NAME` | `unchained_token` | Yes | Name of the session cookie | -| `UNCHAINED_COOKIE_PATH` | `/` | Yes |Cookie path | +| `UNCHAINED_COOKIE_NAME` | `unchained_token` | No | Name of the session cookie | +| `UNCHAINED_COOKIE_PATH` | `/` | No |Cookie path | | `UNCHAINED_COOKIE_DOMAIN` | - | No |Cookie domain restriction | -| `UNCHAINED_COOKIE_SAMESITE` | `false` | No |SameSite cookie attribute (strict, lax, none, or false) | +| `UNCHAINED_COOKIE_SAMESITE` | `none` | No |SameSite cookie attribute (strict, lax, none, or false) | | `UNCHAINED_COOKIE_INSECURE` | - | No |Allow insecure cookies (set to any truthy value, defaults to secure) | ## Web Configuration | Variable | Default | Required | Description | |----------|---------|----------|-------------| -| `ROOT_URL` | `http://localhost:4010` | Yes | Base URL of the application, used for generating absolute URLs | +| `ROOT_URL` | - | Yes | Base URL of the application, used for generating absolute URLs | | `EMAIL_WEBSITE_URL` | - | Yes | Frontend website URL, used in email templates and redirects | -| `EMAIL_WEBSITE_NAME` | `Unchained` | Yes | Name of the website shown in emails and WebAuthn | +| `EMAIL_WEBSITE_NAME` | - | Yes | Name of the website shown in emails and WebAuthn | ## Email Configuration | Variable | Default | Required | Description | |----------|---------|----------|-------------| | `MAIL_URL` | - | - | SMTP connection URL for sending emails (e.g., `smtp://user:pass@host:port`) | -| `EMAIL_FROM` | `noreply@unchained.local` | Yes | Default sender email address | +| `EMAIL_FROM` | - | Yes | Default sender email address | | `EMAIL_ERROR_REPORT_RECIPIENT` | `support@unchained.local` | - | Email address for error reports | | `UNCHAINED_DISABLE_EMAIL_INTERCEPTION` | - | - | Disable email interception in non-production environments (set to any truthy value) | @@ -84,4 +83,4 @@ This document provides a comprehensive list of all environment variables used by - In production, ensure all security-related variables are properly set with strong values - Some variables have different behaviors in development vs production (see `NODE_ENV`) - Email interception is enabled by default in non-production environments unless disabled -- Cookie security settings should be carefully configured for production deployments \ No newline at end of file +- Cookie security settings should be carefully configured for production deployments diff --git a/docs/docs/platform-configuration/index.md b/docs/docs/platform-configuration/index.md index 9cbcc06f13..a8c870537c 100644 --- a/docs/docs/platform-configuration/index.md +++ b/docs/docs/platform-configuration/index.md @@ -20,7 +20,7 @@ The main entry point for an Unchained Engine project is `startPlatform` imported To make things a bit more simple, Unchained offers different [presets](./plugin-presets.md) for loading functionalities out-of-the box: - `base` (Simple Catalog Price based Pricing strategies, Manual Delivery & Invoice Payment, GridFS Asset Storage) - `crypto` (Currency-Rate Updating Workers for ECB & Coinbase, Currency-Converting Pricing Plugin, Event ERC721 Token Lazy-Minting on Ethereum, Payment through Unchained Cryptopay) -- `countries/ch` (Switzerland Tax Calculation and Migros PickMup Integration) +- `countries/ch` (Switzerland tax calculation) - `all` (All of the above + all other available plugins including plugins for various payment gateways) We recommend loading at least `base`. @@ -34,7 +34,7 @@ import { connect, unchainedLogger } from "@unchainedshop/api/fastify"; import defaultModules from "@unchainedshop/plugins/presets/all.js"; import connectDefaultPluginsToFastify from "@unchainedshop/plugins/presets/all-fastify.js"; -// Set up the Fastify web server in insecure mode and set the unchained default logger as request logger +// Set up Fastify with the Unchained request logger const fastify = Fastify({ loggerInstance: unchainedLogger("fastify"), disableRequestLogging: true, @@ -48,7 +48,7 @@ try { }); // Use the connect from @unchainedshop/api to connect Unchained to Fastify, setting up the basic endpoints like /graphql - connect(fastify, platform, { + await connect(fastify, platform, { allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== "production", initPluginMiddlewares: connectDefaultPluginsToFastify }); @@ -56,7 +56,7 @@ try { // Tell Fastify to start listening on a port, thus accepting connections await fastify.listen({ host: "::", - port: process.env.PORT ? parseInt(process.env.PORT) : 3000, + port: process.env.PORT ? parseInt(process.env.PORT, 10) : 4010, }); } catch (err) { fastify.log.error(err); @@ -74,6 +74,7 @@ To configure various aspects of the platform, `startPlatform` accepts a configur - `options`: Module-specific configuration options (see [Module Options](#module-options) below) - `rolesOptions`: `IRoleOptionConfig`: Enables you to customize the existing roles and actions, adjusting fine-grained permissions. - `bulkImporter`: Enables you to define custom bulk import handlers for a clear separation of data import and e-commerce engine. For more information about the bulk import API, refer to the [Bulk Import Guide](../guides/bulk-import). + - `bulkExporter`: Configure custom bulk export handlers through the `handlers` option. - `workQueueOptions`: `SetupWorkqueueOptions` Configuration regarding the work queue, for example disabling it entirely in multi-pod setups - `adminUiConfig`: Customize the Unchained Admin UI, for example configuring a Single-Sign-On Link for external Auth support via oAuth. @@ -126,7 +127,7 @@ await startPlatform({ // Files module files: { transformUrl: (url, params) => url, - privateFileSharingMaxAge: 3600, + privateFileSharingMaxAge: 60 * 60 * 1000, // One hour in milliseconds }, // Worker module worker: { diff --git a/docs/docs/platform-configuration/modules/assortments.md b/docs/docs/platform-configuration/modules/assortments.md index 132e173f61..c3a9db902a 100644 --- a/docs/docs/platform-configuration/modules/assortments.md +++ b/docs/docs/platform-configuration/modules/assortments.md @@ -82,10 +82,10 @@ This creates a deterministic but mixed ordering across all child assortments. To order products sequentially by assortment instead, use `zipTreeBySimplyFlattening`: ```typescript -import zipTreeBySimplyFlattening from "@unchainedshop/core-assortments/tree-zipper/zipTreeBySimplyFlattening"; +import zipTreeBySimplyFlattening from "@unchainedshop/core-assortments/lib/utils/tree-zipper/zipTreeBySimplyFlattening.js"; -const options = { - modules: { +const platformOptions = { + options: { assortments: { zipTree: zipTreeBySimplyFlattening, }, diff --git a/docs/docs/platform-configuration/modules/delivery.md b/docs/docs/platform-configuration/modules/delivery.md index fb2eb36cb4..9a107d594e 100644 --- a/docs/docs/platform-configuration/modules/delivery.md +++ b/docs/docs/platform-configuration/modules/delivery.md @@ -37,10 +37,10 @@ export interface DeliverySettingsOptions { ### Custom Filtering ```typescript -const options = { - modules: { +const platformOptions = { + options: { delivery: { - filterSupportedProviders: ({ order, providers }) => { + filterSupportedProviders: async ({ order, providers }) => { return providers .toSorted((left, right) => { return new Date(left.created).getTime() - new Date(right.created).getTime(); @@ -59,11 +59,11 @@ By default we return all providers based on the creation date and don't filter a ### Default Provider Selection for New Orders ```typescript -const options = { - modules: { +const platformOptions = { + options: { delivery: { - determineDefaultProvider: ({ order, providers }) => { - return providers?.find(({ _id }) => _id === 'this-id-always-default'); + determineDefaultProvider: async ({ order, providers }) => { + return providers?.find(({ _id }) => _id === 'this-id-always-default') || null; }, }, }, diff --git a/docs/docs/platform-configuration/modules/enrollments.md b/docs/docs/platform-configuration/modules/enrollments.md index 4a7e42a00a..da02b89b3b 100644 --- a/docs/docs/platform-configuration/modules/enrollments.md +++ b/docs/docs/platform-configuration/modules/enrollments.md @@ -42,11 +42,11 @@ The `enrollmentNumberHashFn` is used to generate human-readable codes that can b ```typescript import { schedule } from '@unchainedshop/core'; -const options = { - modules: { +const platformOptions = { + options: { enrollments: { autoSchedulingSchedule: schedule.parse.text('every 7 days'), - enrollmentNumberHashFn: (enrollment, index) => enrollment.sequence + 300000 + index, + enrollmentNumberHashFn: (enrollment, index) => String(enrollment.sequence + 300000 + index), }, }, }; diff --git a/docs/docs/platform-configuration/modules/index.md b/docs/docs/platform-configuration/modules/index.md index 97b502b435..f15c544113 100644 --- a/docs/docs/platform-configuration/modules/index.md +++ b/docs/docs/platform-configuration/modules/index.md @@ -69,10 +69,11 @@ const resolvers = { }; // In custom code after platform start -const { modules } = await startPlatform({ ... }); +const { unchainedAPI } = await startPlatform({}); +const { modules } = unchainedAPI; const products = await modules.products.findProducts({ - status: 'ACTIVE', + includeDrafts: false, limit: 10, }); ``` @@ -87,10 +88,10 @@ Most modules follow a consistent pattern: modules.products.findProduct({ productId }); // Find multiple entities -modules.products.findProducts({ status: 'ACTIVE', limit: 10 }); +modules.products.findProducts({ includeDrafts: false, limit: 10 }); // Count entities -modules.products.count({ status: 'ACTIVE' }); +modules.products.count({ includeDrafts: false }); // Check existence modules.products.productExists({ productId }); @@ -99,10 +100,11 @@ modules.products.productExists({ productId }); ### Mutation Methods ```typescript // Create -const productId = await modules.products.create({ type: 'SIMPLE' }); +const product = await modules.products.create({ type: 'SIMPLE_PRODUCT', tags: [] }); +const productId = product._id; // Update -await modules.products.update(productId, { status: 'ACTIVE' }); +await modules.products.publish(product); // Delete (usually soft delete) await modules.products.delete(productId); @@ -113,14 +115,14 @@ await modules.products.delete(productId); Modules emit events for important operations. Subscribe to events for custom logic: ```typescript -import { emit, registerEvents } from '@unchainedshop/events'; +import { subscribe, registerEvents } from '@unchainedshop/events'; // Register custom event handlers registerEvents(['CUSTOM_EVENT']); // Subscribe to events -events.on('PRODUCT_CREATE', async ({ payload }) => { - console.log('Product created:', payload.productId); +subscribe('PRODUCT_CREATE', async ({ payload }) => { + console.log('Product created:', payload.product._id); }); ``` diff --git a/docs/docs/platform-configuration/modules/orders.md b/docs/docs/platform-configuration/modules/orders.md index ba387ca04a..fc2dabb2df 100644 --- a/docs/docs/platform-configuration/modules/orders.md +++ b/docs/docs/platform-configuration/modules/orders.md @@ -31,7 +31,7 @@ export interface OrdersSettingsOptions { ``` - `ensureUserHasCart`: If enabled, Unchained will try to pre-generate a new cart when a user does not have one on various occasions, it's still not guaranteed that a user always has a cart. (default: false) -- `lockOrderDuringCheckout`: If enabled, Unchained tries to use a so-called "Distributed Locking" approach with MongoDB while the checkout process is running, highly encouraged for most cases (default: true) +- `lockOrderDuringCheckout`: If enabled, Unchained tries to use a so-called "Distributed Locking" approach with MongoDB while the checkout process is running, highly encouraged for most cases (default: false) ### Order Number Creation @@ -48,10 +48,10 @@ With `validateOrderPosition` you can validate cart manipulations and throw Error **The default validator checks if a product is active.** ```typescript -const options = { - modules: { +const platformOptions = { + options: { orders: { - orderNumberHashFn: (order, index) => order.sequence + 100000 + index, + orderNumberHashFn: (order, index) => String(String(order.sequence + 100000 + index)), validateOrderPosition: async ({ order, product, quantityDiff, configuration }, context) => { const justOneAtATime = product.tags?.includes('one-at-a-time'); const positions = await context.modules.orders.positions.findOrderPositions({ diff --git a/docs/docs/platform-configuration/modules/payment.md b/docs/docs/platform-configuration/modules/payment.md index d09f900169..2ad59dbe93 100644 --- a/docs/docs/platform-configuration/modules/payment.md +++ b/docs/docs/platform-configuration/modules/payment.md @@ -38,10 +38,10 @@ export interface PaymentSettingsOptions { ### Custom Filtering ```typescript -const options = { - modules: { +const platformOptions = { + options: { payment: { - filterSupportedProviders: ({ order, providers }) => { + filterSupportedProviders: async ({ order, providers }) => { return providers .toSorted((left, right) => { return new Date(left.created).getTime() - new Date(right.created).getTime(); @@ -60,11 +60,11 @@ By default we return all providers based on the creation date and don't filter a ### Default Provider Selection for New Orders ```typescript -const options = { - modules: { +const platformOptions = { + options: { payment: { - determineDefaultProvider: ({ order, providers }) => { - return providers?.find(({ _id }) => _id === 'this-id-always-default'); + determineDefaultProvider: async ({ order, providers }) => { + return providers?.find(({ _id }) => _id === 'this-id-always-default') || null; }, }, }, diff --git a/docs/docs/platform-configuration/modules/products.md b/docs/docs/platform-configuration/modules/products.md index a9b13a01ce..d286d42f99 100644 --- a/docs/docs/platform-configuration/modules/products.md +++ b/docs/docs/platform-configuration/modules/products.md @@ -25,8 +25,8 @@ export interface ProductsSettingsOptions { ```typescript import slugify from 'slugify'; -const options = { - modules: { +const platformOptions = { + options: { products: { slugify, }, diff --git a/docs/docs/platform-configuration/modules/quotations.md b/docs/docs/platform-configuration/modules/quotations.md index 3f722464ce..0e6417744c 100644 --- a/docs/docs/platform-configuration/modules/quotations.md +++ b/docs/docs/platform-configuration/modules/quotations.md @@ -26,10 +26,10 @@ The `quotationNumberHashFn` is used to generate human-readable codes that can be ### Example Custom Configuration ```typescript -const options = { - modules: { +const platformOptions = { + options: { quotations: { - quotationNumberHashFn: (quotation, index) => quotation.sequence + 300000 + index, + quotationNumberHashFn: (quotation, index) => String(quotation.sequence + 300000 + index), }, }, }; diff --git a/docs/docs/platform-configuration/modules/users.md b/docs/docs/platform-configuration/modules/users.md index 42830e263c..bc693264ed 100644 --- a/docs/docs/platform-configuration/modules/users.md +++ b/docs/docs/platform-configuration/modules/users.md @@ -15,8 +15,9 @@ The users module handles user authentication, registration, profiles, and accoun export interface UserSettingsOptions { mergeUserCartsOnLogin?: boolean; autoMessagingAfterUserCreation?: boolean; + guestUserMaxAgeInDays?: number; earliestValidTokenDate?: ( - type: UserAccountAction.VERIFY_EMAIL | UserAccountAction.RESET_PASSWORD, + type: typeof UserAccountAction.VERIFY_EMAIL | typeof UserAccountAction.RESET_PASSWORD, ) => Date; validateEmail?: (email: string) => Promise; validateUsername?: (username: string) => Promise; @@ -31,7 +32,7 @@ Assuming somebody starts his journey in your web shop with a guest user and you ### Auto Messaging After User Creation -If Auto Messaging is turned on and E-Mail is provided during registration, Unchained will (default: disabled): +If Auto Messaging is turned on and E-Mail is provided during registration, Unchained will (default: enabled): 1. Send an E-Mail Verification Link to users that registered with a password 2. Send Set-Password Link to users that registered without a password @@ -43,8 +44,8 @@ When sending reset-password or e-mail verification links, tokens are generated. To control how long those tokens are valid, you can customize `earliestValidTokenDate`. For example if you want the tokens to be valid for 30 days (default: 1 hour): ```typescript -const options = { - modules: { +const platformOptions = { + options: { users: { earliestValidTokenDate: () => { return new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30); @@ -61,10 +62,10 @@ Changing this will affect newly created tokens and older tokens so you can safel Unchained provides different hooks to validate user registration data, here is an example to restrict registration to an e-mail address suffix: ```typescript -const options = { - modules: { +const platformOptions = { + options: { users: { - validateEmail: (emailAddress) => { + validateEmail: async (emailAddress) => { return emailAddress.endsWith("@unchained.shop"); }, }, diff --git a/docs/docs/platform-configuration/modules/worker.md b/docs/docs/platform-configuration/modules/worker.md index f264502aa7..8ad8884bc9 100644 --- a/docs/docs/platform-configuration/modules/worker.md +++ b/docs/docs/platform-configuration/modules/worker.md @@ -26,8 +26,8 @@ You can provide a custom list of blacklisted variables, keys which are part of t Example custom configuration: ```typescript -const options = { - modules: { +const platformOptions = { + options: { worker: { blacklistedVariables: ['secret-key'], }, @@ -39,7 +39,7 @@ By default, those variables are filtered: [buildObfuscatedFieldsFilter](https:// ## Events -The worker module does not emit events directly. Work items are processed by registered worker plugins which may emit their own events. +The worker module emits `WORK_ADDED`, `WORK_ALLOCATED`, `WORK_FINISHED`, `WORK_DELETED`, and `WORK_RESCHEDULED`. The first four carry the work record after configured private fields are removed; rescheduling carries `{ work, oldScheduled }`. `WorkerEventTypes` is exported by `@unchainedshop/core-worker`. ## More Information diff --git a/docs/docs/platform-configuration/plugin-presets.md b/docs/docs/platform-configuration/plugin-presets.md index f13d973c89..fa9eb5313d 100644 --- a/docs/docs/platform-configuration/plugin-presets.md +++ b/docs/docs/platform-configuration/plugin-presets.md @@ -23,8 +23,8 @@ connect(app, platform) // Either: // a) Load GridFS REST endpoints for Express.js: -import connectPlugins from '@unchainedshop/plugins/presets/base-express.js'; -// a) Load GridFS REST endpoints for Fastify: +// import connectPlugins from '@unchainedshop/plugins/presets/base-express.js'; +// b) Load GridFS REST endpoints for Fastify: import connectPlugins from '@unchainedshop/plugins/presets/base-fastify.js'; connectPlugins(app); @@ -63,6 +63,9 @@ The base preset includes essential plugins for a minimal e-commerce setup: **Workers:** - Bulk import - Zombie killer (cleanup) +- Guest user garbage collection +- Cart invalidation +- Bulk export - Message handling - External service integration - HTTP request handling @@ -85,11 +88,11 @@ connect(app, platform) // Either: // a) Load all custom API handlers for Express.js: -import connectPlugins from '@unchainedshop/plugins/presets/all-express.js'; +// import connectPlugins from '@unchainedshop/plugins/presets/all-express.js'; // b) Load all custom API handlers for Fastify: import connectPlugins from '@unchainedshop/plugins/presets/all-fastify.js'; -connectPlugins(app); +connectPlugins(app, platform); ``` The all preset extends the base preset with additional payment providers, delivery methods, and features: @@ -142,14 +145,14 @@ const platform = await startPlatform({ connect(app, platform) // a) Load Crypto API handlers for Express.js: -import connectCryptoPlugins from '@unchainedshop/plugins/presets/crypto-express.js'; -import connectPlugins from '@unchainedshop/plugins/presets/base-express.js'; +// import connectCryptoPlugins from '@unchainedshop/plugins/presets/crypto-express.js'; +// import connectPlugins from '@unchainedshop/plugins/presets/base-express.js'; // b) Load Crypto API handlers for Fastify: import connectCryptoPlugins from '@unchainedshop/plugins/presets/crypto-fastify.js'; import connectPlugins from '@unchainedshop/plugins/presets/base-fastify.js'; // Make sure you load the base plugins too as those are not part of the crypto preset! -connectCryptoPlugins(app); +connectCryptoPlugins(app, platform.unchainedAPI); connectPlugins(app) ``` @@ -184,7 +187,7 @@ const platform = await startPlatform({ connect(app, platform) // a) Load Base API handlers for Express.js: -import connectPlugins from '@unchainedshop/plugins/presets/base-express.js'; +// import connectPlugins from '@unchainedshop/plugins/presets/base-express.js'; // b) Load Base API handlers for Fastify: import connectPlugins from '@unchainedshop/plugins/presets/base-fastify.js'; @@ -192,9 +195,6 @@ import connectPlugins from '@unchainedshop/plugins/presets/base-fastify.js'; connectPlugins(app) ``` -**Delivery:** -- Pick-Mup delivery service - **Pricing:** - Swiss tax calculation for products - Swiss tax calculation for delivery diff --git a/docs/docs/plugins/delivery/delivery-post.md b/docs/docs/plugins/delivery/delivery-post.md index 86e256e2ad..293300a0ad 100644 --- a/docs/docs/plugins/delivery/delivery-post.md +++ b/docs/docs/plugins/delivery/delivery-post.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/delivery/post'; +import '@unchainedshop/plugins/delivery/post.js'; ``` ## Configuration @@ -27,7 +27,7 @@ Create a delivery provider using this adapter: mutation CreatePostDelivery { createDeliveryProvider(deliveryProvider: { type: SHIPPING - adapterKey: "shop.unchained.delivery.post" + adapterKey: "shop.unchained.post" }) { _id } @@ -37,7 +37,7 @@ mutation CreatePostDelivery { ## Features - Standard shipping delivery type -- Configurable estimated delivery time +- Zero delivery-throughput estimate by default - Auto-release support - No external API dependencies @@ -45,9 +45,9 @@ mutation CreatePostDelivery { | Property | Value | |----------|-------| -| Key | `shop.unchained.delivery.post` | +| Key | `shop.unchained.post` | | Type | `SHIPPING` | -| Auto-release | Configurable | +| Auto-release | `true` | | Source | [delivery/post.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/delivery/post.ts) | ## Behavior @@ -59,70 +59,29 @@ Always returns `true` - no configuration required. Returns `true` by default, allowing orders to proceed automatically after payment. ### `send()` -Returns success without external API calls. For production integrations with actual carriers, create a custom adapter. +Inherits `DeliveryAdapter.send()`, which returns `false`. No shipment is booked and the delivery remains open until it is completed separately or a custom adapter returns a successful result. ### `estimatedDeliveryThroughput()` -Returns a default delivery estimate. Override in configuration or extend the adapter for custom calculations. +Inherits the base implementation and resolves to `0` milliseconds. The Post adapter does not read a delivery-time configuration field; override the method in a custom adapter for a different estimate. -## Extending for Real Carriers +## Extending for Carriers -For production use, extend or replace this adapter with carrier-specific integrations: +Start with `DeliveryAdapter` and spread its `actions(config, context)` defaults. A custom `send()` returns `true` for completed delivery, `false` to leave it open, or a worker record for queued work. Persist tracking details separately in the order-delivery context. -```typescript -import { DeliveryDirector } from '@unchainedshop/core'; - -const SwissPostAdapter = { - key: 'ch.post.delivery', - label: 'Swiss Post', - version: '1.0.0', - - typeSupported: (type) => type === 'SHIPPING', - - actions(config, context) { - return { - configurationError() { return null; }, - isActive() { return true; }, - isAutoReleaseAllowed() { return true; }, - - async send() { - const { order } = context; - - // Call Swiss Post API - const response = await swissPostApi.createShipment({ - recipient: order.delivery.address, - weight: calculateWeight(order.items), - }); - - return { - trackingNumber: response.trackingNumber, - trackingUrl: `https://www.post.ch/track?id=${response.trackingNumber}`, - }; - }, - - estimatedDeliveryThroughput(warehousingTime) { - // Swiss Post typically delivers in 1-2 days - return warehousingTime + (2 * 24 * 60 * 60 * 1000); - }, - - async pickUpLocations() { return []; }, - async pickUpLocationById() { return null; }, - }; - }, -}; - -DeliveryDirector.registerAdapter(SwissPostAdapter); -``` +The supplied context contains `order`, `orderDelivery`, and `modules`. Load positions with `modules.orders.positions.findOrderPositions({ orderId: order._id })`; order documents do not embed `items`. The delivery address is in `orderDelivery.context.address`, with the order billing address as a fallback. + +`estimatedDeliveryThroughput(warehousingTime)` is asynchronous and returns milliseconds. See [Custom Delivery Plugins](../../extend/order-fulfilment/fulfilment-plugins/delivery.md) for the complete contract. ## Delivery Pricing Combine with delivery pricing adapters: ```typescript -import '@unchainedshop/plugins/pricing/order-delivery'; -import '@unchainedshop/plugins/pricing/free-delivery'; +import '@unchainedshop/plugins/pricing/order-delivery.js'; +import '@unchainedshop/plugins/pricing/free-delivery.js'; ``` -Set prices via configuration or custom pricing adapter. +Delivery pricing is configured through pricing adapters; the Post adapter itself does not read a price field. ## Related diff --git a/docs/docs/plugins/delivery/delivery-send-message.md b/docs/docs/plugins/delivery/delivery-send-message.md index 4281cc560f..8417d7f8e8 100644 --- a/docs/docs/plugins/delivery/delivery-send-message.md +++ b/docs/docs/plugins/delivery/delivery-send-message.md @@ -12,7 +12,7 @@ The Send Message adapter provides digital delivery functionality by sending orde ## Installation ```typescript -import '@unchainedshop/plugins/delivery/send-message'; +import '@unchainedshop/plugins/delivery/send-message.js'; ``` ## Configuration @@ -54,15 +54,15 @@ Configure the provider after creation using the Admin UI or by updating the prov |----------|-------| | Key | `shop.unchained.delivery.send-message` | | Type | `SHIPPING` | -| Auto-release | Default (configurable) | +| Auto-release | `true` (inherited from DeliveryAdapter) | | Source | [delivery/send-message.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/delivery/send-message.ts) | ## Configuration Options | Key | Description | Default | |-----|-------------|---------| -| `from` | Sender email address | Empty | -| `to` | Recipient email (overrides order email) | Empty | +| `from` | Sender used by the default DELIVERY template | `EMAIL_FROM`, then `noreply@unchained.local`, when empty | +| `to` | Recipient used by the default DELIVERY template | `orders@unchained.local` when empty | | `cc` | CC email address | Empty | ## Behavior @@ -71,7 +71,7 @@ Configure the provider after creation using the Admin UI or by updating the prov Always returns `true`. ### `send()` -Creates a worker job with the `MESSAGE` type using the `DELIVERY` template: +Returns a worker job with the `MESSAGE` type using the `DELIVERY` template. The director marks delivery as delivered when the work is queued, before the downstream message has finished: ```typescript await modules.worker.addWork({ @@ -104,159 +104,34 @@ Send order details to: ## Message Template -Configure the `DELIVERY` template in your messaging setup: +The platform registers a default `DELIVERY` resolver that forwards order details to the configured recipient. It does not fall back to the customer's email address. Override it after platform initialization when delivering customer-specific content: ```typescript import { MessagingDirector } from '@unchainedshop/core'; -const DeliveryTemplate = { - key: 'DELIVERY', - label: 'Delivery Notification', - version: '1.0.0', - - actions: (config, context) => ({ - async send() { - const { orderId, config: deliveryConfig } = context.work.input; - const { modules } = context; - - const order = await modules.orders.findOrder({ orderId }); - const items = await modules.orders.positions.findOrderPositions({ orderId }); - - // Generate download links, license keys, etc. - const deliveryContent = await generateDeliveryContent(items); - - return { - to: deliveryConfig.to || order.contact.emailAddress, - from: deliveryConfig.from, - cc: deliveryConfig.cc, - subject: `Your order ${order.orderNumber} - Download Ready`, - html: renderDeliveryEmail(order, deliveryContent), - }; +MessagingDirector.registerTemplate('DELIVERY', async ({ orderId, config }, { modules }) => { + const order = await modules.orders.findOrder({ orderId }); + if (!order) throw new Error('Order not found'); + const settings = Object.fromEntries(config.map(({ key, value }) => [key, value])); + const recipient = settings.to || order.contact?.emailAddress; + if (!recipient) throw new Error('Delivery recipient missing'); + + return [{ + type: 'EMAIL', + input: { + from: settings.from || process.env.EMAIL_FROM, + to: recipient, + cc: settings.cc, + subject: `Your order ${order.orderNumber}`, + text: 'Your order is ready.', }, - }), -}; - -MessagingDirector.registerAdapter(DeliveryTemplate); -``` - -## Custom Digital Delivery Adapter - -For more complex digital delivery scenarios: - -```typescript -import { DeliveryDirector, type IDeliveryAdapter } from '@unchainedshop/core'; - -const DigitalDeliveryAdapter: IDeliveryAdapter = { - key: 'my-shop.digital-delivery', - label: 'Digital Product Delivery', - version: '1.0.0', - - typeSupported: (type) => type === 'SHIPPING', - - actions(config, context) { - return { - configurationError() { return null; }, - isActive() { return true; }, - isAutoReleaseAllowed() { return true; }, - - async send() { - const { order, modules } = context; - const positions = await modules.orders.positions.findOrderPositions({ - orderId: order._id, - }); - - const deliveryItems = []; - - for (const position of positions) { - const product = await modules.products.findProduct({ - productId: position.productId, - }); - - if (product.type === 'SIMPLE') { - // Generate license key - const licenseKey = await generateLicenseKey(product, order); - deliveryItems.push({ - product: product.texts?.title, - licenseKey, - }); - } - - if (product.meta?.downloadUrl) { - // Generate signed download URL - const downloadUrl = await generateSignedUrl( - product.meta.downloadUrl, - { expiresIn: '7d' } - ); - deliveryItems.push({ - product: product.texts?.title, - downloadUrl, - }); - } - } - - // Queue delivery email - await modules.worker.addWork({ - type: 'MESSAGE', - input: { - template: 'DIGITAL_DELIVERY', - orderId: order._id, - deliveryItems, - }, - }); - - return { - status: 'DELIVERED', - deliveryItems, - }; - }, - - estimatedDeliveryThroughput() { - // Instant delivery - return 0; - }, - - async pickUpLocations() { return []; }, - async pickUpLocationById() { return null; }, - }; - }, -}; - -DeliveryDirector.registerAdapter(DigitalDeliveryAdapter); + }]; +}); ``` -## Combining with Physical Delivery - -For products with both physical and digital components: +Templates are resolver functions registered with `registerTemplate`; they are not director adapters. Register the `MESSAGE` and `EMAIL` worker plugins to process these jobs. -```typescript -async send() { - const { order, modules } = context; - const positions = await modules.orders.positions.findOrderPositions({ - orderId: order._id, - }); - - const digitalItems = positions.filter(p => p.product?.meta?.isDigital); - const physicalItems = positions.filter(p => !p.product?.meta?.isDigital); - - // Handle digital items immediately - if (digitalItems.length > 0) { - await modules.worker.addWork({ - type: 'MESSAGE', - input: { - template: 'DIGITAL_DELIVERY', - orderId: order._id, - items: digitalItems, - }, - }); - } - - // Physical items handled by warehouse - return { - digitalDelivered: digitalItems.length, - physicalPending: physicalItems.length, - }; -} -``` +For license keys or download links, load positions through `modules.orders.positions.findOrderPositions` and localized product text through `modules.products.texts.findLocalizedText`. Position documents do not embed products, and product documents do not embed localized texts. Generate digital content in your own service and pass it to the template. ## Related diff --git a/docs/docs/plugins/delivery/delivery-stores.md b/docs/docs/plugins/delivery/delivery-stores.md index 03d3212171..9e843e59f5 100644 --- a/docs/docs/plugins/delivery/delivery-stores.md +++ b/docs/docs/plugins/delivery/delivery-stores.md @@ -12,7 +12,7 @@ The Stores adapter provides pickup location functionality for in-store or wareho ## Installation ```typescript -import '@unchainedshop/plugins/delivery/stores'; +import '@unchainedshop/plugins/delivery/stores.js'; ``` ## Configuration @@ -52,27 +52,23 @@ Configure the stores after creation via the Admin UI or update the provider's co ### `stores` -JSON array of pickup locations. Each location should have: +Set the configuration entry `stores` to a JSON-encoded array of pickup locations. Store addresses and coordinates must use the nested `address` and `geoPoint` shapes: ```json [ { "_id": "store-1", "name": "Main Store", - "address": "123 Main Street, Zurich", - "city": "Zurich", - "postalCode": "8001", - "countryCode": "CH", - "coordinates": { - "lat": 47.3769, - "lng": 8.5417 + "address": { + "addressLine": "123 Main Street", + "city": "Zurich", + "postalCode": "8001", + "countryCode": "CH" }, - "openingHours": "Mon-Fri 9-18, Sat 9-16" - }, - { - "_id": "store-2", - "name": "Airport Shop", - "address": "Zurich Airport, Terminal 2" + "geoPoint": { + "latitude": 47.3769, + "longitude": 8.5417 + } } ] ``` @@ -80,10 +76,10 @@ JSON array of pickup locations. Each location should have: ## Behavior ### `isActive()` -Always returns `true` - no configuration required. +Returns `true` even when no stores are configured. Set a valid `stores` array before offering pickup locations. ### `isAutoReleaseAllowed()` -Returns `false` - pickup orders require manual confirmation. +Returns `false`, requiring manual order confirmation. The inherited `send()` also returns `false`, so confirming the order does not mark the pickup as delivered. ### `pickUpLocations()` Returns all configured store locations. @@ -139,100 +135,38 @@ mutation SetPickupLocation($deliveryProviderId: ID!, $locationId: ID!) { } ``` -## Extending for Dynamic Stores +## Dynamic Locations -For stores managed in a database or external system: +For locations managed by another service, use your own lookup function. There is no `findWarehouses` or `findWarehouse` method on the warehousing module. ```typescript -import { DeliveryDirector, type IDeliveryAdapter } from '@unchainedshop/core'; - -const DynamicStoresAdapter: IDeliveryAdapter = { - key: 'my-shop.dynamic-stores', - label: 'Dynamic Store Locations', - version: '1.0.0', - - typeSupported: (type) => type === 'PICKUP', - - actions(config, context) { - const { modules } = context; - - return { - configurationError() { return null; }, - isActive() { return true; }, - isAutoReleaseAllowed() { return false; }, - - async pickUpLocations() { - // Fetch from database or external API - const stores = await modules.warehousing.findWarehouses({ - type: 'STORE', - isActive: true, - }); - - return stores.map(store => ({ - _id: store._id, - name: store.name, - address: store.address, - geoPoint: store.coordinates ? { - latitude: store.coordinates.lat, - longitude: store.coordinates.lng, - } : null, - })); - }, - - async pickUpLocationById(locationId) { - const store = await modules.warehousing.findWarehouse({ _id: locationId }); - if (!store) return null; - - return { - _id: store._id, - name: store.name, - address: store.address, - }; - }, - - async send() { - // Notify store about pickup order - const { order } = context; - await notifyStore(order); - return { status: 'READY_FOR_PICKUP' }; - }, - - estimatedDeliveryThroughput(warehousingTime) { - // Pickup ready same day - return warehousingTime; - }, - }; - }, -}; - -DeliveryDirector.registerAdapter(DynamicStoresAdapter); -``` - -## Store Locator Integration - -Combine with geolocation for nearest store finder: - -```typescript -async pickUpLocations(searchParams) { - const stores = await getAllStores(); - - if (searchParams?.coordinates) { - // Sort by distance - return stores - .map(store => ({ - ...store, - distance: calculateDistance( - searchParams.coordinates, - store.coordinates - ), - })) - .sort((a, b) => a.distance - b.distance); - } - - return stores; +import { DeliveryAdapter, DeliveryDirector, type IDeliveryAdapter } from '@unchainedshop/core'; +import { DeliveryProviderType, type DeliveryLocation } from '@unchainedshop/core-delivery'; + +function registerDynamicStores(loadStores: () => Promise) { + const adapter: IDeliveryAdapter = { + ...DeliveryAdapter, + key: 'my-shop.dynamic-stores', + label: 'Dynamic Store Locations', + version: '1.0.0', + typeSupported: (type) => type === DeliveryProviderType.PICKUP, + actions(config, context) { + return { + ...DeliveryAdapter.actions(config, context), + configurationError: () => null, + isActive: () => true, + isAutoReleaseAllowed: () => false, + pickUpLocations: loadStores, + pickUpLocationById: async (id) => (await loadStores()).find((store) => store._id === id) || null, + }; + }, + }; + DeliveryDirector.registerAdapter(adapter); } ``` +Call `registerDynamicStores` with your application's lookup function. `pickUpLocations()` has no search-parameter argument; implement nearest-store sorting in your storefront or in a custom service with its own inputs. + ## Related - [Plugins Overview](./) - All available plugins diff --git a/docs/docs/plugins/enrollments/enrollment-licensed.md b/docs/docs/plugins/enrollments/enrollment-licensed.md index e6c9a3b27c..717bc7dbc7 100644 --- a/docs/docs/plugins/enrollments/enrollment-licensed.md +++ b/docs/docs/plugins/enrollments/enrollment-licensed.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/enrollments/licensed'; +import '@unchainedshop/plugins/enrollments/licensed.js'; ``` ## Features @@ -190,7 +190,7 @@ mutation TerminateSubscription { Use the [Enrollment Order Generator Worker](../workers/worker-enrollment-order-generator.md) to automatically generate orders: ```typescript -import { configureGenerateOrderAutoscheduling } from '@unchainedshop/plugins/worker/enrollment-order-generator'; +import { configureGenerateOrderAutoscheduling } from '@unchainedshop/plugins/worker/enrollment-order-generator.js'; import { enrollmentsSettings } from '@unchainedshop/core-enrollments'; import { schedule } from '@unchainedshop/core'; diff --git a/docs/docs/plugins/events/events-eventbridge.md b/docs/docs/plugins/events/events-eventbridge.md index 10092aabe6..5573dadb06 100644 --- a/docs/docs/plugins/events/events-eventbridge.md +++ b/docs/docs/plugins/events/events-eventbridge.md @@ -2,153 +2,56 @@ sidebar_position: 20 title: AWS EventBridge sidebar_label: AWS EventBridge -description: Enterprise event system using AWS EventBridge +description: Publish Unchained events to AWS EventBridge --- # AWS EventBridge -Enterprise event system using AWS EventBridge for cloud-native event routing. +The adapter sends events to an EventBridge bus with the event name as `DetailType`, the configured source as `Source`, and the JSON payload envelope as `Detail`. ## Installation -```typescript -import '@unchainedshop/plugins/events/aws-eventbridge'; -``` - -Requires the AWS SDK as a peer dependency: - ```bash npm install @aws-sdk/client-eventbridge ``` -:::warning Explicit Configuration Required -Unlike the Node.js event emitter (which is the default), this plugin requires explicit configuration. You must call `setEmitAdapter()` to activate EventBridge as your event system: - -```typescript -import { setEmitAdapter } from '@unchainedshop/events'; -import { EventBridgeEventEmitter } from '@unchainedshop/plugins/events/aws-eventbridge'; - -const adapter = await EventBridgeEventEmitter({ - region: 'us-east-1', - source: 'com.mycompany.unchained', - busName: 'unchained-events', -}); -setEmitAdapter(adapter); -``` -::: - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `EVENT_BRIDGE_REGION` | - | AWS region (required) | -| `EVENT_BRIDGE_SOURCE` | - | Event source identifier (required) | -| `EVENT_BRIDGE_BUS_NAME` | - | EventBridge custom bus name (required) | -| `AWS_ACCESS_KEY_ID` | - | AWS access key | -| `AWS_SECRET_ACCESS_KEY` | - | AWS secret key | - -## Features - -- **Cloud Native**: Fully managed AWS service -- **Event Routing**: Advanced event routing and filtering -- **Integrations**: Native integration with AWS services -- **Scalability**: Automatic scaling and reliability -- **Event Replay**: Built-in event replay capabilities -- **Schema Registry**: Event schema management - -## Use Cases - -- **AWS Environments**: Applications deployed on AWS -- **Enterprise Integration**: Complex event routing requirements -- **External Integrations**: Integration with AWS services and external systems -- **Event Sourcing**: When you need event replay and auditing -- **Compliance**: When you need audit trails and compliance features - -## AWS Setup - -### 1. Create EventBridge Custom Bus - -```bash -aws events create-event-bus --name "unchained-events" -``` - -### 2. Create IAM Policy - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "events:PutEvents", - "events:List*", - "events:Describe*" - ], - "Resource": "*" - } - ] -} -``` - -### 3. Configure Environment +Configure the environment before importing: ```bash EVENT_BRIDGE_REGION=us-east-1 EVENT_BRIDGE_SOURCE=com.mycompany.unchained EVENT_BRIDGE_BUS_NAME=unchained-events -AWS_ACCESS_KEY_ID=AKIA... -AWS_SECRET_ACCESS_KEY=... ``` -## Usage - -### Publishing Events - ```typescript -import { emit } from '@unchainedshop/events'; - -// Events are sent to EventBridge -await emit('ORDER_CREATE', { - orderId: '12345', - userId: 'user123', - total: 99.99 -}); +import '@unchainedshop/plugins/events/aws-eventbridge.js'; ``` -### Subscribing to Events - -EventBridge does not support direct subscription from the application. Use EventBridge rules to route events to: - -- Lambda functions -- SQS queues -- SNS topics -- API Gateway endpoints -- Other AWS services +The import constructs the adapter and calls `setEmitAdapter()` when all three settings are present. `EventBridgeEventEmitter` is an internal factory and is not exported. The AWS SDK resolves credentials from its configured credential providers. -## Performance +## Environment Variables -- **Pros**: Fully managed, highly scalable, feature-rich -- **Cons**: AWS dependency, higher cost, potential latency +| Variable | Default | Description | +|----------|---------|-------------| +| `EVENT_BRIDGE_REGION` | Unset | AWS region; required for registration | +| `EVENT_BRIDGE_SOURCE` | Unset | Event source; required for registration | +| `EVENT_BRIDGE_BUS_NAME` | Unset | Event bus name; required for registration | +| `AWS_ACCESS_KEY_ID` | Unset | Optional environment-based AWS access key | +| `AWS_SECRET_ACCESS_KEY` | Unset | Optional environment-based AWS secret key | +| `AWS_SESSION_TOKEN` | Unset | Session token when using temporary credentials | -## When to Use +The destination bus must exist and the credentials must permit `events:PutEvents`. -Use AWS EventBridge for: +## Subscription and Delivery Limits -- AWS-based deployments -- Complex event routing needs -- Integration with AWS services -- Enterprise compliance requirements -- Event sourcing and replay needs +This adapter publishes events only. Its `subscribe()` method throws. The standard platform registers local subscriptions during startup, so replacing its emitter with this adapter requires a custom composite adapter that preserves local subscription delivery and forwards events to EventBridge. -## Adapter Details +Configure EventBridge rules and targets separately for remote consumers. This plugin does not provision rules, archives, replay, or a schema registry. -| Property | Value | -|----------|-------| -| Source | [events/aws-eventbridge.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/aws-eventbridge.ts) | +Publishing is asynchronous: `emit()` does not wait for EventBridge delivery. The adapter logs rejected SDK calls and does not inspect individual entry failures in successful responses. ## Related -- [Node.js Events](./events-node.md) - In-memory events -- [Redis Events](./events-redis.md) - Distributed events with Redis -- [Plugins Overview](./) - All available plugins +- [Node.js Events](./events-node.md) +- [Redis Events](./events-redis.md) +- [Adapter source](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/aws-eventbridge.ts) diff --git a/docs/docs/plugins/events/events-node.md b/docs/docs/plugins/events/events-node.md index 31daa161c8..4f2b524b02 100644 --- a/docs/docs/plugins/events/events-node.md +++ b/docs/docs/plugins/events/events-node.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/events/node-event-emitter'; +import '@unchainedshop/plugins/events/node-event-emitter.js'; ``` This plugin automatically calls `setEmitAdapter()` when imported, making it the active event system immediately. diff --git a/docs/docs/plugins/events/events-redis.md b/docs/docs/plugins/events/events-redis.md index 6e2f8dad0e..d1962eb9d2 100644 --- a/docs/docs/plugins/events/events-redis.md +++ b/docs/docs/plugins/events/events-redis.md @@ -2,76 +2,20 @@ sidebar_position: 19 title: Redis Events sidebar_label: Redis Events -description: Distributed event system using Redis pub/sub +description: Redis pub/sub event adapter --- # Redis Events -Distributed event system using Redis pub/sub for cross-process communication. +The Redis adapter publishes JSON payloads to a channel named after each event and deserializes messages for subscribers. ## Installation -```typescript -import '@unchainedshop/plugins/events/redis'; -``` - -:::warning Explicit Configuration Required -Unlike the Node.js event emitter (which is the default), this plugin requires explicit configuration. You must call `setEmitAdapter()` to activate Redis as your event system: - -```typescript -import { setEmitAdapter } from '@unchainedshop/events'; -import { RedisEventEmitter } from '@unchainedshop/plugins/events/redis'; - -setEmitAdapter(RedisEventEmitter()); -``` -::: - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `REDIS_HOST` | - | Redis server hostname (required) | -| `REDIS_PORT` | `6379` | Redis server port | -| `REDIS_DB` | `0` | Redis database number | - -## Features - -- **Distributed**: Events work across multiple application instances -- **Persistent Connections**: Maintains Redis pub/sub connections -- **JSON Serialization**: Automatic payload serialization/deserialization -- **Scalable**: Supports horizontal scaling -- **Reliable**: Redis provides reliability and persistence options - -## Use Cases - -- **Multi-Instance Deployments**: Applications running on multiple servers -- **Microservices**: Communication between different services -- **Horizontal Scaling**: When you need to scale beyond a single instance -- **Production Deployments**: Robust event handling for production - -## Redis Setup - -### Docker - ```bash -docker run -d \ - --name redis \ - -p 6379:6379 \ - redis:alpine +npm install @redis/client ``` -### Docker Compose - -```yaml -version: '3' -services: - redis: - image: redis:alpine - ports: - - "6379:6379" -``` - -## Configuration +Set the environment before importing the plugin: ```bash REDIS_HOST=localhost @@ -79,55 +23,39 @@ REDIS_PORT=6379 REDIS_DB=0 ``` -## Usage - -### Publishing Events - ```typescript -import { emit } from '@unchainedshop/events'; - -// Events are automatically distributed to all instances -await emit('ORDER_CREATE', { - orderId: '12345', - userId: 'user123', - total: 99.99 -}); +import '@unchainedshop/plugins/events/redis.js'; ``` -### Subscribing to Events - -```typescript -import { subscribe } from '@unchainedshop/events'; +The import calls `setEmitAdapter()` when the Redis settings are present. `RedisEventEmitter` is an internal factory and is not exported. Import the selected adapter before registering event subscriptions; a later event-adapter import replaces the active adapter. -// Each instance receives the event -subscribe('ORDER_CREATE', async (payload) => { - const { orderId, userId, total } = payload; - await processOrder(orderId); -}); -``` +## Environment Variables -## Performance +| Variable | Default | Description | +|----------|---------|-------------| +| `REDIS_HOST` | Unset | Redis hostname; required for registration | +| `REDIS_PORT` | `6379` | Redis port | +| `REDIS_DB` | `0` | Redis database number | -- **Pros**: Distributed, reliable, cost-effective -- **Cons**: Network latency, requires Redis infrastructure +## Current Limitations -## When to Use +The implementation creates publisher and subscriber clients but does not call their `connect()` methods. Connection lifecycle handling must be added before this adapter can be used with the declared Redis client dependency. -Use Redis Events for: +It also subscribes only the first callback registered for each event name. Applications with multiple listeners for one event need callback fan-out. There is no retry, replay, or durable event queue in this adapter. -- Horizontal scaling requirements -- Multiple application instances -- Production deployments -- Cost-effective distributed events +Event callbacks receive a payload envelope: -## Adapter Details +```typescript +import { registerEvents, subscribe } from '@unchainedshop/events'; -| Property | Value | -|----------|-------| -| Source | [events/redis.ts](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/redis.ts) | +registerEvents(['CUSTOM_EVENT']); +subscribe('CUSTOM_EVENT', ({ payload }) => { + console.log(payload); +}); +``` ## Related -- [Node.js Events](./events-node.md) - In-memory events -- [AWS EventBridge](./events-eventbridge.md) - Cloud-native events -- [Plugins Overview](./) - All available plugins +- [Node.js Events](./events-node.md) +- [AWS EventBridge](./events-eventbridge.md) +- [Adapter source](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/events/redis.ts) diff --git a/docs/docs/plugins/files/file-gridfs.md b/docs/docs/plugins/files/file-gridfs.md index 643d930b4b..5084fb07a9 100644 --- a/docs/docs/plugins/files/file-gridfs.md +++ b/docs/docs/plugins/files/file-gridfs.md @@ -17,9 +17,9 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas **Express:** ```typescript -import gridfsModules from '@unchainedshop/plugins/files/gridfs'; -import '@unchainedshop/plugins/files/gridfs'; -import gridfsHandler from '@unchainedshop/plugins/files/gridfs/handler-express'; +import gridfsModules from '@unchainedshop/plugins/files/gridfs/index.js'; +import '@unchainedshop/plugins/files/gridfs/index.js'; +import gridfsHandler from '@unchainedshop/plugins/files/gridfs/handler-express.js'; const { GRIDFS_PUT_SERVER_PATH = '/gridfs' } = process.env; @@ -35,9 +35,9 @@ app.use(GRIDFS_PUT_SERVER_PATH, gridfsHandler); **Fastify:** ```typescript -import gridfsModules from '@unchainedshop/plugins/files/gridfs'; -import '@unchainedshop/plugins/files/gridfs'; -import gridfsHandler from '@unchainedshop/plugins/files/gridfs/handler-fastify'; +import gridfsModules from '@unchainedshop/plugins/files/gridfs/index.js'; +import '@unchainedshop/plugins/files/gridfs/index.js'; +import gridfsHandler from '@unchainedshop/plugins/files/gridfs/handler-fastify.js'; const { GRIDFS_PUT_SERVER_PATH = '/gridfs' } = process.env; diff --git a/docs/docs/plugins/files/file-minio.md b/docs/docs/plugins/files/file-minio.md index 50551f28f1..6289357e0c 100644 --- a/docs/docs/plugins/files/file-minio.md +++ b/docs/docs/plugins/files/file-minio.md @@ -2,155 +2,44 @@ sidebar_position: 22 title: MinIO/S3 File Storage sidebar_label: MinIO/S3 -description: S3-compatible file storage with MinIO or Amazon S3 +description: S3-compatible file storage using the MinIO client --- # MinIO/S3 File Storage -S3-compatible object storage using the MinIO client, supporting both MinIO and Amazon S3. +The adapter stores objects using the MinIO client and creates signed PUT URLs for direct uploads. -:::warning GridFS Conflict -If you're using a preset that includes GridFS (like `base` or `all`), you must unregister the GridFS adapter before using MinIO: +## Installation + +```bash +npm install minio +``` ```typescript import { FileDirector } from '@unchainedshop/file-upload'; -import '@unchainedshop/plugins/files/minio'; +import '@unchainedshop/plugins/files/minio/index.js'; -// Unregister GridFS adapter loaded by presets +// Remove the GridFS adapter if it was loaded by a preset. FileDirector.unregisterAdapter('shop.unchained.file-upload-plugin.gridfs'); ``` -::: -## Installation - -```typescript -import '@unchainedshop/plugins/files/minio'; -``` - -The plugin automatically registers when environment variables are configured. +The adapter registers on import. Configure the environment beforehand so its client can initialize. File services select the first registered file adapter. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `MINIO_ENDPOINT` | - | MinIO/S3 endpoint URL (required) | -| `MINIO_BUCKET_NAME` | - | Storage bucket name (required) | -| `MINIO_ACCESS_KEY` | - | Access key for authentication | -| `MINIO_SECRET_KEY` | - | Secret key for authentication | -| `MINIO_REGION` | - | Storage region | -| `MINIO_UPLOAD_PREFIX` | - | Prefix for uploaded file paths | -| `MINIO_STS_ENDPOINT` | - | STS endpoint for temporary credentials | -| `AMAZON_S3_SESSION_TOKEN` | - | AWS session token for temporary access | - -## Features - -- **S3 Compatibility**: Works with Amazon S3, MinIO, and other S3-compatible services -- **Signed URLs**: Pre-signed URLs for secure direct uploads -- **Streaming**: Support for streaming uploads and downloads -- **File Management**: Upload, download, and delete operations -- **Multi-format Support**: Automatic MIME type detection -- **Temporary Credentials**: Support for AWS STS temporary credentials - -## Use Cases - -- **Production Deployments**: Scalable file storage -- **CDN Integration**: Easy integration with CloudFront or other CDNs -- **Large Files**: No file size limits -- **High Traffic**: Optimized for file serving at scale - -## Usage - -### Upload from Stream - -```typescript -const fileData = await fileAdapter.uploadFileFromStream( - 'product-images', - fileStream -); -``` - -### Upload from URL - -```typescript -const fileData = await fileAdapter.uploadFileFromURL( - 'product-images', - { - fileLink: 'https://example.com/image.jpg', - fileName: 'product-image.jpg' - } -); -``` - -### Create Signed Upload URL - -```typescript -const signedUrl = await fileAdapter.createSignedURL( - 'product-images', - 'new-image.jpg' -); -``` - -### Download File - -```typescript -const downloadUrl = await fileAdapter.createDownloadURL(file); -const stream = await fileAdapter.createDownloadStream({ fileId: file._id }); -``` - -## Express Handler - -```typescript -import { createMinioExpressHandler } from '@unchainedshop/plugins/files/minio/handler-express'; - -app.use('/files', createMinioExpressHandler()); -``` - -## Fastify Handler - -```typescript -import { createMinioFastifyHandler } from '@unchainedshop/plugins/files/minio/handler-fastify'; - -fastify.register(createMinioFastifyHandler); -``` - -## Local MinIO Setup - -### Docker - -```bash -docker run -d \ - --name minio \ - -p 9000:9000 \ - -p 9001:9001 \ - -e MINIO_ROOT_USER=minioadmin \ - -e MINIO_ROOT_PASSWORD=minioadmin \ - minio/minio server /data --console-address ":9001" -``` - -### Docker Compose - -```yaml -version: '3' -services: - minio: - image: minio/minio - command: server /data --console-address ":9001" - ports: - - "9000:9000" - - "9001:9001" - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - volumes: - - minio_data:/data - -volumes: - minio_data: -``` - -## Configuration Examples - -### Local MinIO +| `MINIO_ENDPOINT` | Unset | Endpoint URL, including scheme and optional port; required | +| `MINIO_BUCKET_NAME` | Unset | Existing bucket name; required | +| `MINIO_ACCESS_KEY` | Unset | Access key | +| `MINIO_SECRET_KEY` | Unset | Secret key | +| `MINIO_REGION` | Unset | Storage region | +| `MINIO_UPLOAD_PREFIX` | Empty | Object-key prefix | +| `MINIO_STS_ENDPOINT` | Unset | STS endpoint for the MinIO assume-role provider | +| `AMAZON_S3_SESSION_TOKEN` | Unset | Session token for temporary credentials | +| `MINIO_WEBHOOK_AUTH_TOKEN` | Unset | Required bearer token for upload notification handlers | + +Example for a local server with an existing `uploads` bucket: ```bash MINIO_ENDPOINT=http://localhost:9000 @@ -159,60 +48,75 @@ MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ``` -### AWS S3 +## File Operations -```bash -MINIO_ENDPOINT=https://s3.amazonaws.com -MINIO_BUCKET_NAME=your-bucket -MINIO_ACCESS_KEY=AKIA... -MINIO_SECRET_KEY=... -MINIO_REGION=us-east-1 -``` +The adapter methods handle object storage. Use the core file services when a file also needs metadata persisted and upload callbacks invoked. -## Path Structure +```typescript +import { MinioAdapter } from '@unchainedshop/plugins/files/minio/index.js'; + +// rawFile uses the same shape as a GraphQL multipart upload. +const rawFile = Promise.resolve({ + filename: 'product-image.jpg', + mimetype: 'image/jpeg', + createReadStream: () => fileStream, +}); +const uploaded = await MinioAdapter.uploadFileFromStream('product-images', rawFile, unchainedAPI); + +const imported = await MinioAdapter.uploadFileFromURL('product-images', { + fileLink: 'https://example.com/image.jpg', + fileName: 'product-image.jpg', +}, unchainedAPI); + +const signed = await MinioAdapter.createSignedURL('product-images', 'new-image.jpg', unchainedAPI); +// Upload directly to signed.putURL before signed.expiryDate. + +// file is a stored file document containing _id and path. +const downloadUrl = await MinioAdapter.createDownloadURL(file); +const stream = await MinioAdapter.createDownloadStream(file, unchainedAPI); +``` -Files are organized using the following structure: +Downloads return the object's public URL. Private download URLs are not implemented: `createDownloadURL` throws for files with `meta.isPrivate`. -``` -bucket/ - └── [MINIO_UPLOAD_PREFIX]/ - └── [directoryName]/ - └── [hashedFilename] -``` +## Upload Notifications -## Security +The Express and Fastify handlers accept `s3:ObjectCreated:Put` notifications. Requests must include `Authorization: Bearer `. The handlers need the request's Unchained context and call `services.files.linkFile` to complete the upload. -- **Pre-signed URLs**: Secure uploads without exposing credentials -- **Hashed Filenames**: Automatic filename hashing -- **Expiration**: Configurable URL expiration times -- **Bucket Policies**: Configure appropriate S3 bucket policies +Mount the handler through `connect()` so the context is available: -## Production Considerations +```typescript +import express from 'express'; +import { connect } from '@unchainedshop/api/express'; +import minioHandler from '@unchainedshop/plugins/files/minio/handler-express.js'; + +connect(app, engine, { + initPluginMiddlewares(app) { + app.post('/files/minio', express.json(), minioHandler); + }, +}); +``` -- **CDN Integration**: Use CloudFront or similar CDN -- **Regional Deployment**: Choose appropriate regions -- **CORS**: Set up CORS for frontend uploads -- **Encryption**: Enable server-side encryption +```typescript +import { connect } from '@unchainedshop/api/fastify'; +import minioHandler from '@unchainedshop/plugins/files/minio/handler-fastify.js'; + +connect(fastify, engine, { + initPluginMiddlewares(app) { + app.post('/files/minio', minioHandler); + }, +}); +``` -## GridFS vs MinIO/S3 +Configure the object store to send notifications to the matching endpoint. These handlers are default exports, not router factories. -| Feature | GridFS | MinIO/S3 | -|---------|--------|----------| -| External Service | No | Yes | -| Scalability | MongoDB limits | Virtually unlimited | -| CDN Integration | Manual | Easy | -| Development Setup | Simple | Requires MinIO/S3 | -| Production Scaling | Limited | Excellent | +## Object Keys and Limits -## Adapter Details +Uploads use `[MINIO_UPLOAD_PREFIX]/[directoryName]/[hashedFilename]`, omitting empty segments. Configure bucket access and upload CORS on the object store. -| Property | Value | -|----------|-------| -| Key | `shop.unchained.file-upload-plugin.minio` | -| Source | [files/minio/](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/files/minio/) | +The current removal implementation does not prepend `MINIO_UPLOAD_PREFIX`, and notification handling assumes an object key compatible with its file-ID extraction. Test prefixed paths and upload completion against your store before enabling a prefix. ## Related -- [GridFS Storage](./file-gridfs.md) - MongoDB-based storage -- [File Uploads Guide](../../guides/file-uploads.md) - File upload implementation -- [Plugins Overview](./) - All available plugins +- [GridFS Storage](./file-gridfs.md) +- [File Uploads Guide](../../guides/file-uploads.md) +- [Adapter source](https://github.com/unchainedshop/unchained/blob/master/packages/plugins/src/files/minio/) diff --git a/docs/docs/plugins/filters/filter-local-search.md b/docs/docs/plugins/filters/filter-local-search.md index a4c702b8a8..9617c802c9 100644 --- a/docs/docs/plugins/filters/filter-local-search.md +++ b/docs/docs/plugins/filters/filter-local-search.md @@ -12,7 +12,7 @@ The Local Search filter provides full-text search using MongoDB's built-in text ## Installation ```typescript -import '@unchainedshop/plugins/filters/local-search'; +import '@unchainedshop/plugins/filters/local-search.js'; ``` ## Adapter Details diff --git a/docs/docs/plugins/filters/filter-strict-equal.md b/docs/docs/plugins/filters/filter-strict-equal.md index 4aa424ed1b..3673eefdd2 100644 --- a/docs/docs/plugins/filters/filter-strict-equal.md +++ b/docs/docs/plugins/filters/filter-strict-equal.md @@ -12,7 +12,7 @@ The Strict Equal filter provides simple exact-match filtering on product fields. ## Installation ```typescript -import '@unchainedshop/plugins/filters/strict-equal'; +import '@unchainedshop/plugins/filters/strict-equal.js'; ``` ## Adapter Details diff --git a/docs/docs/plugins/payment/apple-iap.md b/docs/docs/plugins/payment/apple-iap.md index 5a84abe8b7..a67b1b41a9 100644 --- a/docs/docs/plugins/payment/apple-iap.md +++ b/docs/docs/plugins/payment/apple-iap.md @@ -18,9 +18,9 @@ Unchained payment plugin for Apple In-App Purchase (IAP), enabling iOS apps to p **Express:** ```typescript import express from 'express'; -import appleTransactionsModule from '@unchainedshop/plugins/payment/apple-iap'; -import '@unchainedshop/plugins/payment/apple-iap'; -import { appleIAPHandler } from '@unchainedshop/plugins/payment/apple-iap/handler-express'; +import appleTransactionsModule from '@unchainedshop/plugins/payment/apple-iap/index.js'; +import '@unchainedshop/plugins/payment/apple-iap/index.js'; +import { appleIAPHandler } from '@unchainedshop/plugins/payment/apple-iap/handler-express.js'; const { APPLE_IAP_WEBHOOK_PATH = '/payment/apple-iap' } = process.env; @@ -36,9 +36,9 @@ app.use(APPLE_IAP_WEBHOOK_PATH, express.json({ strict: false }), appleIAPHandler **Fastify:** ```typescript -import appleTransactionsModule from '@unchainedshop/plugins/payment/apple-iap'; -import '@unchainedshop/plugins/payment/apple-iap'; -import { appleIAPHandler } from '@unchainedshop/plugins/payment/apple-iap/handler-fastify'; +import appleTransactionsModule from '@unchainedshop/plugins/payment/apple-iap/index.js'; +import '@unchainedshop/plugins/payment/apple-iap/index.js'; +import { appleIAPHandler } from '@unchainedshop/plugins/payment/apple-iap/handler-fastify.js'; const { APPLE_IAP_WEBHOOK_PATH = '/payment/apple-iap' } = process.env; diff --git a/docs/docs/plugins/payment/braintree.md b/docs/docs/plugins/payment/braintree.md index d7d79dfede..10b177614b 100644 --- a/docs/docs/plugins/payment/braintree.md +++ b/docs/docs/plugins/payment/braintree.md @@ -20,7 +20,7 @@ This plugin is **not** included in the default plugin presets. You need to impor ## Installation ```typescript -import '@unchainedshop/plugins/payment/braintree'; +import '@unchainedshop/plugins/payment/braintree.js'; ``` Requires the `braintree` npm package as a peer dependency: diff --git a/docs/docs/plugins/payment/cryptopay.md b/docs/docs/plugins/payment/cryptopay.md index d4d12eff00..08ac2a46c0 100644 --- a/docs/docs/plugins/payment/cryptopay.md +++ b/docs/docs/plugins/payment/cryptopay.md @@ -22,9 +22,9 @@ Because the plugin is using the currency rate system of Unchained with support f **Express:** ```typescript import express from 'express'; -import cryptopayModules from '@unchainedshop/plugins/payment/cryptopay'; -import '@unchainedshop/plugins/payment/cryptopay'; -import cryptopayHandler from '@unchainedshop/plugins/payment/cryptopay/handler-express'; +import cryptopayModules from '@unchainedshop/plugins/payment/cryptopay/index.js'; +import '@unchainedshop/plugins/payment/cryptopay/index.js'; +import cryptopayHandler from '@unchainedshop/plugins/payment/cryptopay/handler-express.js'; const { CRYPTOPAY_WEBHOOK_PATH = '/payment/cryptopay' } = process.env; @@ -40,9 +40,9 @@ app.use(CRYPTOPAY_WEBHOOK_PATH, express.json(), cryptopayHandler); **Fastify:** ```typescript -import cryptopayModules from '@unchainedshop/plugins/payment/cryptopay'; -import '@unchainedshop/plugins/payment/cryptopay'; -import cryptopayHandler from '@unchainedshop/plugins/payment/cryptopay/handler-fastify'; +import cryptopayModules from '@unchainedshop/plugins/payment/cryptopay/index.js'; +import '@unchainedshop/plugins/payment/cryptopay/index.js'; +import cryptopayHandler from '@unchainedshop/plugins/payment/cryptopay/handler-fastify.js'; const { CRYPTOPAY_WEBHOOK_PATH = '/payment/cryptopay' } = process.env; diff --git a/docs/docs/plugins/payment/datatrans.md b/docs/docs/plugins/payment/datatrans.md index 5fdd25ba2e..cc1ccbe467 100644 --- a/docs/docs/plugins/payment/datatrans.md +++ b/docs/docs/plugins/payment/datatrans.md @@ -18,8 +18,8 @@ Unchained payment plugin for Datatrans, a Swiss payment service provider support **Express:** ```typescript import express from 'express'; -import '@unchainedshop/plugins/payment/datatrans-v2'; -import { datatransHandler } from '@unchainedshop/plugins/payment/datatrans-v2/handler-express'; +import '@unchainedshop/plugins/payment/datatrans-v2/index.js'; +import { datatransHandler } from '@unchainedshop/plugins/payment/datatrans-v2/handler-express.js'; const { DATATRANS_WEBHOOK_PATH = '/payment/datatrans/webhook' } = process.env; @@ -29,8 +29,8 @@ app.use(DATATRANS_WEBHOOK_PATH, express.text({ type: 'application/json' }), data **Fastify:** ```typescript -import '@unchainedshop/plugins/payment/datatrans-v2'; -import { datatransHandler } from '@unchainedshop/plugins/payment/datatrans-v2/handler-fastify'; +import '@unchainedshop/plugins/payment/datatrans-v2/index.js'; +import { datatransHandler } from '@unchainedshop/plugins/payment/datatrans-v2/handler-fastify.js'; const { DATATRANS_WEBHOOK_PATH = '/payment/datatrans/webhook' } = process.env; diff --git a/docs/docs/plugins/payment/invoice-prepaid.md b/docs/docs/plugins/payment/invoice-prepaid.md index 5f196a0441..fbb48b786a 100644 --- a/docs/docs/plugins/payment/invoice-prepaid.md +++ b/docs/docs/plugins/payment/invoice-prepaid.md @@ -12,7 +12,7 @@ Prepaid invoice payment plugin that requires payment confirmation before order f ## Installation ```typescript -import '@unchainedshop/plugins/payment/invoice-prepaid'; +import '@unchainedshop/plugins/payment/invoice-prepaid.js'; ``` ## Setup diff --git a/docs/docs/plugins/payment/invoice.md b/docs/docs/plugins/payment/invoice.md index 28a46e95f8..0839501fa1 100644 --- a/docs/docs/plugins/payment/invoice.md +++ b/docs/docs/plugins/payment/invoice.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/payment/invoice'; +import '@unchainedshop/plugins/payment/invoice.js'; ``` ## Setup diff --git a/docs/docs/plugins/payment/paypal-checkout.md b/docs/docs/plugins/payment/paypal-checkout.md index 43fd71c32b..97889bc264 100644 --- a/docs/docs/plugins/payment/paypal-checkout.md +++ b/docs/docs/plugins/payment/paypal-checkout.md @@ -20,7 +20,7 @@ Unchained payment plugin for PayPal Checkout using the PayPal Checkout Server SD ## Installation ```typescript -import '@unchainedshop/plugins/payment/paypal-checkout'; +import '@unchainedshop/plugins/payment/paypal-checkout.js'; ``` Requires the `@paypal/checkout-server-sdk` npm package as a peer dependency: diff --git a/docs/docs/plugins/payment/payrexx.md b/docs/docs/plugins/payment/payrexx.md index d005baf49b..1f952b071a 100644 --- a/docs/docs/plugins/payment/payrexx.md +++ b/docs/docs/plugins/payment/payrexx.md @@ -16,8 +16,8 @@ Unchained payment plugin for Payrexx, a Swiss payment service provider supportin **Express:** ```typescript import express from 'express'; -import '@unchainedshop/plugins/payment/payrexx'; -import { payrexxHandler } from '@unchainedshop/plugins/payment/payrexx/handler-express'; +import '@unchainedshop/plugins/payment/payrexx/index.js'; +import { payrexxHandler } from '@unchainedshop/plugins/payment/payrexx/handler-express.js'; const { PAYREXX_WEBHOOK_PATH = '/payment/payrexx' } = process.env; @@ -26,8 +26,8 @@ app.use(PAYREXX_WEBHOOK_PATH, express.json({ type: 'application/json' }), payrex **Fastify:** ```typescript -import '@unchainedshop/plugins/payment/payrexx'; -import { payrexxHandler } from '@unchainedshop/plugins/payment/payrexx/handler-fastify'; +import '@unchainedshop/plugins/payment/payrexx/index.js'; +import { payrexxHandler } from '@unchainedshop/plugins/payment/payrexx/handler-fastify.js'; const { PAYREXX_WEBHOOK_PATH = '/payment/payrexx' } = process.env; diff --git a/docs/docs/plugins/payment/postfinance-checkout.md b/docs/docs/plugins/payment/postfinance-checkout.md index 4251cc0b68..721ae6e679 100644 --- a/docs/docs/plugins/payment/postfinance-checkout.md +++ b/docs/docs/plugins/payment/postfinance-checkout.md @@ -17,8 +17,8 @@ The Unchained plugin implements the PostFinance Checkout payment service with su **Express:** ```typescript import express from 'express'; -import '@unchainedshop/plugins/payment/postfinance-checkout'; -import { postfinanceCheckoutHandler } from '@unchainedshop/plugins/payment/postfinance-checkout/handler-express'; +import '@unchainedshop/plugins/payment/postfinance-checkout/index.js'; +import { postfinanceCheckoutHandler } from '@unchainedshop/plugins/payment/postfinance-checkout/handler-express.js'; const { PFCHECKOUT_WEBHOOK_PATH = '/payment/postfinance-checkout' } = process.env; @@ -27,8 +27,8 @@ app.use(PFCHECKOUT_WEBHOOK_PATH, express.json(), postfinanceCheckoutHandler); **Fastify:** ```typescript -import '@unchainedshop/plugins/payment/postfinance-checkout'; -import { postfinanceCheckoutHandler } from '@unchainedshop/plugins/payment/postfinance-checkout/handler-fastify'; +import '@unchainedshop/plugins/payment/postfinance-checkout/index.js'; +import { postfinanceCheckoutHandler } from '@unchainedshop/plugins/payment/postfinance-checkout/handler-fastify.js'; const { PFCHECKOUT_WEBHOOK_PATH = '/payment/postfinance-checkout' } = process.env; diff --git a/docs/docs/plugins/payment/saferpay.md b/docs/docs/plugins/payment/saferpay.md index 3931227d75..f1605d3b54 100644 --- a/docs/docs/plugins/payment/saferpay.md +++ b/docs/docs/plugins/payment/saferpay.md @@ -16,9 +16,9 @@ Unchained payment plugin for Worldline Saferpay, supporting the Payment Page API **Express:** ```typescript -import saferpayTransactionsModule from '@unchainedshop/plugins/payment/saferpay'; -import '@unchainedshop/plugins/payment/saferpay'; -import { saferpayHandler } from '@unchainedshop/plugins/payment/saferpay/handler-express'; +import saferpayTransactionsModule from '@unchainedshop/plugins/payment/saferpay/index.js'; +import '@unchainedshop/plugins/payment/saferpay/index.js'; +import { saferpayHandler } from '@unchainedshop/plugins/payment/saferpay/handler-express.js'; const { SAFERPAY_WEBHOOK_PATH = '/payment/saferpay/webhook' } = process.env; @@ -35,9 +35,9 @@ app.get(SAFERPAY_WEBHOOK_PATH, saferpayHandler); **Fastify:** ```typescript -import saferpayTransactionsModule from '@unchainedshop/plugins/payment/saferpay'; -import '@unchainedshop/plugins/payment/saferpay'; -import { saferpayHandler } from '@unchainedshop/plugins/payment/saferpay/handler-fastify'; +import saferpayTransactionsModule from '@unchainedshop/plugins/payment/saferpay/index.js'; +import '@unchainedshop/plugins/payment/saferpay/index.js'; +import { saferpayHandler } from '@unchainedshop/plugins/payment/saferpay/handler-fastify.js'; const { SAFERPAY_WEBHOOK_PATH = '/payment/saferpay/webhook' } = process.env; diff --git a/docs/docs/plugins/payment/stripe.md b/docs/docs/plugins/payment/stripe.md index 1a8f8a9457..c2591fdfda 100644 --- a/docs/docs/plugins/payment/stripe.md +++ b/docs/docs/plugins/payment/stripe.md @@ -19,8 +19,8 @@ Unchained payment plugin for Stripe, supporting payment intents, saved payment m **Express:** ```typescript import express from 'express'; -import '@unchainedshop/plugins/payment/stripe'; -import { stripeHandler } from '@unchainedshop/plugins/payment/stripe/handler-express'; +import '@unchainedshop/plugins/payment/stripe/index.js'; +import { stripeHandler } from '@unchainedshop/plugins/payment/stripe/handler-express.js'; const { STRIPE_WEBHOOK_PATH = '/payment/stripe' } = process.env; @@ -30,8 +30,8 @@ app.use(STRIPE_WEBHOOK_PATH, express.raw({ type: 'application/json' }), stripeHa **Fastify:** ```typescript -import '@unchainedshop/plugins/payment/stripe'; -import { stripeHandler } from '@unchainedshop/plugins/payment/stripe/handler-fastify'; +import '@unchainedshop/plugins/payment/stripe/index.js'; +import { stripeHandler } from '@unchainedshop/plugins/payment/stripe/handler-fastify.js'; const { STRIPE_WEBHOOK_PATH = '/payment/stripe' } = process.env; diff --git a/docs/docs/plugins/pricing/pricing-delivery-eu-tax.md b/docs/docs/plugins/pricing/pricing-delivery-eu-tax.md index 0175dbeab2..fb8f982086 100644 --- a/docs/docs/plugins/pricing/pricing-delivery-eu-tax.md +++ b/docs/docs/plugins/pricing/pricing-delivery-eu-tax.md @@ -12,7 +12,7 @@ Applies destination-based EU VAT to delivery fees. Only activates for orders who ## Installation ```typescript -import '@unchainedshop/plugins/pricing/delivery-eu-tax'; +import '@unchainedshop/plugins/pricing/delivery-eu-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-delivery-free.md b/docs/docs/plugins/pricing/pricing-delivery-free.md index cde1a7e9c0..308565b832 100644 --- a/docs/docs/plugins/pricing/pricing-delivery-free.md +++ b/docs/docs/plugins/pricing/pricing-delivery-free.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/free-delivery'; +import '@unchainedshop/plugins/pricing/free-delivery.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-delivery-swiss-tax.md b/docs/docs/plugins/pricing/pricing-delivery-swiss-tax.md index 5294277636..3118731bf3 100644 --- a/docs/docs/plugins/pricing/pricing-delivery-swiss-tax.md +++ b/docs/docs/plugins/pricing/pricing-delivery-swiss-tax.md @@ -12,7 +12,7 @@ Applies Swiss VAT rates to delivery fees. Only activates for orders with deliver ## Installation ```typescript -import '@unchainedshop/plugins/pricing/delivery-swiss-tax'; +import '@unchainedshop/plugins/pricing/delivery-swiss-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-delivery-uk-tax.md b/docs/docs/plugins/pricing/pricing-delivery-uk-tax.md index 5eb45b15b3..97174061e2 100644 --- a/docs/docs/plugins/pricing/pricing-delivery-uk-tax.md +++ b/docs/docs/plugins/pricing/pricing-delivery-uk-tax.md @@ -12,7 +12,7 @@ Applies UK VAT rates to delivery fees. Only activates for orders with delivery a ## Installation ```typescript -import '@unchainedshop/plugins/pricing/delivery-uk-tax'; +import '@unchainedshop/plugins/pricing/delivery-uk-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-delivery-us-sales-tax.md b/docs/docs/plugins/pricing/pricing-delivery-us-sales-tax.md index 0d3a9562bc..9d0bc2009d 100644 --- a/docs/docs/plugins/pricing/pricing-delivery-us-sales-tax.md +++ b/docs/docs/plugins/pricing/pricing-delivery-us-sales-tax.md @@ -12,7 +12,7 @@ Applies the statewide base sales tax rate to delivery fees for US orders. Shares ## Installation ```typescript -import '@unchainedshop/plugins/pricing/delivery-us-sales-tax'; +import '@unchainedshop/plugins/pricing/delivery-us-sales-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-discount-100-off.md b/docs/docs/plugins/pricing/pricing-discount-100-off.md index 4afbd2a374..ec8a2c6868 100644 --- a/docs/docs/plugins/pricing/pricing-discount-100-off.md +++ b/docs/docs/plugins/pricing/pricing-discount-100-off.md @@ -12,7 +12,7 @@ A sample discount adapter demonstrating a fixed-amount coupon code (100 CHF off) ## Installation ```typescript -import '@unchainedshop/plugins/pricing/discount-100-off'; +import '@unchainedshop/plugins/pricing/discount-100-off.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-discount-half-price-manual.md b/docs/docs/plugins/pricing/pricing-discount-half-price-manual.md index 892187c558..dd2cd37ecc 100644 --- a/docs/docs/plugins/pricing/pricing-discount-half-price-manual.md +++ b/docs/docs/plugins/pricing/pricing-discount-half-price-manual.md @@ -12,7 +12,7 @@ A sample discount adapter demonstrating a percentage-based coupon code. Applies ## Installation ```typescript -import '@unchainedshop/plugins/pricing/discount-half-price-manual'; +import '@unchainedshop/plugins/pricing/discount-half-price-manual.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-discount-half-price.md b/docs/docs/plugins/pricing/pricing-discount-half-price.md index 37ae5b3baf..ca034d5cfc 100644 --- a/docs/docs/plugins/pricing/pricing-discount-half-price.md +++ b/docs/docs/plugins/pricing/pricing-discount-half-price.md @@ -12,7 +12,7 @@ A sample discount adapter demonstrating automatic system-triggered discounts. Ap ## Installation ```typescript -import '@unchainedshop/plugins/pricing/discount-half-price'; +import '@unchainedshop/plugins/pricing/discount-half-price.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-order-delivery.md b/docs/docs/plugins/pricing/pricing-order-delivery.md index 06f475c590..ebe032e42e 100644 --- a/docs/docs/plugins/pricing/pricing-order-delivery.md +++ b/docs/docs/plugins/pricing/pricing-order-delivery.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/order-delivery'; +import '@unchainedshop/plugins/pricing/order-delivery.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-order-discount.md b/docs/docs/plugins/pricing/pricing-order-discount.md index e976718cfe..c6057cec21 100644 --- a/docs/docs/plugins/pricing/pricing-order-discount.md +++ b/docs/docs/plugins/pricing/pricing-order-discount.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/order-discount'; +import '@unchainedshop/plugins/pricing/order-discount.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-order-items-discount.md b/docs/docs/plugins/pricing/pricing-order-items-discount.md index a012942e15..3b546534f1 100644 --- a/docs/docs/plugins/pricing/pricing-order-items-discount.md +++ b/docs/docs/plugins/pricing/pricing-order-items-discount.md @@ -12,7 +12,7 @@ Applies discounts to the total value of goods (items only), excluding delivery a ## Installation ```typescript -import '@unchainedshop/plugins/pricing/order-items-discount'; +import '@unchainedshop/plugins/pricing/order-items-discount.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-order-items.md b/docs/docs/plugins/pricing/pricing-order-items.md index 81e22ec681..fc0556e152 100644 --- a/docs/docs/plugins/pricing/pricing-order-items.md +++ b/docs/docs/plugins/pricing/pricing-order-items.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/order-items'; +import '@unchainedshop/plugins/pricing/order-items.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-order-payment.md b/docs/docs/plugins/pricing/pricing-order-payment.md index 04e7e549ca..19f4c7f6d6 100644 --- a/docs/docs/plugins/pricing/pricing-order-payment.md +++ b/docs/docs/plugins/pricing/pricing-order-payment.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/order-payment'; +import '@unchainedshop/plugins/pricing/order-payment.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-order-round.md b/docs/docs/plugins/pricing/pricing-order-round.md index fe5a713e2b..3909a2869e 100644 --- a/docs/docs/plugins/pricing/pricing-order-round.md +++ b/docs/docs/plugins/pricing/pricing-order-round.md @@ -12,7 +12,7 @@ Rounds all order pricing categories (items, delivery, payment, discounts, taxes) ## Installation ```typescript -import '@unchainedshop/plugins/pricing/order-round'; +import '@unchainedshop/plugins/pricing/order-round.js'; ``` ## How It Works @@ -31,7 +31,7 @@ import '@unchainedshop/plugins/pricing/order-round'; Configure the rounding behavior before starting the engine: ```typescript -import { OrderPriceRound } from '@unchainedshop/plugins/pricing/order-round'; +import { OrderPriceRound } from '@unchainedshop/plugins/pricing/order-round.js'; // Round to nearest 5 cents (default) OrderPriceRound.configure({ diff --git a/docs/docs/plugins/pricing/pricing-payment-free.md b/docs/docs/plugins/pricing/pricing-payment-free.md index a442114294..b0ecb936c9 100644 --- a/docs/docs/plugins/pricing/pricing-payment-free.md +++ b/docs/docs/plugins/pricing/pricing-payment-free.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/free-payment'; +import '@unchainedshop/plugins/pricing/free-payment.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-catalog-price-options.md b/docs/docs/plugins/pricing/pricing-product-catalog-price-options.md index 7b52721d35..30fc27fd04 100644 --- a/docs/docs/plugins/pricing/pricing-product-catalog-price-options.md +++ b/docs/docs/plugins/pricing/pricing-product-catalog-price-options.md @@ -12,7 +12,7 @@ Adds prices for product options to the pricing calculation. Used when products h ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-catalog-price-options'; +import '@unchainedshop/plugins/pricing/product-catalog-price-options.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-catalog-price.md b/docs/docs/plugins/pricing/pricing-product-catalog-price.md index b995de3384..39be4f2824 100644 --- a/docs/docs/plugins/pricing/pricing-product-catalog-price.md +++ b/docs/docs/plugins/pricing/pricing-product-catalog-price.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-catalog-price'; +import '@unchainedshop/plugins/pricing/product-catalog-price.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-discount.md b/docs/docs/plugins/pricing/pricing-product-discount.md index 8ed8f6d74e..63a0723fb5 100644 --- a/docs/docs/plugins/pricing/pricing-product-discount.md +++ b/docs/docs/plugins/pricing/pricing-product-discount.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-discount'; +import '@unchainedshop/plugins/pricing/product-discount.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-eu-tax.md b/docs/docs/plugins/pricing/pricing-product-eu-tax.md index 3e402d9353..1a11f38bc0 100644 --- a/docs/docs/plugins/pricing/pricing-product-eu-tax.md +++ b/docs/docs/plugins/pricing/pricing-product-eu-tax.md @@ -12,7 +12,7 @@ Applies destination-based EU VAT to product prices for all 27 member states. Onl ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-eu-tax'; +import '@unchainedshop/plugins/pricing/product-eu-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-rate-conversion.md b/docs/docs/plugins/pricing/pricing-product-rate-conversion.md index 84183a2184..3a53fd20dc 100644 --- a/docs/docs/plugins/pricing/pricing-product-rate-conversion.md +++ b/docs/docs/plugins/pricing/pricing-product-rate-conversion.md @@ -12,7 +12,7 @@ Converts product prices between currencies using configured exchange rates. Only ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-price-rateconversion'; +import '@unchainedshop/plugins/pricing/product-price-rateconversion.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-round.md b/docs/docs/plugins/pricing/pricing-product-round.md index 4511d1fb47..c0b49114a3 100644 --- a/docs/docs/plugins/pricing/pricing-product-round.md +++ b/docs/docs/plugins/pricing/pricing-product-round.md @@ -12,7 +12,7 @@ Rounds all product pricing calculations to a configurable precision. Typically r ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-round'; +import '@unchainedshop/plugins/pricing/product-round.js'; ``` ## How It Works @@ -26,7 +26,7 @@ import '@unchainedshop/plugins/pricing/product-round'; Configure the rounding behavior before starting the engine: ```typescript -import { ProductRound } from '@unchainedshop/plugins/pricing/product-round'; +import { ProductRound } from '@unchainedshop/plugins/pricing/product-round.js'; // Round to nearest 5 cents (default) ProductRound.configure({ diff --git a/docs/docs/plugins/pricing/pricing-product-swiss-tax.md b/docs/docs/plugins/pricing/pricing-product-swiss-tax.md index 6bb6908e63..5250a73ff7 100644 --- a/docs/docs/plugins/pricing/pricing-product-swiss-tax.md +++ b/docs/docs/plugins/pricing/pricing-product-swiss-tax.md @@ -12,7 +12,7 @@ Applies Swiss VAT rates to product prices. Only activates for deliveries to Swit ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-swiss-tax'; +import '@unchainedshop/plugins/pricing/product-swiss-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-uk-tax.md b/docs/docs/plugins/pricing/pricing-product-uk-tax.md index fa4c503835..8829b2fb7e 100644 --- a/docs/docs/plugins/pricing/pricing-product-uk-tax.md +++ b/docs/docs/plugins/pricing/pricing-product-uk-tax.md @@ -12,7 +12,7 @@ Applies UK VAT rates to product prices. Only activates for deliveries into the U ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-uk-tax'; +import '@unchainedshop/plugins/pricing/product-uk-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/pricing/pricing-product-us-sales-tax.md b/docs/docs/plugins/pricing/pricing-product-us-sales-tax.md index 6b5c0ef540..a7e73e5700 100644 --- a/docs/docs/plugins/pricing/pricing-product-us-sales-tax.md +++ b/docs/docs/plugins/pricing/pricing-product-us-sales-tax.md @@ -16,7 +16,7 @@ This adapter covers the statewide rate only (including mandatory statewide local ## Installation ```typescript -import '@unchainedshop/plugins/pricing/product-us-sales-tax'; +import '@unchainedshop/plugins/pricing/product-us-sales-tax.js'; ``` ## How It Works diff --git a/docs/docs/plugins/quotations/quotation-manual.md b/docs/docs/plugins/quotations/quotation-manual.md index 4888338798..99c90dc40f 100644 --- a/docs/docs/plugins/quotations/quotation-manual.md +++ b/docs/docs/plugins/quotations/quotation-manual.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/quotations/manual'; +import '@unchainedshop/plugins/quotations/manual.js'; ``` ## Features diff --git a/docs/docs/plugins/warehousing/warehousing-eth-minter.md b/docs/docs/plugins/warehousing/warehousing-eth-minter.md index 520822e8de..04a1e29c64 100644 --- a/docs/docs/plugins/warehousing/warehousing-eth-minter.md +++ b/docs/docs/plugins/warehousing/warehousing-eth-minter.md @@ -12,7 +12,7 @@ The ETH Minter adapter enables tokenization for NFT and Web3 products, supportin ## Installation ```typescript -import '@unchainedshop/plugins/warehousing/eth-minter'; +import '@unchainedshop/plugins/warehousing/eth-minter.js'; ``` ## Configuration diff --git a/docs/docs/plugins/warehousing/warehousing-store.md b/docs/docs/plugins/warehousing/warehousing-store.md index 500e755f3a..d9c0c9b25b 100644 --- a/docs/docs/plugins/warehousing/warehousing-store.md +++ b/docs/docs/plugins/warehousing/warehousing-store.md @@ -7,7 +7,7 @@ description: Physical inventory management adapter # Store Warehousing Adapter -The Store adapter provides basic physical inventory management for simple use cases. +The Store adapter reports a fixed stock quantity for physical products. It does not track purchases or decrement inventory. :::info Included in Base Preset This plugin is part of the `base` preset and loaded automatically. Using the base preset is strongly recommended, so explicit installation is usually not required. @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/warehousing/store'; +import '@unchainedshop/plugins/warehousing/store.js'; ``` ## Configuration @@ -38,8 +38,8 @@ Configure the `name` via the Admin UI after creation. ## Features -- Physical inventory management -- Unlimited stock (returns 99999) +- Physical-product availability +- Fixed stock of 99999 units - Zero production/commissioning time - Simple drop-in for development @@ -65,7 +65,7 @@ Configure the `name` via the Admin UI after creation. Always returns `true`. ### `stock()` -Returns `99999` - effectively unlimited stock. +Returns `99999` for every product and reference date. This is a fixed placeholder quantity, not measured inventory. ### `productionTime()` Returns `0` - no production delay. @@ -80,119 +80,48 @@ Returns `0` - no preparation delay. Use the Store adapter during development when you don't need real inventory tracking: ```typescript -import '@unchainedshop/plugins/warehousing/store'; +import '@unchainedshop/plugins/warehousing/store.js'; ``` ### Simple Stores For small shops where inventory is managed manually outside the system. -### Drop-shipping +## Connecting Inventory -Where stock is always available from suppliers: +Use a custom adapter with an application-provided inventory lookup. Start with the `WarehousingAdapter` defaults so all required actions are present: ```typescript -const DropShipAdapter = { - ...StoreAdapter, - key: 'my-shop.dropship', - label: 'Drop Ship', - - actions(config, context) { - return { - ...StoreAdapter.actions(config, context), - - async stock() { - // Always available from supplier - return 99999; - }, - - async commissioningTime() { - // Supplier needs 2-3 days to ship - return 3 * 24 * 60 * 60 * 1000; - }, - }; - }, -}; -``` - -## Extending for Real Inventory - -For production use, extend with actual inventory tracking: - -```typescript -import { - WarehousingDirector, - WarehousingAdapter, - type IWarehousingAdapter -} from '@unchainedshop/core'; - -const RealStoreAdapter: IWarehousingAdapter = { - ...WarehousingAdapter, - - key: 'my-shop.real-store', - label: 'Real Store Inventory', - version: '1.0.0', - orderIndex: 0, - - typeSupported: (type) => type === 'PHYSICAL', - - actions(configuration, context) { - const { product, modules } = context; - - return { - ...WarehousingAdapter.actions(configuration, context), - - isActive() { - return true; - }, - - configurationError() { - return null; - }, - - async stock() { - // Get stock from product warehousing data - const sku = product?.warehousing?.sku; - if (!sku) return 0; - - // Query your inventory system - const inventory = await db.collection('inventory').findOne({ sku }); - return inventory?.quantity || 0; - }, - - async productionTime() { - const currentStock = await this.stock(); - if (currentStock > 0) return 0; - - // Out of stock - check backorder time - const sku = product?.warehousing?.sku; - const supplier = await getSupplierLeadTime(sku); - return supplier.leadTimeDays * 24 * 60 * 60 * 1000; - }, - - async commissioningTime() { - // Standard picking and packing time: 4 hours - return 4 * 60 * 60 * 1000; - }, - }; - }, -}; - -WarehousingDirector.registerAdapter(RealStoreAdapter); +import { WarehousingAdapter, WarehousingDirector, type IWarehousingAdapter } from '@unchainedshop/core'; +import { WarehousingProviderType } from '@unchainedshop/core-warehousing'; + +function registerInventory(stockForSku: (sku: string, referenceDate: Date) => Promise) { + const adapter: IWarehousingAdapter = { + ...WarehousingAdapter, + key: 'my-shop.inventory', + label: 'Inventory Service', + version: '1.0.0', + typeSupported: (type) => type === WarehousingProviderType.PHYSICAL, + actions(config, context) { + return { + ...WarehousingAdapter.actions(config, context), + configurationError: () => null, + isActive: () => true, + stock: async (referenceDate) => { + const sku = context.product?.warehousing?.sku; + return sku ? stockForSku(sku, referenceDate) : 0; + }, + commissioningTime: async () => 4 * 60 * 60 * 1000, + }; + }, + }; + WarehousingDirector.registerAdapter(adapter); +} ``` -## Integration with Delivery - -Warehousing time affects delivery estimates: +Call `registerInventory` with your inventory-system lookup. The stock plugin's context contains the product and order context; it does not expose a `modules.warehousing.findWarehouse` API. Keep persistence and external inventory operations in your own module or service. -```typescript -// In delivery adapter -async estimatedDeliveryThroughput(warehousingTime) { - // warehousingTime = productionTime + commissioningTime - const shippingTime = 3 * 24 * 60 * 60 * 1000; // 3 days shipping - return warehousingTime + shippingTime; -} -``` +Production and commissioning times are measured in milliseconds. Delivery adapters receive the combined warehousing time through their asynchronous `estimatedDeliveryThroughput(warehousingTime)` action. ## Query Stock Status diff --git a/docs/docs/plugins/workers/push-notification.md b/docs/docs/plugins/workers/push-notification.md index 87b999068a..7b0c41bceb 100644 --- a/docs/docs/plugins/workers/push-notification.md +++ b/docs/docs/plugins/workers/push-notification.md @@ -12,7 +12,7 @@ Send W3C compliant web push notifications to subscribed users. ## Installation ```typescript -import '@unchainedshop/plugins/worker/push-notification'; +import '@unchainedshop/plugins/worker/push-notification.js'; ``` ### Peer Dependency diff --git a/docs/docs/plugins/workers/twilio.md b/docs/docs/plugins/workers/twilio.md index 28466e5f16..790cb3b282 100644 --- a/docs/docs/plugins/workers/twilio.md +++ b/docs/docs/plugins/workers/twilio.md @@ -12,7 +12,7 @@ Send SMS messages through the Twilio messaging service. ## Installation ```typescript -import '@unchainedshop/plugins/worker/twilio'; +import '@unchainedshop/plugins/worker/twilio.js'; ``` ## Usage diff --git a/docs/docs/plugins/workers/worker-budgetsms.md b/docs/docs/plugins/workers/worker-budgetsms.md index 7e2a788ec5..dcf2abbaa0 100644 --- a/docs/docs/plugins/workers/worker-budgetsms.md +++ b/docs/docs/plugins/workers/worker-budgetsms.md @@ -12,7 +12,7 @@ Send SMS messages through the BudgetSMS service with support for test mode. ## Installation ```typescript -import '@unchainedshop/plugins/worker/budgetsms'; +import '@unchainedshop/plugins/worker/budgetsms.js'; ``` ## Environment Variables diff --git a/docs/docs/plugins/workers/worker-bulk-import.md b/docs/docs/plugins/workers/worker-bulk-import.md index 2ba3341aec..a1978211fa 100644 --- a/docs/docs/plugins/workers/worker-bulk-import.md +++ b/docs/docs/plugins/workers/worker-bulk-import.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/bulk-import'; +import '@unchainedshop/plugins/worker/bulk-import.js'; ``` ## Features diff --git a/docs/docs/plugins/workers/worker-bulkgate.md b/docs/docs/plugins/workers/worker-bulkgate.md index 1c46787b85..f463a49859 100644 --- a/docs/docs/plugins/workers/worker-bulkgate.md +++ b/docs/docs/plugins/workers/worker-bulkgate.md @@ -12,7 +12,7 @@ Send transactional and promotional SMS messages through the BulkGate service. ## Installation ```typescript -import '@unchainedshop/plugins/worker/bulkgate'; +import '@unchainedshop/plugins/worker/bulkgate.js'; ``` ## Environment Variables diff --git a/docs/docs/plugins/workers/worker-email.md b/docs/docs/plugins/workers/worker-email.md index 502cb07d81..036cfd9011 100644 --- a/docs/docs/plugins/workers/worker-email.md +++ b/docs/docs/plugins/workers/worker-email.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/email'; +import '@unchainedshop/plugins/worker/email.js'; ``` ## Environment Variables diff --git a/docs/docs/plugins/workers/worker-enrollment-order-generator.md b/docs/docs/plugins/workers/worker-enrollment-order-generator.md index 618c30d9f3..93998fa9b9 100644 --- a/docs/docs/plugins/workers/worker-enrollment-order-generator.md +++ b/docs/docs/plugins/workers/worker-enrollment-order-generator.md @@ -12,7 +12,7 @@ Automatically generates orders from active and paused enrollments based on their ## Installation ```typescript -import '@unchainedshop/plugins/worker/enrollment-order-generator'; +import '@unchainedshop/plugins/worker/enrollment-order-generator.js'; ``` ## Purpose @@ -30,7 +30,7 @@ This worker processes enrollments (subscriptions) and: To enable automatic order generation, configure the scheduling in your platform setup: ```typescript -import { configureGenerateOrderAutoscheduling } from '@unchainedshop/plugins/worker/enrollment-order-generator'; +import { configureGenerateOrderAutoscheduling } from '@unchainedshop/plugins/worker/enrollment-order-generator.js'; import { enrollmentsSettings } from '@unchainedshop/core-enrollments'; // Configure the schedule (e.g., daily at midnight) diff --git a/docs/docs/plugins/workers/worker-error-notifications.md b/docs/docs/plugins/workers/worker-error-notifications.md index 91c9350c74..623b4568e8 100644 --- a/docs/docs/plugins/workers/worker-error-notifications.md +++ b/docs/docs/plugins/workers/worker-error-notifications.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/error-notifications'; +import '@unchainedshop/plugins/worker/error-notifications.js'; ``` ## Purpose diff --git a/docs/docs/plugins/workers/worker-export-token.md b/docs/docs/plugins/workers/worker-export-token.md index 67251bfa79..500623c35c 100644 --- a/docs/docs/plugins/workers/worker-export-token.md +++ b/docs/docs/plugins/workers/worker-export-token.md @@ -12,7 +12,7 @@ An external worker placeholder for managing NFT/token minting and export process ## Installation ```typescript -import '@unchainedshop/plugins/worker/export-token'; +import '@unchainedshop/plugins/worker/export-token.js'; ``` ## Purpose @@ -29,7 +29,7 @@ The Export Token Worker: To enable automatic ownership updates, configure the worker in your platform setup: ```typescript -import { configureExportToken } from '@unchainedshop/plugins/worker/export-token'; +import { configureExportToken } from '@unchainedshop/plugins/worker/export-token.js'; // Pass the unchained API to enable event listeners configureExportToken(unchainedAPI); diff --git a/docs/docs/plugins/workers/worker-external.md b/docs/docs/plugins/workers/worker-external.md index c4468e4afc..a3a5cd48cd 100644 --- a/docs/docs/plugins/workers/worker-external.md +++ b/docs/docs/plugins/workers/worker-external.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/external'; +import '@unchainedshop/plugins/worker/external.js'; ``` ## Purpose diff --git a/docs/docs/plugins/workers/worker-heartbeat.md b/docs/docs/plugins/workers/worker-heartbeat.md index bc996601e3..7badf17aa5 100644 --- a/docs/docs/plugins/workers/worker-heartbeat.md +++ b/docs/docs/plugins/workers/worker-heartbeat.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/heartbeat'; +import '@unchainedshop/plugins/worker/heartbeat.js'; ``` ## Purpose diff --git a/docs/docs/plugins/workers/worker-http-request.md b/docs/docs/plugins/workers/worker-http-request.md index a4a7f171c1..ac62bfc683 100644 --- a/docs/docs/plugins/workers/worker-http-request.md +++ b/docs/docs/plugins/workers/worker-http-request.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/http-request'; +import '@unchainedshop/plugins/worker/http-request.js'; ``` ## Features diff --git a/docs/docs/plugins/workers/worker-message.md b/docs/docs/plugins/workers/worker-message.md index 3aaa86793b..405dd83884 100644 --- a/docs/docs/plugins/workers/worker-message.md +++ b/docs/docs/plugins/workers/worker-message.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/message'; +import '@unchainedshop/plugins/worker/message.js'; ``` ## Purpose diff --git a/docs/docs/plugins/workers/worker-token-ownership.md b/docs/docs/plugins/workers/worker-token-ownership.md index 6dbe84abfa..fddd5350e4 100644 --- a/docs/docs/plugins/workers/worker-token-ownership.md +++ b/docs/docs/plugins/workers/worker-token-ownership.md @@ -12,7 +12,7 @@ Two workers for managing NFT/token ownership: one for refreshing tokens and one ## Installation ```typescript -import '@unchainedshop/plugins/worker/update-token-ownership'; +import '@unchainedshop/plugins/worker/update-token-ownership.js'; ``` ## Workers Included diff --git a/docs/docs/plugins/workers/worker-update-coinbase-rates.md b/docs/docs/plugins/workers/worker-update-coinbase-rates.md index 961cd3c53b..47d602620a 100644 --- a/docs/docs/plugins/workers/worker-update-coinbase-rates.md +++ b/docs/docs/plugins/workers/worker-update-coinbase-rates.md @@ -12,7 +12,7 @@ Automatically fetches and updates currency exchange rates from Coinbase, support ## Installation ```typescript -import '@unchainedshop/plugins/worker/update-coinbase-rates'; +import '@unchainedshop/plugins/worker/update-coinbase-rates.js'; ``` ## Purpose diff --git a/docs/docs/plugins/workers/worker-update-ecb-rates.md b/docs/docs/plugins/workers/worker-update-ecb-rates.md index 35252eaa35..5829eea46e 100644 --- a/docs/docs/plugins/workers/worker-update-ecb-rates.md +++ b/docs/docs/plugins/workers/worker-update-ecb-rates.md @@ -12,7 +12,7 @@ Automatically fetches and updates EUR-based currency exchange rates from the Eur ## Installation ```typescript -import '@unchainedshop/plugins/worker/update-ecb-rates'; +import '@unchainedshop/plugins/worker/update-ecb-rates.js'; ``` ### Peer Dependency diff --git a/docs/docs/plugins/workers/worker-zombie-killer.md b/docs/docs/plugins/workers/worker-zombie-killer.md index b14a4fcd47..95b78fe315 100644 --- a/docs/docs/plugins/workers/worker-zombie-killer.md +++ b/docs/docs/plugins/workers/worker-zombie-killer.md @@ -16,7 +16,7 @@ This plugin is part of the `base` preset and loaded automatically. Using the bas ## Installation ```typescript -import '@unchainedshop/plugins/worker/zombie-killer'; +import '@unchainedshop/plugins/worker/zombie-killer.js'; ``` ## Purpose diff --git a/docs/docs/quick-start/setup-environment.md b/docs/docs/quick-start/setup-environment.md index e74ba37ece..4a8723dc8f 100644 --- a/docs/docs/quick-start/setup-environment.md +++ b/docs/docs/quick-start/setup-environment.md @@ -12,8 +12,8 @@ This guide will help you prepare your development environment for working with U ### Required Software -#### Node.js (v22 or newer) -Unchained Engine requires Node.js 22+ for optimal performance and compatibility. +#### Node.js (v26.8.2 or newer) +Unchained Engine requires Node.js 26.8.2 or newer for optimal performance and compatibility. **Check your version:** ```bash @@ -23,8 +23,8 @@ node --version **Install or update Node.js:** - Using [nvm](https://github.com/nvm-sh/nvm) (recommended): ```bash - nvm install 22 - nvm use 22 + nvm install 26.8.2 + nvm use 26.8.2 ``` - Direct download from [nodejs.org](https://nodejs.org/) diff --git a/docs/docs/troubleshooting/faq.md b/docs/docs/troubleshooting/faq.md index 00e562564a..e690af46b2 100644 --- a/docs/docs/troubleshooting/faq.md +++ b/docs/docs/troubleshooting/faq.md @@ -34,7 +34,7 @@ Any framework that can make HTTP requests: Yes. Unchained is designed to scale horizontally and has been used in production by businesses processing significant order volumes. Key features for scale: - Stateless architecture -- Distributed event system (Redis) +- Distributed event delivery using a configured emitter - Background job processing - External file storage (S3) @@ -42,7 +42,7 @@ Yes. Unchained is designed to scale horizontally and has been used in production ### What are the system requirements? -- Node.js 22+ +- Node.js 26.8.2 or newer - MongoDB 6+ - 1GB+ RAM (2GB+ recommended for production) @@ -75,7 +75,7 @@ Use type extensions and custom resolvers: ```typescript const customTypeDefs = ` - extend type Product { + extend interface Product { customField: String } `; @@ -87,10 +87,8 @@ const customResolvers = { }; await startPlatform({ - modules: { - customTypeDefs, - customResolvers, - }, + typeDefs: [customTypeDefs], + resolvers: [customResolvers], }); ``` @@ -101,15 +99,17 @@ See [Extending GraphQL](../extend/graphql) for details. Create a payment adapter and register it: ```typescript -import { PaymentDirector } from '@unchainedshop/core'; +import { PaymentAdapter, PaymentDirector } from '@unchainedshop/core'; const MyPaymentAdapter = { + ...PaymentAdapter, key: 'my-payment', label: 'My Payment', version: '1.0.0', - typeSupported: (type) => type === 'CARD', - actions: (params) => ({ - // ... implement methods + typeSupported: (type) => type === 'GENERIC', + actions: (configuration, context) => ({ + ...PaymentAdapter.actions(configuration, context), + // Override the payment operations for your gateway. }), }; @@ -133,7 +133,10 @@ app.post('/webhooks/stripe', async (req, res) => { // Use with Unchained import { startPlatform } from '@unchainedshop/platform'; -await startPlatform({ expressApp: app }); +import { connect } from '@unchainedshop/api/express'; +const platform = await startPlatform({}); +connect(app, platform); +app.listen(4010); ``` ### How do I run background jobs? @@ -274,24 +277,9 @@ See [Pricing System](../concepts/pricing-system). ### How do I implement custom pricing logic? -Create a pricing adapter: - -```typescript -class MyPricingAdapter extends ProductPricingAdapter { - static key = 'my-pricing'; - static orderIndex = 10; - - async calculate() { - this.result.addItem({ - amount: 100, - category: 'DISCOUNT', - }); - return super.calculate(); - } -} +Compose the `ProductPricingAdapter` object from `@unchainedshop/core`, override `actions(params)`, and write rows to `baseActions.resultSheet()`. Return `baseActions.calculate()` so the director can append your rows. Use `addDiscount()` with a discount ID for discount rows. -ProductPricingDirector.registerAdapter(MyPricingAdapter); -``` +See the [complete pricing example](../concepts/director-adapter-pattern.md#pricing-directors). ### How do I handle multiple currencies? @@ -346,10 +334,10 @@ See [Multi-Language Setup](../guides/multi-language-setup). ### What's the recommended production setup? -- Node.js 22+ on container platform +- Node.js matching the repository `.nvmrc` on a container platform - MongoDB Atlas for database - S3/MinIO for file storage -- Redis for distributed events +- A configured distributed event emitter when running multiple instances - CDN for static assets See platform-specific documentation for deployment guides. @@ -362,21 +350,20 @@ Migrations run automatically on startup when the Unchained platform boots. The m ### How is authentication handled? -- JWT tokens for API authentication -- Session cookies optional +- Signed session cookies with MongoDB-backed sessions +- Generated bearer access tokens for API integrations - WebAuthn for passwordless auth - OIDC for external identity providers ### How do I implement role-based access? -Use the built-in roles system: +Use the built-in roles system after platform initialization (the `Role` constructor registers the role): ```typescript import { Roles, Role } from '@unchainedshop/roles'; const customRole = new Role('support'); -customRole.allow('viewOrders', () => true); -Roles.registerRole(customRole); +customRole.allow('viewOrders', async () => true); // Assign to user await modules.users.updateRoles(userId, ['support']); diff --git a/docs/docs/troubleshooting/index.md b/docs/docs/troubleshooting/index.md index 8a72919053..eaf1c7a55b 100644 --- a/docs/docs/troubleshooting/index.md +++ b/docs/docs/troubleshooting/index.md @@ -272,10 +272,10 @@ mutation UpdateProductPricing { 1. Ensure a file storage plugin is imported in your entry file: ```typescript // GridFS (MongoDB built-in) -import '@unchainedshop/plugins/files/gridfs'; +import '@unchainedshop/plugins/files/gridfs/index.js'; // Or MinIO/S3 -import '@unchainedshop/plugins/files/minio'; +import '@unchainedshop/plugins/files/minio/index.js'; ``` 2. For MinIO/S3, verify credentials: @@ -393,7 +393,7 @@ When reporting issues, include: ````markdown ## Environment -- Node.js: 22.0.0 +- Node.js: 26.8.2 - Unchained: 3.0.0 - OS: macOS 14.0 diff --git a/docs/static/skills/upgrade-unchained/SKILL.md b/docs/static/skills/upgrade-unchained/SKILL.md index 9f48bbb156..5ae6e95a5a 100644 --- a/docs/static/skills/upgrade-unchained/SKILL.md +++ b/docs/static/skills/upgrade-unchained/SKILL.md @@ -13,7 +13,7 @@ description: Guides upgrading Unchained Engine to a new major version. Use when 4. Once a target version is selected (e.g., `4.5.0`), fetch these resources: - Migration guide: `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/MIGRATION.md` - Changelog: `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/CHANGELOG.md` - - README: `https://raw.githubusercontent.com/unchainedshop/unchained/refs/heads/master/README.md` + - README: `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/README.md` 5. Execute the upgrade by: - Updating npm dependencies - Removing deprecated dependencies @@ -27,10 +27,10 @@ Fetch example boot files for the target version to understand current patterns: | Framework | Example URL | |-----------|-------------| | Express | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/kitchensink-express/src/boot.ts` | -| Fastify | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/kitchensink/src/boot.ts` | -| Minimal | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/minimal/src/boot.ts` | -| Ticketing | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/ticketing/src/boot.ts` | -| OIDC | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/oidc/src/boot.ts` | +| Fastify | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/kitchensink/boot.ts` | +| Minimal | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/minimal/boot.ts` | +| Ticketing | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/ticketing/boot.ts` | +| OIDC | `https://raw.githubusercontent.com/unchainedshop/unchained/refs/tags/v{version}/examples/oidc/boot.ts` | ## Additional Resources diff --git a/examples/kitchensink-express/README.md b/examples/kitchensink-express/README.md index 3cfdd44029..1ac1c97384 100644 --- a/examples/kitchensink-express/README.md +++ b/examples/kitchensink-express/README.md @@ -6,27 +6,33 @@ Full-featured example of the Unchained Engine using Express as the HTTP server. - **Express** HTTP server - **GraphQL API** with GraphQL Yoga -- **All official plugins** via `@unchainedshop/plugins/presets/all` +- **Broad plugin preset** via `@unchainedshop/plugins/presets/all.js` - **AI Chat integration** (OpenAI compatible, including local LLMs) -- **Image generation** with OpenAI DALL-E +- **Image generation** with OpenAI `gpt-image-1` - **Discount plugins** (half-price manual, 100-off) - **Database seeding** with admin user, country, currency, language, and providers -- **Development access token** for testing (`admin` / `secret`) +- **Development access token** generated and logged on startup for the seeded administrator > **Note:** Admin UI is disabled by default in this example (`adminUI: false`). Use the Fastify kitchensink for the full admin experience. ## Prerequisites -- Node.js >= 22 +- Node.js 26.8.2 or newer (26.8.2 is pinned) for repository development (see [`.nvmrc`](../../.nvmrc)) - MongoDB (or uses in-memory MongoDB for development) ## Quick Start +From the repository root: + ```bash npm install +npm run build +cd examples/kitchensink-express npm run dev ``` +The remaining commands run from `examples/kitchensink-express/`. + Server starts at http://localhost:4010 with: - GraphQL endpoint: `/graphql` - Default login: `admin@unchained.local` / `password` @@ -44,13 +50,13 @@ Server starts at http://localhost:4010 with: ## Environment Variables -### Required +### Server configuration | Variable | Description | Default | |----------|-------------|---------| | `ROOT_URL` | Public URL of the server | `http://localhost:4010` | | `PORT` | Server port | `4010` | -| `UNCHAINED_TOKEN_SECRET` | Secret for session tokens (min 32 chars) | - | +| `UNCHAINED_TOKEN_SECRET` | Session secret (at least 32 characters); replace the bundled development value for deployment | Development value in `.env.defaults` | | `EMAIL_FROM` | Default sender email | `noreply@unchained.local` | | `EMAIL_WEBSITE_NAME` | Website name for emails | `Unchained` | | `EMAIL_WEBSITE_URL` | Website URL for emails | `http://localhost:4010` | @@ -72,17 +78,15 @@ Server starts at http://localhost:4010 with: | `OPENAI_MODEL` | Model name for chat | | `OPENAI_API_KEY` | OpenAI API key (for image generation) | -To use a local LLM: -```bash -llama-server -hf ggml-org/gpt-oss-20b-GGUF --ctx-size 0 --jinja -ub 2048 -b 2048 -``` +Chat is enabled only when both `OPENAI_BASE_URL` and `OPENAI_MODEL` are set. Point them at an OpenAI-compatible server that supports tool calling: -Then set: -``` +```bash OPENAI_BASE_URL=http://127.0.0.1:8080/v1 -OPENAI_MODEL=gpt-oss +OPENAI_MODEL=your-served-model ``` +`OPENAI_API_KEY` enables the image generation tool within an enabled chat configuration. Model and tool-schema compatibility depend on the server you run. + ## Database Seeding On first start, the seed script creates: @@ -105,11 +109,17 @@ This launches the MCP inspector to debug and test MCP tools. ## Docker +Build from the repository root so Docker can resolve every npm workspace from the root lockfile: + ```bash -docker build -t unchained-kitchensink-express . -docker run -p 4010:4010 unchained-kitchensink-express +docker build -f examples/kitchensink-express/Dockerfile -t unchained-kitchensink-express . +docker run --rm -p 4010:3000 \ + -e MONGO_URL=mongodb://host.docker.internal:27017/unchained \ + unchained-kitchensink-express ``` +`host.docker.internal` is available in Docker Desktop; for other deployments use your MongoDB service address. The image listens on container port `3000`. Configure production secrets and provider credentials as described in the [Docker deployment guide](../../docs/docs/deployment/docker.md). + ## License EUPL-1.2 diff --git a/examples/kitchensink-express/src/boot.ts b/examples/kitchensink-express/src/boot.ts index 50ac62f671..6cdd06c28e 100644 --- a/examples/kitchensink-express/src/boot.ts +++ b/examples/kitchensink-express/src/boot.ts @@ -14,7 +14,7 @@ import '@unchainedshop/plugins/pricing/discount-100-off.js'; const logger = createLogger('express'); const app = express(); -// llama-server -hf ggml-org/gpt-oss-20b-GGUF --ctx-size 0 --jinja -ub 2048 -b 2048 +// Enable chat when both an OpenAI-compatible endpoint and model are configured. const provider = process.env.OPENAI_BASE_URL && process.env.OPENAI_MODEL && createOpenAICompatible({ name: 'local', baseURL: process.env.OPENAI_BASE_URL, diff --git a/examples/kitchensink/.env.defaults b/examples/kitchensink/.env.defaults index 916198638c..7378deba93 100644 --- a/examples/kitchensink/.env.defaults +++ b/examples/kitchensink/.env.defaults @@ -15,11 +15,6 @@ UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL= UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES=./fragments.json UNCHAINED_ADMIN_UI_DEFAULT_USER_TAGS= -# Local LLM: -# Note: Using llama.cpp with a local server is currently not possible because the -# Unchained MCP Zod schema has date patterns that llama.cpp cannot handle. -# See: https://github.com/ggml-org/llama.cpp/issues/12252 -# llama-server -hf ggml-org/gpt-oss-20b-GGUF --ctx-size 0 --jinja -ub 2048 -b 2048 - -# OPENAI_BASE_URL=http://127.0.0.1:8080/v1 -# OPENAI_MODEL=gpt-oss \ No newline at end of file +# Optional OpenAI chat and image generation (see src/boot.ts): +# OPENAI_API_KEY=your-api-key +# OPENAI_MODEL=gpt-5.2 diff --git a/examples/kitchensink/README.md b/examples/kitchensink/README.md index 66dfe1e2e9..49c11ccaf8 100644 --- a/examples/kitchensink/README.md +++ b/examples/kitchensink/README.md @@ -7,27 +7,33 @@ Full-featured example of the Unchained Engine using Fastify as the HTTP server. - **Fastify** HTTP server with custom Unchained logger - **GraphQL API** with GraphQL Yoga - **Admin UI** integration (served at `/`) -- **All official plugins** via `@unchainedshop/plugins/presets/all` -- **Ticketing support** with `@unchainedshop/ticketing` -- **AI Chat integration** (OpenAI compatible, including local LLMs) -- **Image generation** with OpenAI DALL-E +- **Broad plugin preset** via `@unchainedshop/plugins/presets/all.js` +- **Ticketing example** available separately in [`../ticketing`](../ticketing/README.md) +- **AI Chat integration** with OpenAI (enabled when `OPENAI_API_KEY` is set) +- **Image generation** with OpenAI `gpt-image-1` - **Discount plugins** (half-price manual, 100-off) - **Product discoverability filter** (hide products by tag) - **Database seeding** with admin user, country, currency, language, and providers -- **Development access token** for testing (`admin` / `secret`) +- **Development access token** generated and logged on startup for the seeded administrator ## Prerequisites -- Node.js >= 22 +- Node.js 26.8.2 or newer (26.8.2 is pinned) for repository development (see [`.nvmrc`](../../.nvmrc)) - MongoDB (or uses in-memory MongoDB for development) ## Quick Start +From the repository root: + ```bash npm install +npm run build +cd examples/kitchensink npm run dev ``` +The remaining commands run from `examples/kitchensink/`. + Server starts at http://localhost:4010 with: - GraphQL endpoint: `/graphql` - Admin UI: `/` @@ -45,13 +51,13 @@ Server starts at http://localhost:4010 with: ## Environment Variables -### Required +### Server configuration | Variable | Description | Default | |----------|-------------|---------| | `ROOT_URL` | Public URL of the server | `http://localhost:4010` | | `PORT` | Server port | `4010` | -| `UNCHAINED_TOKEN_SECRET` | Secret for session tokens (min 32 chars) | - | +| `UNCHAINED_TOKEN_SECRET` | Session secret (at least 32 characters); replace the bundled development value for deployment | Development value in `.env.defaults` | | `EMAIL_FROM` | Default sender email | `noreply@unchained.local` | | `EMAIL_WEBSITE_NAME` | Website name for emails | `Unchained` | | `EMAIL_WEBSITE_URL` | Website URL for emails | `http://localhost:4010` | @@ -69,22 +75,10 @@ Server starts at http://localhost:4010 with: | Variable | Description | |----------|-------------| -| `OPENAI_BASE_URL` | OpenAI-compatible API base URL | -| `OPENAI_MODEL` | Model name for chat | -| `OPENAI_API_KEY` | OpenAI API key (for image generation) | - -To use a local LLM: -```bash -llama-server -hf ggml-org/gpt-oss-20b-GGUF --ctx-size 0 --jinja -ub 2048 -b 2048 -``` +| `OPENAI_API_KEY` | Enables chat and the `gpt-image-1` image generation tool | +| `OPENAI_MODEL` | Chat model passed to the provider; defaults to `gpt-5.2` in `src/boot.ts` | -> **Note:** Using llama.cpp with a local server is currently not possible because the Unchained MCP Zod schema has date patterns that llama.cpp cannot handle. See: https://github.com/ggml-org/llama.cpp/issues/12252 - -Then set: -``` -OPENAI_BASE_URL=http://127.0.0.1:8080/v1 -OPENAI_MODEL=gpt-oss -``` +Set these values in `.env`. For an OpenAI-compatible local endpoint, use the [Express example](../kitchensink-express/README.md), which configures `createOpenAICompatible` from `OPENAI_BASE_URL` and `OPENAI_MODEL`. ### Admin UI @@ -100,19 +94,25 @@ OPENAI_MODEL=gpt-oss On first start, the seed script creates: - Admin user: `admin@unchained.local` -- Country: Switzerland (CH) -- Currency: Swiss Franc (CHF) +- Countries: Switzerland (CH) and United States (US), plus `UNCHAINED_COUNTRY` if different +- Currencies: Swiss Franc (CHF) and US Dollar (USD), plus `UNCHAINED_CURRENCY` if different - Language: German (de) - Delivery provider: Send Message - Payment provider: Invoice ## Docker +Build from the repository root so Docker can resolve every npm workspace from the root lockfile: + ```bash -docker build -t unchained-kitchensink . -docker run -p 4010:4010 unchained-kitchensink +docker build -f examples/kitchensink/Dockerfile -t unchained-kitchensink . +docker run --rm -p 4010:3000 \ + -e MONGO_URL=mongodb://host.docker.internal:27017/unchained \ + unchained-kitchensink ``` +`host.docker.internal` is available in Docker Desktop; for other deployments use your MongoDB service address. The image listens on container port `3000`. Configure production secrets and provider credentials as described in the [Docker deployment guide](../../docs/docs/deployment/docker.md). + ## License EUPL-1.2 diff --git a/examples/minimal/README.md b/examples/minimal/README.md index d8c160f182..8a9fdead37 100644 --- a/examples/minimal/README.md +++ b/examples/minimal/README.md @@ -1,15 +1,18 @@ # Minimal Example -Minimal Headless E-Commerce Backend in < 20 lines of ESM code. +Minimal headless e-commerce backend using Fastify, Unchained Engine, and the base plugin preset. -Batteries included: -- Fastify -- Unchained Engine E-Commerce Framework -- Unchained Admin UI -- GraphiQL +Use Node.js 26.8.2 or newer (26.8.2 is pinned) for repository development (see [`.nvmrc`](../../.nvmrc)). -``` +```bash npx degit unchainedshop/unchained/examples/minimal my-minimal-app +cd my-minimal-app npm install -node --env-file .env.defaults boot.js -``` \ No newline at end of file +# Install the optional static Admin UI and its Fastify dependency +npm install @unchainedshop/admin-ui @fastify/static +npm start +``` + +The supplied `.env.defaults` starts the server on `http://localhost:4010`, with GraphQL and GraphiQL at `/graphql`. `adminUI: true` serves the installed Admin UI at `/`; without the optional UI package, the adapter serves a fallback landing page. + +This minimal example does not seed an administrator or shop data. For a seeded development shop, use the [kitchensink example](../kitchensink/README.md). Set `MONGO_URL` in `.env` to use an external MongoDB instance; local development can use the installed MongoDB Memory Server dependency. diff --git a/examples/oidc/README.md b/examples/oidc/README.md index abbdf616a2..90a944af8b 100644 --- a/examples/oidc/README.md +++ b/examples/oidc/README.md @@ -4,24 +4,28 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine ## Prerequisites -- Node.js >=22 +- Node.js 26.8.2 or newer (26.8.2 is pinned) for repository development (see [`.nvmrc`](../../.nvmrc)) - An OIDC provider (Zitadel Cloud or Keycloak instance) ## Getting Started -1. Install dependencies: +1. Install and build from the repository root, then enter the example: ```bash npm install + npm run build + cd examples/oidc ``` -2. Configure your OIDC provider (see sections below) +2. Configure one OIDC provider in `examples/oidc/.env` (see sections below). Zitadel takes precedence when both client IDs are set. 3. Run the development server: ```bash npm run dev ``` +The default server URL is `http://localhost:4010`; `/login` starts the configured provider flow and `/graphql` exposes the API. + ## Zitadel Setup [Zitadel](https://zitadel.com) is a modern identity and access management platform that provides secure authentication and authorization. @@ -29,7 +33,7 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine ### Step-by-step Configuration 1. **Create a Zitadel Cloud Account** - - Visit [zitadel.cloud](https://zitadel.cloud) and sign up for a free account + - Visit [zitadel.cloud](https://zitadel.cloud) and create an account - Create a new project or use the default project 2. **Create an Application** @@ -39,7 +43,7 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine - Select "PKCE" (Proof Key for Code Exchange) for enhanced security 3. **Configure Application Settings** - - Set your redirect URIs (e.g., `http://localhost:4000/auth/callback`) + - Set your redirect URIs (e.g., `http://localhost:4010/login/zitadel/callback`) - Note down your Client ID 4. **Environment Configuration** @@ -48,9 +52,11 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine ```env UNCHAINED_ZITADEL_CLIENT_ID=your_client_id_here - UNCHAINED_ZITADEL_DISCOVERY_URL=https://your-instance.zitadel.cloud/.well-known/openid-configuration + UNCHAINED_ZITADEL_DISCOVERY_URL=https://your-instance.zitadel.cloud ``` +Despite its name, `UNCHAINED_ZITADEL_DISCOVERY_URL` is the issuer base URL passed to `@fastify/oauth2` discovery, without the `/.well-known/openid-configuration` suffix. + ### Resources - [Zitadel Documentation](https://zitadel.com/docs) @@ -66,7 +72,7 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine ```bash # Using Docker - docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev + docker run -p 127.0.0.1:8080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.7.3 start-dev ``` 2. **Access Admin Console** @@ -75,12 +81,15 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine 3. **Create a Realm** - Create a new realm (e.g., "myrealm") - - Or use the master realm for testing + - Keep the master realm for Keycloak administration 4. **Create a Client** - Navigate to "Clients" and create a new client - Set Client ID to "myclient" (or your preferred name) - - Configure appropriate redirect URIs + - Enable the standard authorization code flow + - Set the redirect URI to `http://localhost:4010/login/keycloak/callback` + - Set the post-logout redirect URI to `http://localhost:4010/` + - For a confidential client, enable client authentication and copy the client secret 5. **Environment Configuration** @@ -88,6 +97,7 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine ```env UNCHAINED_KEYCLOAK_CLIENT_ID=myclient + UNCHAINED_KEYCLOAK_CLIENT_SECRET=your_client_secret UNCHAINED_KEYCLOAK_REALM_URL=http://localhost:8080/realms/myrealm ``` @@ -99,19 +109,19 @@ This example demonstrates how to integrate [Unchained Commerce](https://unchaine ## Advanced: MCP Server Authorization -Our Keycloak example includes advanced support for **OAuth 2.1** authentication protecting the **Model Context Protocol (MCP) Server** of Unchained Engine. +The Keycloak example demonstrates bearer-token authentication for the **Model Context Protocol (MCP) server** of Unchained Engine. ### What is MCP? The [Model Context Protocol](https://modelcontextprotocol.io) is a standardized way for AI models to securely access external data sources and tools. -### OAuth 2.1 Protection +### Authorization integration -This example implements OAuth 2.1 authorization as specified in the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). +The Keycloak adapter exposes protected-resource metadata at `/.well-known/oauth-protected-resource`, verifies bearer tokens on `/mcp`, and maps client roles into the Unchained context. It illustrates an integration with the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization); it does not exercise every requirement of that specification. ### Usage -Once configured, you can expose your MCP server to compatible MCP clients with proper OAuth 2.1 authentication, ensuring secure access to your Unchained Commerce data and operations. +Configure the client roles in Keycloak for the Unchained operations you need. `npm run test-mcp-oauth` exercises dynamic client registration and a client-credentials token request; it requires Keycloak to allow registration and the registered service account to receive the required roles. This test is separate from the browser authorization-code login flow. ## Learn More diff --git a/examples/oidc/keycloak.ts b/examples/oidc/keycloak.ts index a3601355f7..4274a492b7 100644 --- a/examples/oidc/keycloak.ts +++ b/examples/oidc/keycloak.ts @@ -173,7 +173,7 @@ export default async function setupKeycloak(app: FastifyInstance) { }); app.addHook('onRequest', async (req, reply) => { - // Some code + // Verify bearer tokens for requests to the MCP endpoint. if (req.url === MCP_API_PATH) { try { const encodedToken = req.headers.authorization?.replace('Bearer ', ''); diff --git a/examples/oidc/test-mcp-oauth.ts b/examples/oidc/test-mcp-oauth.ts index 9c9a91c1c7..37999a49ea 100644 --- a/examples/oidc/test-mcp-oauth.ts +++ b/examples/oidc/test-mcp-oauth.ts @@ -2,10 +2,10 @@ /** * Test script for MCP OAuth authentication * - * This script tests the proper MCP OAuth flow using Dynamic Client Registration (RFC 7591) + * This script tests a Keycloak service-account flow using Dynamic Client Registration (RFC 7591) * followed by Client Credentials grant to obtain an access token. * - * MCP Authentication Flow: + * Service-account test flow: * 1. Discover OAuth endpoints from protected resource metadata * 2. Dynamically register a client with the authorization server * 3. Use the registered client credentials to obtain an access token diff --git a/examples/ticketing/README.md b/examples/ticketing/README.md index fdb790576e..390babb73e 100644 --- a/examples/ticketing/README.md +++ b/examples/ticketing/README.md @@ -16,16 +16,22 @@ Example demonstrating the Unchained Engine ticketing extension for event tickets ## Prerequisites -- Node.js >= 22 +- Node.js 26.8.2 or newer (26.8.2 is pinned) for repository development (see [`.nvmrc`](../../.nvmrc)) - MongoDB (or uses in-memory MongoDB for development) ## Quick Start +From the repository root: + ```bash npm install +npm run build +cd examples/ticketing npm run dev ``` +The remaining commands run from `examples/ticketing/`. + Server starts at http://localhost:4010 with: - GraphQL endpoint: `/graphql` - Default login: `admin@unchained.local` / `password` @@ -42,13 +48,13 @@ Server starts at http://localhost:4010 with: ## Environment Variables -### Required +### Server configuration | Variable | Description | Default | |----------|-------------|---------| | `ROOT_URL` | Public URL of the server | `http://localhost:4010` | | `PORT` | Server port | `4010` | -| `UNCHAINED_TOKEN_SECRET` | Secret for session tokens (min 32 chars) | - | +| `UNCHAINED_TOKEN_SECRET` | Session secret (at least 32 characters); replace the bundled development value for deployment | Development value in `.env.defaults` | | `UNCHAINED_SECRET` | Secret for magic key encryption | `secret` | | `EMAIL_FROM` | Default sender email | `noreply@unchained.local` | | `EMAIL_WEBSITE_NAME` | Website name for emails | `Unchained` | @@ -63,17 +69,16 @@ Server starts at http://localhost:4010 with: | `UNCHAINED_CURRENCY` | Default currency ISO code | `CHF` | | `UNCHAINED_LANG` | Default language ISO code | `de` | -### Apple Wallet (Optional) +### Apple Wallet push notifications (Optional) | Variable | Description | |----------|-------------| -| `PASS_CERTIFICATE_PATH` | Path to Apple pass certificate (PEM) | +| `PASS_CERTIFICATE_PATH` | Certificate and key option passed to `https.request` for Apple push notifications | | `PASS_CERTIFICATE_SECRET` | PEM passphrase | -| `PASS_TEAM_ID` | Apple Developer Team ID | ## Ticketing Setup -The example includes placeholder implementations for ticket rendering: +The example includes logging placeholders for ticket rendering. They must be replaced with a readable PDF stream and wallet pass objects before ticket downloads can work: ```typescript setupTicketing(platform.unchainedAPI, { @@ -85,7 +90,7 @@ setupTicketing(platform.unchainedAPI, { ### Implementing PDF Tickets -```typescript +```tsx import ReactPDF from '@react-pdf/renderer'; const renderOrderPDF = async ({ orderId }, { modules }) => { @@ -116,15 +121,21 @@ Run integration tests: npm run test:run:integration ``` -Tests are located in the `tests/` directory. +Tests are located in the `tests/` directory. The checked-in boot callbacks only log messages, so the successful-download cases require working renderers and suitable order/token fixtures. ## Docker +Build from the repository root so Docker can resolve every npm workspace from the root lockfile: + ```bash -docker build -t unchained-ticketing . -docker run -p 4010:4010 unchained-ticketing +docker build -f examples/ticketing/Dockerfile -t unchained-ticketing . +docker run --rm -p 4010:3000 \ + -e MONGO_URL=mongodb://host.docker.internal:27017/unchained \ + unchained-ticketing ``` +`host.docker.internal` is available in Docker Desktop; for other deployments use your MongoDB service address. The image listens on container port `3000`. Configure production secrets and provider credentials as described in the [Docker deployment guide](../../docs/docs/deployment/docker.md). + ## License EUPL-1.2 diff --git a/node26roadmap.md b/node26roadmap.md index 1fbee31703..5b393c15a0 100644 --- a/node26roadmap.md +++ b/node26roadmap.md @@ -1,115 +1,108 @@ -# Node.js 25/26 Adoption Roadmap - -How the Unchained Engine can benefit from Node.js 24/25/26 features, ranked by impact / effort / safety. - -## Context - -Node 26 becomes the next Active LTS (released 2026-05-05 as *Current*; Active LTS **2026-10-28**), carrying V8 14.6 and shipping the headline features in scope here: **Temporal** (unflagged) and the now-mature **Explicit Resource Management** (`using`/`await using`, stable since Node 24). - -**Starting point (verified in-repo):** ESM TypeScript monorepo, `engines.node >=22.0.0`, `.nvmrc=25`, runs Node v25.9.0, `@types/node ^25`, `typescript ^5.9.3`, `mongodb ^7`, build via `tsc --build` to `lib/`. The codebase is already modern — `Array.fromAsync`, `--env-file`, `node --test`, `crypto.randomUUID`, `structuredClone`, `AbortController`, a native `schedule.ts` cron engine (replaced `@breejs/later`), and **no date library at all**. The tsconfig is even **type-strip-ready** (`packages/shared/node-native.tsconfig.json`: `erasableSyntaxOnly`, `verbatimModuleSyntax`, `allowImportingTsExtensions`, `rewriteRelativeImportExtensions`). - -The remaining wins are concentrated, not broad — and split cleanly by what the `engines.node` floor allows. - -## The decision that gates everything: the engine floor - -| Feature cluster | Min Node | On `>=22` floor? | -|---|---|---| -| Pure-JS refactors, `Object.groupBy`/Set algebra, `Promise.withResolvers`, native-TS dev loop, time-ordered IDs | 21–22 | ✅ Safe today | -| **Explicit Resource Management** (`using`/`await using`, `DisposableStack`, Mongo v7 `asyncDispose`), `RegExp.escape`, `Error.isError` | **24** | ❌ `using` is a **hard SyntaxError** on Node 22 | -| **Temporal** (unflagged), `crypto.randomUUIDv7`, `Map.getOrInsert` | **26** | ❌ flagged on 24/25, absent on 22 | - -`tsc` at the repo's `target: "esnext"` would emit **native** `using` into `lib/` (current `lib/` emits plain `try/finally` only because there's no `using` in source yet) — which would break any Node-22 consumer. The team already runs Node 25 (`.nvmrc=25`), so the runtime is fine; the published-floor promise is the only blocker. - -**Decision: bump `engines.node` to `>=24`.** Cleanest path — emit native `using` with no polyfill and no build-target change. Drops Node 22 consumers (Node 22 is EOL April 2027; the team is already on 25). This unblocks the entire Wave 1 ERM tier. *(Rejected alternative: keep `>=22` and downlevel `using` via `target: es2022` + a `Symbol.[async]Dispose` polyfill at every entrypoint — it touches the shared base tsconfig every package extends, a monorepo-wide emit change with per-package polyfill load.)* - ---- - -## Performance: the single biggest lever (time-ordered document IDs) - -The one item with a structural, measurable **steady-state runtime** upside — and it is **not** gated on the Node version. - -`packages/mongodb/src/generate-db-object-id.ts` generates every collection's `_id` as a **fully random 24-hex string** (`crypto.getRandomValues` → hex). Random `_id`s scatter inserts across the `_id` B-tree → constant page splits, index fragmentation, and a hot working set that won't stay in RAM. On high-write collections (orders, order positions, events, work queue) this directly taxes insert throughput and inflates resident index size. (It's why Mongo's own `ObjectId` is timestamp-prefixed — the engine opted out with random hex.) - -**Change:** make `generateDbObjectId` produce **time-ordered** IDs. Node 26's `crypto.randomUUIDv7` is the standardized form, but a 48-bit `Date.now()` hex prefix + random suffix (hex ULID) gives the identical B-tree benefit **today on the `>=22` floor** and preserves the existing 24-hex format. New inserts append to the right edge → near-zero page splits, better locality, smaller hot index. - -Caveats: -1. **New inserts only** — existing `_id`s can't be rewritten, so the index stays mixed until old docs age out (still net-positive on growing collections). -2. **Security tradeoff** — time-ordered IDs leak creation time + rough insertion order. Keep ≥74 random suffix bits (UUIDv7/ULID-grade) to retain unguessability/non-enumerability. A deliberate call given the engine's audit history. -3. Downstream code must keep treating IDs as opaque strings (it does). - -Scope: one file + a focused insert-throughput benchmark. - -**Other performance notes:** the Node 26 upgrade *itself* gives free wins — ~12% faster `JSON.parse` on large payloads (GraphQL layer) and Undici 8 (faster outbound `fetch` to payment/delivery/tax providers) — but those are "upgrade Node," not code changes. ERM (Wave 1) is a **tail-latency/resilience** win (prevents leaked cursors/sessions → pool exhaustion, leaked locks → contention stalls), not throughput. Temporal is *slower* than `Date` — adopt for correctness only. `Object.groupBy`/Set algebra are micro-level. The native-TS loop is a build/CI-time win, not production runtime. - ---- - -## Wave 0 — Safe now, no engine bump (`>=22`) - -Ranked by impact/effort; none touches the floor. - -1. **Unify duplicated `isExpired`.** Near-verbatim copies in `packages/core-enrollments/src/module/configureEnrollmentsModule.ts:57` and `packages/core-quotations/src/module/configureQuotationsModule.ts:185` (both `new Date(referenceDate)` + strict `relevantDate.getTime() > expiryDate.getTime()`). Extract `isExpired(expires, referenceDate?)` into `packages/utils`, call from both. Keep strict `>`; storage stays BSON Date. *Removes a copy-paste correctness hazard across two billing-critical domains.* - -2. **Name the magic millisecond constants + fix the `setSeconds` smell.** Hardcoded `86400000` / `24*60*60*1000` in `packages/api/src/mcp/tools/order/handlers/getSalesSummary.ts:33`, `packages/api/src/mcp/utils/orderFilters.ts:50`, `packages/api/src/express/createTempUploadMiddleware.ts:1`, plus `getTime()+ms` in `packages/core/src/directors/WarehousingDirector.ts:138`. Fold in the in-place mutation at `packages/core/src/directors/FailedRescheduler.ts:38` (`new Date(now.setSeconds(now.getSeconds()+5))` mutates `now`; replace with `new Date(now.getTime()+5000)`). Shared named constants now → `Temporal.Duration` in Wave 2. Zero behavior change. - -3. **`Object.groupBy`/`Map.groupBy` + native Set algebra** (Node 21/22, stable on floor). Replace `reduce`-based grouping at `packages/core/src/directors/BasePricingSheet.ts:62/93`, `packages/core/src/directors/EnrollmentAdapter.ts:92`, `packages/core/src/services/calculateDiscountTotal.ts:66`; use `Set.intersection`/`difference` for assortment/filter/role-eligibility checks. **Leave the already-optimal `new Set(...)` dedupes in DataLoader batch fns alone.** Apply opportunistically. Use `Map.groupBy` for non-string keys. - -4. **`Promise.withResolvers` in the worker debounce.** `packages/core/src/directors/EventListenerWorker.ts:9` hand-rolls a deferred with a manual `AbortController` + `node:timers/promises` `setTimeout`. Replace the executor-closure with `Promise.withResolvers()`. Purely syntactic. - -5. **(Optional) Native TypeScript dev/test loop.** The repo is already strip-ready. Run `.ts` sources/tests directly via Node (type stripping is *stable* on Node 24.12+/25.2+) to drop the transpile step in the dev/test inner loop. **`tsc --build` must remain** for `.d.ts` + `.js` publish artifacts. Payoff only if iteration speed is a felt pain. *Note: `--experimental-transform-types` was removed in Node 26 — never introduce enums/namespaces/decorators; `erasableSyntaxOnly` already enforces this.* - -6. **Time-ordered IDs** — see the Performance section. Self-contained, safe on `>=22`, highest runtime payoff. - ---- - -## Wave 1 — Explicit Resource Management (after the `>=24` floor bump) - -The **highest real-world value** for a server engine: convert duplicated, leak-prone `try/finally` cleanup into scope-bound, deterministic disposal. Set `engines.node >=24.0.0` (`.nvmrc` is already 25 — align the CI matrix). The team is already signaling intent — `createDatabaseResource` in `packages/mongodb/src/initDb.ts:105` *already implements `AsyncDisposable` correctly*. - -1. **`await using` for order locks.** `acquireLock` (`packages/core-orders/src/module/configureOrdersModule.ts:177`, `@kontsedal/locco`) returns a lock handle. Wrap it so the handle implements `[Symbol.asyncDispose] = () => lock.release()` (idempotent). The three duplicated `try { … } finally { lock.release() }` blocks in `packages/core/src/services/checkoutOrder.ts:39`, `confirmOrder.ts:17`, `rejectOrder.ts:17` collapse to `await using lock = await this.orders.acquireLock(...)`. *Deadlock-prevention in the most concurrency-sensitive path.* - -2. **`AsyncDisposableStack` for discount-adapter reservations.** `adapter.release()` is called by hand in scattered catch/finally branches: `packages/core/src/services/updateCalculation.ts:51`, `removeCartDiscount.ts:26`, `createManualOrderDiscount.ts:52`. Expose `[Symbol.asyncDispose]` on the adapter (or wrap), then `stack.use(...)` + commit-on-success via `stack.move()`. Closes a reservation-leak risk on new error branches. - -3. **Native Mongo v7 session/cursor disposal.** Where code starts sessions or iterates cursors, use `await using session = client.startSession()` (auto-`endSession`) and `await using cursor = coll.find(..., { session })` (auto-close). Promote `createDatabaseResource` (`initDb.ts:105`) for scoped/test contexts. **Do not** touch the long-lived global `mongoClient` lifecycle (`initDb.ts:131`). Driver v7 still labels `asyncDispose` experimental; requires Node ≥20.19 — under a `>=24` floor. - -4. **Disposable timers/listeners + consolidated shutdown.** Manual `clearInterval`/`clearTimeout` in `packages/api/src/mongo-store.ts:251`, `packages/core/src/directors/IntervalWorker.ts:55`, `packages/events/src/audit/index.ts` (flush timer), `packages/platform/src/startPlatform.ts:127`. Add `[Symbol.dispose]`/`[Symbol.asyncDispose]` to `MongoStore`/`AuditLog` (extend existing `close()`), aggregate platform teardown with a `DisposableStack`, and have `EventListenerWorker` track unsubscribers. Robustness/clarity — most timers already `unref()`. *The `[Symbol.dispose]` methods can be authored on `>=22` today; only the `using` call sites need 24+.* - -5. **`RegExp.escape` / `Error.isError`** (Node 24): replace the hand-rolled search-term escape (ReDoS/injection footgun in filter/search query building) and `instanceof Error` checks across worker/plugin error paths. - -**SuppressedError caveat (applies to 1–4):** if a disposer throws, the *last* disposer error propagates and the original nests in `.suppressed`/`.error`. Audit centralized error/audit logging to unwrap it, or root causes get lost. Disposal is strictly LIFO. - ---- - -## Wave 2 — Target Node 26 Active LTS (2026-10-28): Temporal & friends - -Temporal is **flagged on 24/25, absent on 22**; only Node 26 has it unflagged (still surfaced as *experimental*). Adopting before the floor is Node 26 means a polyfill (`temporal-polyfill` ~20kB gz) **and** every value still round-trips through `new Date(instant.epochMilliseconds)` at the Mongo BSON-Date boundary (ns precision truncated). So Temporal is a **post-Node-26-LTS** direction, pre-staged by Wave 0 #2. - -When the floor reaches Node 26 LTS: -- **`schedule.ts` / `addToDate` / `periodForReferenceDate`** — the hand-rolled local-time date math (`packages/core/src/utils/schedule.ts`, `packages/core-enrollments/src/addToDate.ts`, `packages/core/src/directors/EnrollmentAdapter.ts:37`) becomes DST/timezone-correct via `Temporal.ZonedDateTime`/`PlainDateTime.add`/`Duration`. **Keep the custom cron *parser*** — Temporal has no cron parsing and the field-advancing loop is already optimized; swap only the underlying date arithmetic. -- **Session TTL / expiry math** (`packages/api/src/mongo-store.ts`, the unified `isExpired`) → `Temporal.Instant` comparisons. -- **Magic-duration constants** (from Wave 0 #2) → `Temporal.Duration.from({days:1})`. -- **`crypto.randomUUIDv7`** — the standardized form of the time-ordered IDs from the Performance section (that win is available today without waiting for Node 26). -- **`Map.getOrInsert`/`getOrInsertComputed`** — cleaner cache/memoization. -- **Migration chores at the bump:** `NODE_MODULE_VERSION` → 147 (rebuild native addons, e.g. `@mongodb-js/zstd`); `--experimental-transform-types` removed (already safe via `erasableSyntaxOnly`); legacy `_stream_*` modules removed; Rust + GCC 13.2+ needed for build-from-source. - -**Persistence rule throughout:** keep MongoDB on BSON `Date`; convert to Temporal only at the edges where wall-clock/timezone semantics matter. - ---- - -## Explicitly NOT worth it now - -- **Broad Temporal adoption while floor is `<26`** — polyfill cost + mandatory Date round-trip + still-experimental. -- **Rewriting the cron parser/`getNextOccurrences` with Temporal** — no native cron; existing loop optimized; underpins all job timing. High risk, low reward. -- **Shipping ERM while floor stays `>=22`** — native `using` SyntaxErrors on Node 22 consumers. Resolved by the decided `>=24` bump; do not adopt `using` before that bump lands. -- **`node --run` to replace `npm-run-all`** — can't do parallel fan-out (the `run-p dev:*`/`build:*` in `package.json:71/77` is load-bearing) and skips pre/post hooks (`pretest` ESLint). Keep `npm-run-all`. -- **`bcryptjs` → `node:crypto` scrypt** (`packages/core-users/src/module/configureUsersModule.ts:1`, pbkdf2 fallback at `:431`) — a security-critical hash-format migration (existing bcrypt hashes can't rehash without a login; scrypt is CPU-bound/event-loop risk). Not a Node-version play. -- **`node:sqlite`, `Float16Array`, `Atomics.pause`, native WebSocket server** — irrelevant to a MongoDB-backed transactional engine (no built-in WS *server* exists, so `ws`/`graphql-ws` stays; SQLite is niche test-fixture only). -- **GridFS write-stream `await using`** (`packages/plugins/src/files/gridfs/handler-express.ts:67`) — existing `pipeline()`+`finished()` already handles cleanup. Low priority. - ---- - -## Verification - -- **Wave 0:** unit-test the extracted `isExpired` (boundary: equal timestamps stay non-expired); confirm magic-constant refactors are byte-equal in behavior. For time-ordered IDs, benchmark insert throughput + index size on an insert-heavy collection before/after. `npm run test:run:unit` + `npm run lint`. -- **Native-TS spike (if pursued):** run a package's tests via `node --test` on `.ts` directly vs current path; compare results + wall-clock. -- **Wave 1:** integration tests around order checkout/confirm/reject (`tests/`) proving lock release on both success and thrown-error paths; add a test that forces a throw inside the `await using` scope and asserts release. Confirm no Node-22-only consumers remain before bumping `engines.node`; bump `.nvmrc`/CI matrix to 24+. -- **Wave 2:** gate on Node 26 Active LTS; rebuild native addons; run full `npm run test` on Node 26; verify Temporal date math against existing `schedule.test.ts` / `periodForReferenceDate.test.ts` golden values (esp. DST/month-end/leap-year). +# Node.js Adoption Roadmap + +This records the current runtime baseline and proposals for further work. +The baseline below was checked against the v4.8.x source in September 2026; +the refactors and experiments below remain pending. + +## Current baseline + +| Area | Repository state | +|---|---| +| Development runtime | All `.nvmrc` files and Node Docker stages pin Node.js 26.8.2 | +| Declared engine floor | All repository package manifests declare `node >=26.8.2`; npm remains `>=10.0.0` where specified | +| TypeScript | TypeScript 5.9 at the root, Node.js 26 type definitions | +| Build | `tsc --build` emits package artifacts to `lib/`; root references also include examples | +| Tests | Native `node --test`; `npm test` runs package unit tests, integration tests, then Docker healthcheck regression tests; integration tests use global setup and share a process | +| Development examples | Native TypeScript through `node --watch` | +| Database | MongoDB driver 7 | +| Scheduling | Native cron parser in `packages/core/src/utils/schedule.ts` | + +The native TypeScript test/development loop has already landed. Keep the +TypeScript build for published JavaScript and declarations, and for workspace +imports that resolve to `lib/`. There is no root `pretest` script. + +## Runtime alignment completed + +The supported minimum is now Node.js **26.8.2**. Package engines, `.nvmrc` files, +Node Docker stages, contributor guidance, and CI use that baseline. This supersedes +the earlier proposal to adopt Node.js 24. See the +[migration guide](MIGRATION.md#v48x-nodejs-baseline-2682) before upgrading deployments. + +The optional `@parse/node-apn@8.1.0` dependency still declares support for Node.js +20, 22, and 24 only. Its Apple Wallet update-notification integration therefore +remains outside the dependency's declared Node.js 26 support. Verify that +integration before relying on pass update notifications; its engine metadata has +not been overridden. + +The shared compiler target is `esnext`, so native syntax such as `using` can be +preserved in published output. Future syntax and API adoption still needs +validation on the exact minimum release, including dependency requirements and +test-runner flags. Keep the package build and runtime tests in CI. + +## Small refactors to evaluate + +1. **Shared expiration comparison.** Enrollment and quotation modules both compare + a reference date with `expires`. Their public signatures differ: enrollment + callers supply the options object, while quotations default it. Share the + comparison without changing those interfaces or the strict `>` boundary. + Sources: [enrollments](packages/core-enrollments/src/module/configureEnrollmentsModule.ts) + and [quotations](packages/core-quotations/src/module/configureQuotationsModule.ts). + +2. **Named duration constants.** Replace repeated millisecond literals where a + name would explain the intent. In + [FailedRescheduler](packages/core/src/directors/FailedRescheduler.ts), replacing + `new Date(now.setSeconds(now.getSeconds() + 5))` with arithmetic would also avoid + mutating `now`; preserve observable scheduling behavior. + +3. **Grouping helpers.** Evaluate native grouping or Set operations in pricing + calculations where they improve readability and are available on the supported + minimum runtime. Preserve key ordering, rounding, and duplicate semantics. + +The [event worker debounce](packages/core/src/directors/EventListenerWorker.ts) +already uses an abortable promise timer. It has no hand-written Promise executor +to replace with `Promise.withResolvers`; any refactor must preserve cancellation. + +## Resource cleanup to evaluate + +[createDatabaseResource](packages/mongodb/src/initDb.ts) already exposes an +`AsyncDisposable` wrapper. Evaluate scope-bound cleanup for: + +- Order locks in checkout, confirmation, and rejection services. +- Discount adapter reservations on success and error paths. +- Short-lived MongoDB sessions/cursors in tests and scoped operations. +- Timers and event subscriptions during platform shutdown. + +Check disposal support in the installed driver and runtime. Preserve lock-release +ordering and original errors when cleanup also fails. Keep the long-lived shared +MongoDB client under the existing platform lifecycle. + +## Date and time work + +Evaluate Temporal only when its runtime support matches the supported engine +floor. Start with DST, month-end, and recurrence behavior in +[schedule.ts](packages/core/src/utils/schedule.ts), +[addToDate.ts](packages/core-enrollments/src/addToDate.ts), and +[EnrollmentAdapter](packages/core/src/directors/EnrollmentAdapter.ts). +Keep the cron parser and persist BSON `Date` values at the database boundary. +A rewrite needs correctness tests and measurements; it is not a promised speedup. + +## Document ID experiment + +[generateDbObjectId](packages/mongodb/src/generate-db-object-id.ts) currently +creates random hexadecimal IDs (24 digits by default). Time-ordered IDs could +change insert locality, but any performance benefit needs a workload benchmark. + +A 24-hex ID has 96 bits total. A 48-bit timestamp prefix leaves only 48 random +bits; it cannot also retain a UUIDv7-sized random suffix in the same format. +Changing the format requires a collision, concurrency, and information-exposure +review. Existing IDs must remain valid. Do not present this as a drop-in change +or rely on IDs as authorization credentials. + +## Validation for implementation work + +- Run unit tests and affected integration tests on Node.js 26.8.2. +- Test expiry boundaries, disposal after errors, DST, month-end, and leap-year cases. +- Build published artifacts and exercise them separately from source TypeScript. +- Benchmark ID changes against the existing generator using the same database, + dataset, concurrency, index definitions, and hardware. +- Keep runtime adoption and security-sensitive password-format migrations separate. diff --git a/packages/api/README.md b/packages/api/README.md index f1cdd68384..98c959b785 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -70,8 +70,8 @@ await fastify.listen({ port: 4010 }); | Export | Description | |--------|-------------| -| `UnchainedContext` | GraphQL context type | -| `LocaleContext` | Locale-aware context type | +| `Context` | GraphQL context type | +| `UnchainedLocaleContext` | Locale-aware context type | ### Loaders @@ -89,22 +89,22 @@ Data loaders for efficient batched queries: |--------|-------------| | `acl` | Access control list utilities | | `roles` | Role definitions and actions | -| `actions` | Available permission actions | +| `roles.actions` | Available permission actions | ### Error Handling | Export | Description | |--------|-------------| -| `UnauthorizedError` | Authentication error | -| `PermissionDeniedError` | Authorization error | +| `NoPermissionError` | Authorization error | | `InvalidIdError` | Invalid ID format error | -| `NotFoundError` | Resource not found error | +| `ProductNotFoundError` | Product not found error | ### Events | Event | Description | |-------|-------------| -| `API_REQUEST` | Emitted on API requests | +| `API_LOGIN_TOKEN_CREATED` | Session token created | +| `API_LOGOUT` | User logged out | ## Configuration @@ -113,7 +113,7 @@ const server = await startAPIServer({ unchainedAPI: core, roles: customRoles, adminUiConfig: { - basePath: '/admin', + defaultProductTags: ['featured'], }, context: (defaultResolver) => async (props, req, res) => { const context = await defaultResolver(props, req, res); @@ -137,7 +137,6 @@ The API exposes a complete GraphQL schema with: - **Queries**: Products, orders, users, assortments, filters, etc. - **Mutations**: CRUD operations, checkout, authentication -- **Subscriptions**: Real-time updates (where supported) ## MCP Server @@ -149,7 +148,7 @@ MCP support is an optional peer dependency: npm install @modelcontextprotocol/server ``` -Without it, the engine boots normally and `/mcp` responds with `503`. The chat handlers (`connect(..., { chat })`) additionally require the optional peers `ai` and `@ai-sdk/mcp` — and `@modelcontextprotocol/server` too, since chat derives its tool set through the engine's own `/mcp` endpoint. +Without it, the engine boots normally and authenticated admin requests to `/mcp` receive `503`; authentication and role checks still run first. The chat handlers (`connect(..., { chat })`) additionally require the optional peers `ai` and `@ai-sdk/mcp` — and `@modelcontextprotocol/server` too, since chat derives its tool set through the engine's own `/mcp` endpoint. ## Security @@ -157,7 +156,7 @@ The API layer implements comprehensive security controls. ### Access Control -- **128+ permission actions** covering all API operations +- **Permission actions** covering all API operations - **Role-Based Access Control (RBAC)** with built-in and custom roles - **ACL enforcement** on all GraphQL mutations - **Ownership validation** ensuring users can only access their resources @@ -166,10 +165,10 @@ The API layer implements comprehensive security controls. | Variable | Purpose | Default | |----------|---------|---------| -| `UNCHAINED_TOKEN_SECRET` | Session encryption (min 32 chars) | Required | +| `UNCHAINED_TOKEN_SECRET` | Session signing secret (min 32 chars) | Required | | `UNCHAINED_COOKIE_NAME` | Cookie name | `unchained_token` | | `UNCHAINED_COOKIE_SAMESITE` | SameSite attribute | `none` | -| `UNCHAINED_COOKIE_INSECURE` | Disable secure flag | `false` | +| `UNCHAINED_COOKIE_INSECURE` | Any nonempty value disables the secure flag | Unset | Cookies are `httpOnly` and `secure` by default. @@ -180,180 +179,32 @@ Errors are designed to prevent information leakage: - No distinction between "invalid" vs "expired" tokens - Permission errors don't reveal action details -### CORS Configuration +### CORS and Trust Proxy -CORS behavior depends on `NODE_ENV`: +`connect()` does not configure general CORS handling or infer trust settings from `NODE_ENV`. Configure CORS with your HTTP framework or reverse proxy. GraphQL Yoga also has its own CORS options, passed to `startAPIServer`. -| Environment | Default Behavior | -|-------------|------------------| -| `development` | Permissive CORS (reflects any origin) + trust proxy headers | -| `production` | No CORS headers (reverse proxy should handle it) | - -#### Environment Variable - -| Variable | Purpose | Default | -|----------|---------|---------| -| `UNCHAINED_CORS_ORIGINS` | Allowed CORS origins | Auto (see above) | - -#### Programmatic Configuration - -```typescript -// Auto behavior (recommended) - permissive in dev, none in prod -connect(app, { graphqlHandler, db, unchainedAPI }); - -// Explicit whitelist (production) -connect(app, { graphqlHandler, db, unchainedAPI }, { - corsOrigins: "https://shop.example.com,https://admin.example.com", -}); - -// Force permissive (not recommended in production) -connect(app, { graphqlHandler, db, unchainedAPI }, { - corsOrigins: true, -}); - -// Disable CORS entirely -connect(app, { graphqlHandler, db, unchainedAPI }, { - corsOrigins: false, -}); -``` - -### Deployment Scenarios - -#### Scenario 1: Direct TLS (No Reverse Proxy) - -For simple deployments where the Node.js server handles TLS directly: - -```typescript -import express from 'express'; -import https from 'https'; -import fs from 'fs'; -import { connect } from '@unchainedshop/api/express'; - -const app = express(); - -// Configure CORS for your frontend origins -connect(app, { graphqlHandler, db, unchainedAPI }, { - corsOrigins: "https://shop.example.com,https://admin.example.com", -}); - -// Create HTTPS server with your certificates -https.createServer({ - key: fs.readFileSync('/path/to/privkey.pem'), - cert: fs.readFileSync('/path/to/fullchain.pem'), -}, app).listen(443); -``` - -Environment: -```bash -NODE_ENV=production -UNCHAINED_CORS_ORIGINS=https://shop.example.com,https://admin.example.com -UNCHAINED_TOKEN_SECRET=your-32-char-secret-here -``` - -#### Scenario 2: Behind Reverse Proxy (Recommended) - -For production deployments behind nginx, Caddy, or a cloud load balancer: +For a local development server accessed from a remote frontend, both adapters provide an explicit compatibility option: ```typescript -import express from 'express'; -import { connect } from '@unchainedshop/api/express'; - -const app = express(); - -// Trust the reverse proxy for client IP headers -app.set('trust proxy', 1); - -// Let the proxy handle CORS, or configure here connect(app, { graphqlHandler, db, unchainedAPI }, { - corsOrigins: "https://shop.example.com,https://admin.example.com", + allowRemoteToLocalhostSecureCookies: true, }); - -app.listen(4010); // Internal port, not exposed -``` - -**Nginx configuration:** -```nginx -server { - listen 443 ssl http2; - server_name api.example.com; - - ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; - - location / { - # Pass client IP to the app - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $host; - - proxy_pass http://localhost:4010; - } -} ``` -**Caddy configuration:** -```caddyfile -api.example.com { - reverse_proxy localhost:4010 -} -``` +This reflects the request origin, allows credentials, and overrides `x-forwarded-proto` to `https`. The Express adapter also enables trust for one proxy hop. Use this option only for that development setup. -#### Scenario 3: Proxy Handles CORS - -For complex multi-origin setups, let the reverse proxy manage CORS: +Configure production proxy trust on the application according to your proxy topology: ```typescript -// Don't set corsOrigins - let proxy handle it +// Express, when the application is reachable only through one trusted proxy app.set('trust proxy', 1); connect(app, { graphqlHandler, db, unchainedAPI }); -``` - -**Nginx with CORS:** -```nginx -location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # CORS whitelist - set $cors ""; - if ($http_origin ~* "^https://(shop|admin)\.example\.com$") { - set $cors $http_origin; - } - add_header 'Access-Control-Allow-Origin' $cors always; - add_header 'Access-Control-Allow-Credentials' 'true' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always; - - if ($request_method = 'OPTIONS') { return 204; } - - proxy_pass http://localhost:4010; -} -``` - -### Trust Proxy - -In development mode (`NODE_ENV=development`), trust proxy is automatically enabled. - -In production, configure it on your Express/Fastify app before calling `connect()`: -```typescript -// Express -app.set('trust proxy', 1); // Trust first hop -app.set('trust proxy', 'loopback'); // Trust loopback addresses -app.set('trust proxy', '10.0.0.0/8'); // Trust specific CIDR - -// Fastify -const fastify = Fastify({ trustProxy: true }); +// Fastify: configure trust when creating the instance +const fastify = Fastify({ trustProxy: '127.0.0.1' }); ``` -**SECURITY**: Only enable trust proxy if your reverse proxy: -1. Strips incoming `X-Real-IP` and `X-Forwarded-For` headers from clients -2. Sets `X-Real-IP` to the actual client IP -3. Is the only way to reach your API server - -See [SECURITY.md](../../SECURITY.md) for complete security documentation including FIPS 140-3 mode. +See [SECURITY.md](../../SECURITY.md) for session storage, proxy, and cryptography details. ## License diff --git a/packages/core-assortments/README.md b/packages/core-assortments/README.md index e6d8b03cea..412f3a19aa 100644 --- a/packages/core-assortments/README.md +++ b/packages/core-assortments/README.md @@ -16,12 +16,15 @@ npm install @unchainedshop/core-assortments ```typescript import { configureAssortmentsModule } from '@unchainedshop/core-assortments'; -const assortmentsModule = await configureAssortmentsModule({ db }); +const assortmentsModule = await configureAssortmentsModule({ db, migrationRepository }); // Create an assortment -const assortmentId = await assortmentsModule.create({ +const assortment = await assortmentsModule.create({ slugs: ['electronics'], isRoot: true, + isActive: true, + sequence: 10, + tags: [], }); // Find assortments @@ -31,8 +34,9 @@ const assortments = await assortmentsModule.findAssortments({ // Add product to assortment await assortmentsModule.products.create({ - assortmentId, + assortmentId: assortment._id, productId: 'product-123', + tags: [], }); ``` @@ -61,48 +65,52 @@ await assortmentsModule.products.create({ | `create` | Create a new assortment | | `update` | Update assortment data | | `delete` | Soft delete an assortment | -| `setBase` | Set base assortment | | `invalidateCache` | Clear assortment cache | ### Submodules #### Products (`assortments.products`) + | Method | Description | |--------|-------------| -| `findProducts` | Find products in assortment | +| `findAssortmentProducts` | Find assortment-product links | | `create` | Add product to assortment | | `delete` | Remove product from assortment | -| `reorder` | Reorder products | +| `updateManualOrder` | Reorder products | #### Links (`assortments.links`) + | Method | Description | |--------|-------------| | `findLinks` | Find assortment links (parent-child) | | `create` | Create link between assortments | | `delete` | Remove link | -| `reorder` | Reorder child assortments | +| `updateManualOrder` | Reorder child assortments | #### Media (`assortments.media`) + | Method | Description | |--------|-------------| -| `findMedia` | Find assortment media | +| `findAssortmentMedias` | Find assortment media | | `create` | Add media to assortment | | `delete` | Remove media | -| `reorder` | Reorder media | +| `updateManualOrder` | Reorder media | #### Texts (`assortments.texts`) + | Method | Description | |--------|-------------| | `findTexts` | Find localized texts | | `updateTexts` | Update assortment texts | #### Filters (`assortments.filters`) + | Method | Description | |--------|-------------| | `findFilters` | Find filters for assortment | | `create` | Add filter to assortment | | `delete` | Remove filter | -| `reorder` | Reorder filters | +| `updateManualOrder` | Reorder filters | ### Settings diff --git a/packages/core-bookmarks/README.md b/packages/core-bookmarks/README.md index 16c11b19bb..951ed6ecc4 100644 --- a/packages/core-bookmarks/README.md +++ b/packages/core-bookmarks/README.md @@ -16,7 +16,7 @@ npm install @unchainedshop/core-bookmarks ```typescript import { configureBookmarksModule } from '@unchainedshop/core-bookmarks'; -const bookmarksModule = await configureBookmarksModule({ db }); +const bookmarksModule = await configureBookmarksModule({ db, migrationRepository }); // Create a bookmark const bookmarkId = await bookmarksModule.create({ diff --git a/packages/core-countries/README.md b/packages/core-countries/README.md index 5e0f7ef35a..85ec24cf76 100644 --- a/packages/core-countries/README.md +++ b/packages/core-countries/README.md @@ -16,7 +16,7 @@ npm install @unchainedshop/core-countries ```typescript import { configureCountriesModule } from '@unchainedshop/core-countries'; -const countriesModule = await configureCountriesModule({ db }); +const countriesModule = await configureCountriesModule({ db, migrationRepository }); // Create a country const countryId = await countriesModule.create({ @@ -28,7 +28,8 @@ const countryId = await countriesModule.create({ const countries = await countriesModule.findCountries({ includeInactive: false }); // Get localized country name -const name = countriesModule.name(country, new Intl.Locale('en')); +const country = await countriesModule.findCountry({ countryId }); +const name = country && countriesModule.name(country, new Intl.Locale('en')); ``` ## API Overview diff --git a/packages/core-currencies/README.md b/packages/core-currencies/README.md index 499f551511..5892324861 100644 --- a/packages/core-currencies/README.md +++ b/packages/core-currencies/README.md @@ -16,7 +16,7 @@ npm install @unchainedshop/core-currencies ```typescript import { configureCurrenciesModule } from '@unchainedshop/core-currencies'; -const currenciesModule = await configureCurrenciesModule({ db }); +const currenciesModule = await configureCurrenciesModule({ db, migrationRepository }); // Create a currency const currencyId = await currenciesModule.create({ diff --git a/packages/core-delivery/README.md b/packages/core-delivery/README.md index 9e1c9873de..ed873ad747 100644 --- a/packages/core-delivery/README.md +++ b/packages/core-delivery/README.md @@ -16,20 +16,21 @@ npm install @unchainedshop/core-delivery ```typescript import { configureDeliveryModule, DeliveryProviderType } from '@unchainedshop/core-delivery'; -const deliveryModule = await configureDeliveryModule({ db }); +const deliveryModule = await configureDeliveryModule({ db, migrationRepository }); // Create a delivery provider -const providerId = await deliveryModule.create({ +const provider = await deliveryModule.create({ type: DeliveryProviderType.SHIPPING, - adapterKey: 'shop.unchained.delivery.post', + configuration: [], + adapterKey: 'shop.unchained.post', }); -// Find providers for a context -const providers = await deliveryModule.findSupported({ - order: orderObject, -}); +// Find configured providers +const providers = await deliveryModule.findProviders({}); ``` +Context-dependent provider selection is available through `services.orders.supportedDeliveryProviders` in [`@unchainedshop/core`](../core/README.md). + ## API Overview ### Module Configuration @@ -46,8 +47,6 @@ const providers = await deliveryModule.findSupported({ | `findProviders` | Find providers with filtering | | `count` | Count providers | | `providerExists` | Check if provider exists | -| `findSupported` | Find providers supported for context | -| `findInterface` | Get provider interface definition | ### Mutations diff --git a/packages/core-enrollments/README.md b/packages/core-enrollments/README.md index d88d21261a..ef2ae1de26 100644 --- a/packages/core-enrollments/README.md +++ b/packages/core-enrollments/README.md @@ -16,24 +16,32 @@ npm install @unchainedshop/core-enrollments ```typescript import { configureEnrollmentsModule, EnrollmentStatus } from '@unchainedshop/core-enrollments'; -const enrollmentsModule = await configureEnrollmentsModule({ db }); +const enrollmentsModule = await configureEnrollmentsModule({ db, migrationRepository }); // Create an enrollment -const enrollmentId = await enrollmentsModule.create({ +const enrollment = await enrollmentsModule.create({ userId: 'user-123', productId: 'plan-product-456', quantity: 1, + countryCode: 'CH', + currencyCode: 'CHF', + configuration: [], + billingAddress: {}, + contact: {}, + delivery: {}, }); -// Activate enrollment -await enrollmentsModule.activate(enrollmentId); +// Read the newly created enrollment +const savedEnrollment = await enrollmentsModule.findEnrollment({ enrollmentId: enrollment._id }); // Find active enrollments const enrollments = await enrollmentsModule.findEnrollments({ - status: EnrollmentStatus.ACTIVE, + status: [EnrollmentStatus.ACTIVE], }); ``` +Activation and termination workflows are available through `services.enrollments` in [`@unchainedshop/core`](../core/README.md). + ## API Overview ### Module Configuration @@ -49,24 +57,23 @@ const enrollments = await enrollmentsModule.findEnrollments({ | `findEnrollment` | Find enrollment by ID | | `findEnrollments` | Find enrollments with filtering and pagination | | `count` | Count enrollments matching query | -| `enrollmentExists` | Check if enrollment exists | ### Mutations | Method | Description | |--------|-------------| | `create` | Create a new enrollment | -| `update` | Update enrollment data | +| `updatePlan` | Update the product, quantity, and configuration | +| `updateContext` | Update enrollment metadata | | `delete` | Delete an enrollment | -| `activate` | Activate an enrollment | -| `terminate` | Terminate an enrollment | +| `updateStatus` | Update enrollment status | ### Period Management | Method | Description | |--------|-------------| -| `addPeriod` | Add a billing period | -| `findPeriod` | Find a specific period | +| `addEnrollmentPeriod` | Add a billing period | +| `removeEnrollmentPeriodByOrderId` | Remove periods associated with an order | | `isExpired` | Check if enrollment is expired | ### Utilities @@ -102,8 +109,7 @@ const enrollments = await enrollmentsModule.findEnrollments({ | `ENROLLMENT_CREATE` | Enrollment created | | `ENROLLMENT_UPDATE` | Enrollment updated | | `ENROLLMENT_REMOVE` | Enrollment deleted | -| `ENROLLMENT_ACTIVATE` | Enrollment activated | -| `ENROLLMENT_TERMINATE` | Enrollment terminated | +| `ENROLLMENT_ADD_PERIOD` | Billing period added | ## License diff --git a/packages/core-events/README.md b/packages/core-events/README.md index 2f2217a873..0fe16cf7fe 100644 --- a/packages/core-events/README.md +++ b/packages/core-events/README.md @@ -16,18 +16,18 @@ npm install @unchainedshop/core-events ```typescript import { configureEventsModule } from '@unchainedshop/core-events'; -const eventsModule = await configureEventsModule({ db }); +const eventsModule = await configureEventsModule({ db, migrationRepository }); // Find events by type const orderEvents = await eventsModule.findEvents({ - types: ['ORDER_CREATE', 'ORDER_PAID'], + types: ['ORDER_CREATE', 'ORDER_CONFIRMED'], limit: 100, }); // Get event statistics const report = await eventsModule.getReport({ types: ['ORDER_CREATE'], - dateRange: { start: '2024-01-01', end: '2024-12-31' }, + dateRange: { start: new Date(Date.now() - 24 * 60 * 60 * 1000) }, }); ``` diff --git a/packages/core-files/README.md b/packages/core-files/README.md index fffe64b89e..2146f26e6b 100644 --- a/packages/core-files/README.md +++ b/packages/core-files/README.md @@ -18,6 +18,7 @@ import { configureFilesModule } from '@unchainedshop/core-files'; const filesModule = await configureFilesModule({ db, + migrationRepository, options: { transformUrl: (url, params) => url, // Optional URL transformation }, @@ -33,7 +34,7 @@ const fileId = await filesModule.create({ // Find file and normalize URL const file = await filesModule.findFile({ fileId }); -const normalizedUrl = filesModule.normalizeUrl(file.url, {}); +const normalizedUrl = file?.url && filesModule.normalizeUrl(file.url, {}); ``` ## API Overview @@ -49,7 +50,7 @@ const normalizedUrl = filesModule.normalizeUrl(file.url, {}); | Method | Description | |--------|-------------| | `findFile` | Find file by ID or URL | -| `findFiles` | Find files with custom selector | +| `findFiles` | Find files by IDs, path, metadata, or creation time | ### Mutations @@ -88,6 +89,7 @@ const normalizedUrl = filesModule.normalizeUrl(file.url, {}); ```typescript const filesModule = await configureFilesModule({ db, + migrationRepository, options: { transformUrl: (url, params) => { // Transform URLs for CDN, thumbnails, etc. diff --git a/packages/core-filters/README.md b/packages/core-filters/README.md index 329f7294b7..df6ecc0da7 100644 --- a/packages/core-filters/README.md +++ b/packages/core-filters/README.md @@ -16,19 +16,17 @@ npm install @unchainedshop/core-filters ```typescript import { configureFiltersModule, FilterType } from '@unchainedshop/core-filters'; -const filtersModule = await configureFiltersModule({ db }); +const filtersModule = await configureFiltersModule({ db, migrationRepository }); // Create a filter -const filterId = await filtersModule.create({ +const filter = await filtersModule.create({ key: 'color', type: FilterType.MULTI_CHOICE, + options: [], }); -// Search with filters -const result = await filtersModule.search.searchProducts({ - filterQuery: { color: ['red', 'blue'] }, - assortmentId: 'category-123', -}); +// Add an option +await filtersModule.createFilterOption(filter._id, { value: 'red' }); ``` ## API Overview @@ -54,29 +52,25 @@ const result = await filtersModule.search.searchProducts({ |--------|-------------| | `create` | Create a new filter | | `update` | Update filter data | -| `delete` | Soft delete a filter | +| `delete` | Delete a filter, its texts, and cached product IDs | ### Filter Options | Method | Description | |--------|-------------| | `createFilterOption` | Add option to filter | -| `updateFilterOption` | Update filter option | | `removeFilterOption` | Remove filter option | ### Texts | Method | Description | |--------|-------------| -| `findFilterTexts` | Find localized filter texts | -| `updateTexts` | Update filter texts | +| `texts.findTexts` | Find localized filter texts | +| `texts.updateTexts` | Update filter texts | -### Search Submodule +### Search services -| Method | Description | -|--------|-------------| -| `search.searchProducts` | Search products with filters | -| `search.searchAssortments` | Search assortments | +Product and assortment search are available through `services.filters.searchProducts` and `services.filters.searchAssortments` in [`@unchainedshop/core`](../core/README.md). Use its filter mutation services when changes need to invalidate dependent caches. ### Constants @@ -95,7 +89,6 @@ const result = await filtersModule.search.searchProducts({ | Export | Description | |--------|-------------| | `Filter` | Filter document type | -| `FilterOption` | Filter option type | | `FilterQuery` | Query parameters type | | `FiltersModule` | Module interface type | | `SearchQuery` | Search query type | diff --git a/packages/core-languages/README.md b/packages/core-languages/README.md index acfb3693cb..6b9245063c 100644 --- a/packages/core-languages/README.md +++ b/packages/core-languages/README.md @@ -16,7 +16,7 @@ npm install @unchainedshop/core-languages ```typescript import { configureLanguagesModule } from '@unchainedshop/core-languages'; -const languagesModule = await configureLanguagesModule({ db }); +const languagesModule = await configureLanguagesModule({ db, migrationRepository }); // Create a language const languageId = await languagesModule.create({ @@ -27,7 +27,8 @@ const languageId = await languagesModule.create({ const languages = await languagesModule.findLanguages({ includeInactive: false }); // Check if language is base language -const isBase = languagesModule.isBase(language); +const language = await languagesModule.findLanguage({ languageId }); +const isBase = language && languagesModule.isBase(language); ``` ## API Overview diff --git a/packages/core-orders/README.md b/packages/core-orders/README.md index 8c281f7889..95957da2fd 100644 --- a/packages/core-orders/README.md +++ b/packages/core-orders/README.md @@ -14,28 +14,31 @@ npm install @unchainedshop/core-orders ## Usage ```typescript -import { configureOrdersModule, OrderStatus } from '@unchainedshop/core-orders'; +import { configureOrdersModule } from '@unchainedshop/core-orders'; -const ordersModule = await configureOrdersModule({ db }); +const ordersModule = await configureOrdersModule({ db, migrationRepository }); // Create an order -const orderId = await ordersModule.create({ +const order = await ordersModule.create({ userId: 'user-123', - currency: 'CHF', + currencyCode: 'CHF', countryCode: 'CH', }); // Add position to order -await ordersModule.positions.create({ - orderId, +await ordersModule.positions.addProductItem({ + orderId: order._id, + originalProductId: 'product-456', productId: 'product-456', quantity: 2, }); -// Checkout order -await ordersModule.checkout(orderId, { paymentContext: {} }); +// Read the resulting positions +const positions = await ordersModule.positions.findOrderPositions({ orderId: order._id }); ``` +Checkout, confirmation, payment charging, and delivery dispatch are orchestrated by the services and directors in [`@unchainedshop/core`](../core/README.md). This module provides persistence and status updates. + ## API Overview ### Module Configuration @@ -58,44 +61,44 @@ await ordersModule.checkout(orderId, { paymentContext: {} }); | Method | Description | |--------|-------------| | `create` | Create a new order | -| `update` | Update order data | +| `updateCartFields` | Update cart fields | | `delete` | Delete an order | -| `checkout` | Process order checkout | -| `confirm` | Confirm an order | -| `reject` | Reject an order | +| `updateStatus` | Update order status and emit lifecycle events | | `setPaymentProvider` | Set payment provider | | `setDeliveryProvider` | Set delivery provider | ### Submodules #### Positions (`orders.positions`) + | Method | Description | |--------|-------------| -| `findPositions` | Find order positions | -| `create` | Add position to order | -| `update` | Update position | +| `findOrderPositions` | Find order positions | +| `addProductItem` | Add a product position to an order | +| `updateProductItem` | Update a product position | | `delete` | Remove position | #### Payments (`orders.payments`) + | Method | Description | |--------|-------------| -| `findPayment` | Find order payment | +| `findOrderPayment` | Find order payment | | `create` | Create payment for order | -| `markPaid` | Mark payment as paid | -| `charge` | Charge the payment | +| `markAsPaid` | Mark payment as paid | #### Deliveries (`orders.deliveries`) + | Method | Description | |--------|-------------| | `findDelivery` | Find order delivery | | `create` | Create delivery for order | -| `markDelivered` | Mark as delivered | -| `send` | Trigger delivery | +| `markAsDelivered` | Mark as delivered | #### Discounts (`orders.discounts`) + | Method | Description | |--------|-------------| -| `findDiscounts` | Find order discounts | +| `findOrderDiscounts` | Find order discounts | | `create` | Add discount to order | | `delete` | Remove discount | @@ -103,7 +106,7 @@ await ordersModule.checkout(orderId, { paymentContext: {} }); | Export | Description | |--------|-------------| -| `OrderStatus` | Order status values (OPEN, PENDING, CONFIRMED, FULFILLED, REJECTED) | +| `OrderStatus` | Order status values (PENDING, CONFIRMED, FULFILLED, REJECTED); carts use `null` | ### Settings diff --git a/packages/core-orders/src/db/OrdersCollection.ts b/packages/core-orders/src/db/OrdersCollection.ts index 3ba94d2220..cdb7050b90 100644 --- a/packages/core-orders/src/db/OrdersCollection.ts +++ b/packages/core-orders/src/db/OrdersCollection.ts @@ -8,7 +8,7 @@ import { } from '@unchainedshop/mongodb'; import type { DateFilterInput } from '@unchainedshop/utils'; -// NOTE: Renamed from FULLFILLED to FULFILLED in v5.0.0 +// Use FULFILLED; the old FULLFILLED spelling is not a supported status. // Migration for status: db.orders.updateMany({ status: 'FULLFILLED' }, { $set: { status: 'FULFILLED' } }) // Migration for field: db.orders.updateMany({ fullfilled: { $exists: true } }, { $rename: { fullfilled: 'fulfilled' } }) export const OrderStatus = { diff --git a/packages/core-orders/src/module/configureOrdersModule.ts b/packages/core-orders/src/module/configureOrdersModule.ts index d0fb2ce933..c00a278426 100644 --- a/packages/core-orders/src/module/configureOrdersModule.ts +++ b/packages/core-orders/src/module/configureOrdersModule.ts @@ -17,8 +17,7 @@ import { emit, registerEvents } from '@unchainedshop/events'; import renameCurrencyCode from '../migrations/20250502111800-currency-code.ts'; import normalizeOrderContactPhone from '../migrations/20260625120000-normalize-contact-phone.ts'; -// NOTE: Renamed from ORDER_FULLFILLED to ORDER_FULFILLED in v5.0.0 -// This is a breaking change for event subscribers +// Subscribe to ORDER_FULFILLED; the old ORDER_FULLFILLED spelling is not emitted. const ORDER_EVENTS: string[] = [ 'ORDER_CHECKOUT', 'ORDER_CONFIRMED', diff --git a/packages/core-payment/README.md b/packages/core-payment/README.md index 037a1a9b9d..2604fc8e8c 100644 --- a/packages/core-payment/README.md +++ b/packages/core-payment/README.md @@ -16,20 +16,21 @@ npm install @unchainedshop/core-payment ```typescript import { configurePaymentModule, PaymentProviderType } from '@unchainedshop/core-payment'; -const paymentModule = await configurePaymentModule({ db }); +const paymentModule = await configurePaymentModule({ db, migrationRepository }); // Create a payment provider -const providerId = await paymentModule.create({ - type: PaymentProviderType.CARD, +const provider = await paymentModule.paymentProviders.create({ + type: PaymentProviderType.GENERIC, + configuration: [], adapterKey: 'shop.unchained.payment.stripe', }); -// Find providers for a context -const providers = await paymentModule.findSupported({ - order: orderObject, -}); +// Find configured providers +const providers = await paymentModule.paymentProviders.findProviders({}); ``` +Context-dependent provider selection is available through `services.orders.supportedPaymentProviders` in [`@unchainedshop/core`](../core/README.md). + ## API Overview ### Module Configuration @@ -38,7 +39,7 @@ const providers = await paymentModule.findSupported({ |--------|-------------| | `configurePaymentModule` | Configure and return the payment module | -### Queries +### Provider queries (`payment.paymentProviders`) | Method | Description | |--------|-------------| @@ -46,10 +47,8 @@ const providers = await paymentModule.findSupported({ | `findProviders` | Find providers with filtering | | `count` | Count providers | | `providerExists` | Check if provider exists | -| `findSupported` | Find providers supported for context | -| `findInterface` | Get provider interface definition | -### Mutations +### Provider mutations (`payment.paymentProviders`) | Method | Description | |--------|-------------| @@ -57,20 +56,20 @@ const providers = await paymentModule.findSupported({ | `update` | Update provider configuration | | `delete` | Soft delete a provider | -### Credentials +### Credentials (`payment.paymentCredentials`) | Method | Description | |--------|-------------| -| `findCredentials` | Find stored credentials for user | -| `createCredentials` | Store payment credentials | -| `deleteCredentials` | Remove stored credentials | +| `findPaymentCredentials` | Find stored credentials for user | +| `upsertCredentials` | Store payment credentials | +| `removeCredentials` | Remove stored credentials | | `markPreferred` | Mark credentials as preferred | ### Constants | Export | Description | |--------|-------------| -| `PaymentProviderType` | Provider types (CARD, INVOICE, GENERIC) | +| `PaymentProviderType` | Provider types (INVOICE, GENERIC) | ### Settings @@ -96,17 +95,17 @@ const providers = await paymentModule.findSupported({ ## Security (PCI DSS) -This module is designed for **PCI DSS SAQ-A eligibility**: +This module stores payment provider tokens and metadata. PCI DSS scope depends on the payment integration and deployment; see [SECURITY.md](../../SECURITY.md). ### Tokenization -- **No card data storage**: Credit card numbers (PAN) and CVV are never stored -- **Provider tokens only**: Only payment provider-issued tokens are stored -- **Secure credentials**: Payment credentials contain references, not card data +- Do not place card numbers or CVV in credential tokens or metadata +- Store provider-issued tokens in the `token` field +- Keep provider metadata free of sensitive card data ```typescript // PaymentCredentials structure - tokens only, no card data -type PaymentCredentials = { +type StoredCredentialFields = { paymentProviderId: string; userId: string; token?: string; // Provider-issued token (NOT card number) @@ -117,7 +116,7 @@ type PaymentCredentials = { ### Payment Flow -All payment integrations use tokenization patterns: +A tokenized card integration typically follows this flow: 1. Card data collected by payment provider (Stripe, Datatrans, etc.) 2. Provider returns secure token 3. Unchained stores only the token reference diff --git a/packages/core-products/README.md b/packages/core-products/README.md index cfaea276dc..aee9dbc41c 100644 --- a/packages/core-products/README.md +++ b/packages/core-products/README.md @@ -16,12 +16,12 @@ npm install @unchainedshop/core-products ```typescript import { configureProductsModule, ProductType } from '@unchainedshop/core-products'; -const productsModule = await configureProductsModule({ db }); +const productsModule = await configureProductsModule({ db, migrationRepository }); // Create a product -const productId = await productsModule.create({ - type: ProductType.SimpleProduct, - slugs: ['my-product'], +const product = await productsModule.create({ + type: ProductType.SIMPLE_PRODUCT, + tags: [], }); // Find products @@ -31,7 +31,7 @@ const products = await productsModule.findProducts({ }); // Publish a product -await productsModule.publish(productId); +await productsModule.publish(product); ``` ## API Overview @@ -60,56 +60,62 @@ await productsModule.publish(productId); | `delete` | Soft delete a product | | `publish` | Publish a draft product | | `unpublish` | Unpublish a product | -| `addAssignment` | Add product proxy assignment | -| `removeAssignment` | Remove product assignment | +| `assignments.addProxyAssignment` | Add product proxy assignment | +| `assignments.removeAssignment` | Remove product assignment | ### Submodules #### Media (`products.media`) + | Method | Description | |--------|-------------| -| `findProductMedia` | Find media for a product | -| `createMedia` | Add media to product | -| `updateMedia` | Update media metadata | -| `deleteMedia` | Remove media from product | -| `reorderMedia` | Reorder product media | +| `findProductMedias` | Find media for a product | +| `create` | Add media to product | +| `update` | Update media metadata | +| `delete` | Remove media from product | +| `updateManualOrder` | Reorder product media | #### Prices (`products.prices`) + | Method | Description | |--------|-------------| -| `findProductPrices` | Get product prices | -| `createPrice` | Add a price | -| `updatePrice` | Update a price | -| `deletePrice` | Remove a price | +| `catalogPrices` | Get catalog prices for a product | +| `price` | Resolve a catalog price for a pricing context | +| `catalogPriceRange` | Get the catalog price range | + +Update stored catalog prices through `products.update(productId, { "commerce.pricing": prices })`. #### Reviews (`products.reviews`) + | Method | Description | |--------|-------------| | `findProductReviews` | Find reviews for product | -| `createReview` | Add a review | -| `updateReview` | Update a review | -| `deleteReview` | Remove a review | +| `create` | Add a review | +| `update` | Update a review | +| `delete` | Remove a review | #### Texts (`products.texts`) + | Method | Description | |--------|-------------| -| `findProductTexts` | Find localized texts | +| `findTexts` | Find localized texts | | `updateTexts` | Update product texts | #### Variations (`products.variations`) + | Method | Description | |--------|-------------| | `findProductVariations` | Find product variations | -| `createVariation` | Add a variation | -| `updateVariation` | Update a variation | -| `deleteVariation` | Remove a variation | +| `create` | Add a variation | +| `update` | Update a variation | +| `delete` | Remove a variation | ### Constants | Export | Description | |--------|-------------| -| `ProductType` | Product types (SimpleProduct, ConfigurableProduct, BundleProduct, PlanProduct, TokenizedProduct) | -| `ProductStatus` | Product status values (ACTIVE, DRAFT) | +| `ProductType` | Product types (SIMPLE_PRODUCT, CONFIGURABLE_PRODUCT, BUNDLE_PRODUCT, PLAN_PRODUCT, TOKENIZED_PRODUCT) | +| `ProductStatus` | Product status values (ACTIVE, DRAFT, DELETED) | ### Types diff --git a/packages/core-quotations/README.md b/packages/core-quotations/README.md index e99ff77c26..6d24f2568c 100644 --- a/packages/core-quotations/README.md +++ b/packages/core-quotations/README.md @@ -16,27 +16,31 @@ npm install @unchainedshop/core-quotations ```typescript import { configureQuotationsModule, QuotationStatus } from '@unchainedshop/core-quotations'; -const quotationsModule = await configureQuotationsModule({ db }); +const quotationsModule = await configureQuotationsModule({ db, migrationRepository }); // Create a quotation request -const quotationId = await quotationsModule.create({ +const quotation = await quotationsModule.create({ userId: 'user-123', + currencyCode: 'CHF', productId: 'custom-product-456', configuration: [{ key: 'quantity', value: '1000' }], }); -// Propose a quote -await quotationsModule.propose(quotationId, { - price: { amount: 5000, currency: 'CHF' }, - expiresAt: new Date('2024-12-31'), +// Store a proposal and update its status +await quotationsModule.updateProposal(quotation._id, { + price: { amount: 5000, currencyCode: 'CHF' }, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }); +await quotationsModule.updateStatus(quotation._id, { status: QuotationStatus.PROPOSED }); // Find quotations const quotations = await quotationsModule.findQuotations({ - status: QuotationStatus.PROPOSED, + userId: 'user-123', }); ``` +For adapter validation and quotation workflows, use `services.quotations` in [`@unchainedshop/core`](../core/README.md). + ## API Overview ### Module Configuration @@ -52,19 +56,16 @@ const quotations = await quotationsModule.findQuotations({ | `findQuotation` | Find quotation by ID | | `findQuotations` | Find quotations with filtering and pagination | | `count` | Count quotations matching query | -| `quotationExists` | Check if quotation exists | ### Mutations | Method | Description | |--------|-------------| | `create` | Create a quotation request | -| `update` | Update quotation data | -| `delete` | Delete a quotation | -| `propose` | Propose a quote | -| `verify` | Verify a quotation | -| `reject` | Reject a quotation | -| `fulfill` | Mark quotation as fulfilled | +| `updateContext` | Update quotation context | +| `updateProposal` | Update proposal price, expiry, and metadata | +| `updateStatus` | Update quotation status | +| `deleteRequestedUserQuotations` | Delete a user's requested quotations | ### Constants @@ -83,19 +84,15 @@ const quotations = await quotationsModule.findQuotations({ | Export | Description | |--------|-------------| | `Quotation` | Quotation document type | -| `QuotationConfiguration` | Configuration item type | | `QuotationsModule` | Module interface type | ## Events | Event | Description | |-------|-------------| -| `QUOTATION_CREATE` | Quotation requested | +| `QUOTATION_REQUEST_CREATE` | Quotation requested | | `QUOTATION_UPDATE` | Quotation updated | | `QUOTATION_REMOVE` | Quotation deleted | -| `QUOTATION_PROPOSE` | Quote proposed | -| `QUOTATION_REJECT` | Quotation rejected | -| `QUOTATION_FULLFILL` | Quotation fulfilled | ## License diff --git a/packages/core-quotations/src/db/QuotationsCollection.ts b/packages/core-quotations/src/db/QuotationsCollection.ts index dbf83ad9d9..74519ffb7e 100644 --- a/packages/core-quotations/src/db/QuotationsCollection.ts +++ b/packages/core-quotations/src/db/QuotationsCollection.ts @@ -1,6 +1,6 @@ import { mongodb, buildDbIndexes, type TimestampFields, type LogFields } from '@unchainedshop/mongodb'; -// NOTE: Renamed from FULLFILLED to FULFILLED in v5.0.0 +// Use FULFILLED; the old FULLFILLED spelling is not a supported status. // Migration for status: db.quotations.updateMany({ status: 'FULLFILLED' }, { $set: { status: 'FULFILLED' } }) // Migration for field: db.quotations.updateMany({ fullfilled: { $exists: true } }, { $rename: { fullfilled: 'fulfilled' } }) export const QuotationStatus = { diff --git a/packages/core-users/README.md b/packages/core-users/README.md index 6e253eef3d..7a5bbabb0c 100644 --- a/packages/core-users/README.md +++ b/packages/core-users/README.md @@ -16,7 +16,7 @@ npm install @unchainedshop/core-users ```typescript import { configureUsersModule } from '@unchainedshop/core-users'; -const usersModule = await configureUsersModule({ db }); +const usersModule = await configureUsersModule({ db, migrationRepository }); // Find users const users = await usersModule.findUsers({ @@ -32,7 +32,7 @@ const userId = await usersModule.createUser({ // Update profile await usersModule.updateProfile(userId, { - displayName: 'John Doe', + profile: { displayName: 'John Doe' }, }); ``` @@ -64,11 +64,11 @@ await usersModule.updateProfile(userId, { | `updateRoles` | Update user roles | | `updateTags` | Update user tags | | `updateAvatar` | Set user avatar | -| `updateBillingAddress` | Update billing address | -| `updatePassword` | Change user password | -| `updateUsername` | Change username | -| `delete` | Soft delete a user | -| `replaceUserId` | Migrate data between users | +| `updateLastBillingAddress` | Update billing address | +| `setPassword` | Change user password | +| `setUsername` | Change username | +| `markDeleted` | Soft delete and anonymize a user | +| `deletePermanently` | Permanently delete a user document | ### Authentication @@ -80,15 +80,17 @@ await usersModule.updateProfile(userId, { | `removeEmail` | Remove email address | | `verifyEmail` | Mark email as verified | | `updateHeartbeat` | Update last activity timestamp | -| `updateLastLogin` | Record login event | ### WebAuthn Submodule | Method | Description | |--------|-------------| -| `webAuthn.findCredentials` | Find WebAuthn credentials for user | -| `webAuthn.createCredential` | Register new WebAuthn credential | -| `webAuthn.removeCredential` | Remove WebAuthn credential | +| `webAuthn.createCredentialCreationOptions` | Create registration options | +| `webAuthn.createCredentialRequestOptions` | Create authentication options | +| `webAuthn.verifyCredentialCreation` | Verify registration response | +| `webAuthn.verifyCredentialRequest` | Verify authentication response | +| `addWebAuthnCredential` | Store a verified user credential | +| `removeWebAuthnCredential` | Remove a user credential | ### Settings @@ -117,7 +119,6 @@ await usersModule.updateProfile(userId, { | `USER_REMOVE` | User deleted | | `USER_UPDATE_PROFILE` | Profile updated | | `USER_UPDATE_PASSWORD` | Password changed | -| `USER_UPDATE_ROLES` | Roles changed | | `USER_ACCOUNT_ACTION` | Account action triggered | ## Security @@ -126,18 +127,18 @@ This module implements security best practices for user authentication and data ### Password Security -- **Algorithm**: PBKDF2 with SHA-512 -- **Iterations**: 300,000 (exceeds OWASP recommendation) +- **Algorithm**: New hashes use PBKDF2 with SHA-512; verification also accepts legacy bcrypt hashes +- **Iterations**: 300,000 - **Salt**: 16 bytes, cryptographically random per password -- **Key Length**: 256 bytes -- **FIPS 140-3**: Compatible when running on FIPS-enabled Node.js +- **Key Length**: 256 bits (32 bytes) +- See [SECURITY.md](../../SECURITY.md) for deployment-specific cryptography considerations ### Token Security - **Generation**: `crypto.randomUUID()` (CSPRNG-based) - **Storage**: SHA-256 hashed before database storage -- **Expiration**: Time-limited (configurable, default 1 hour) -- **Single-use**: Tokens invalidated after verification +- **Expiration**: Reset and verification tokens default to one hour; customize `userSettings.earliestValidTokenDate` by account action +- **Single-use**: Password-reset and email-verification tokens are consumed when used; access tokens are reusable ### WebAuthn/FIDO2 diff --git a/packages/core-users/src/module/configureUsersModule.ts b/packages/core-users/src/module/configureUsersModule.ts index 9271deea80..e77a47d448 100644 --- a/packages/core-users/src/module/configureUsersModule.ts +++ b/packages/core-users/src/module/configureUsersModule.ts @@ -600,7 +600,7 @@ export const configureUsersModule = async (moduleInput: ModuleInput { - // Generate high-entropy token using CSPRNG (128 bits of entropy) + // Generate high-entropy token using CSPRNG (UUIDv4 has 122 random bits) const plainToken = crypto.randomUUID(); // SHA-256 is appropriate for high-entropy tokens (OWASP recommendation) const secret = await sha256(plainToken); diff --git a/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts b/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts index 9f63405fe2..e753a30fee 100644 --- a/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts +++ b/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts @@ -301,7 +301,7 @@ describe('WebAuthn Module', () => { 'https://example.com', 'testuser-multiple', ); - // Small delay to avoid duplicate _id (which uses Date.getTime()) + // Space out consecutive challenge requests. await new Promise((resolve) => setTimeout(resolve, 2)); const options2 = await webAuthnModule.createCredentialCreationOptions( 'https://example.com', @@ -322,7 +322,7 @@ describe('WebAuthn Module', () => { `testuser-unique-${i}`, ); challenges.add(options!.challenge); - // Small delay to avoid duplicate _id (which uses Date.getTime()) + // Space out consecutive challenge requests. await new Promise((resolve) => setTimeout(resolve, 2)); } diff --git a/packages/core-users/src/module/pbkdf2.ts b/packages/core-users/src/module/pbkdf2.ts index e8d40b4ec6..3bf135c8f0 100644 --- a/packages/core-users/src/module/pbkdf2.ts +++ b/packages/core-users/src/module/pbkdf2.ts @@ -2,7 +2,7 @@ import { timingSafeStringEqual } from '@unchainedshop/utils'; // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 const PBKDF2_ITERATIONS = 300000; // Iterations, > 210'000 -const PBKDF2_KEY_LENGTH = 256; // Bytes +const PBKDF2_KEY_LENGTH = 256; // Bits (32 bytes); crypto.subtle.deriveBits expects bits const PBKDF2_SALT_LENGTH = 16; // Bytes export function generateSalt(saltLength = PBKDF2_SALT_LENGTH) { diff --git a/packages/core-warehousing/README.md b/packages/core-warehousing/README.md index e3a74f7d68..cebaddcbf2 100644 --- a/packages/core-warehousing/README.md +++ b/packages/core-warehousing/README.md @@ -16,18 +16,21 @@ npm install @unchainedshop/core-warehousing ```typescript import { configureWarehousingModule, WarehousingProviderType } from '@unchainedshop/core-warehousing'; -const warehousingModule = await configureWarehousingModule({ db }); +const warehousingModule = await configureWarehousingModule({ db, migrationRepository }); // Create a warehousing provider -const providerId = await warehousingModule.create({ +const provider = await warehousingModule.create({ type: WarehousingProviderType.PHYSICAL, - adapterKey: 'shop.unchained.warehousing.inventory', + configuration: [], + adapterKey: 'shop.unchained.warehousing.store', }); // Find providers const providers = await warehousingModule.findProviders({}); ``` +Context-dependent provider selection is available through `services.orders.supportedWarehousingProviders` in [`@unchainedshop/core`](../core/README.md). + ## API Overview ### Module Configuration @@ -44,8 +47,6 @@ const providers = await warehousingModule.findProviders({}); | `findProviders` | Find providers with filtering | | `count` | Count providers | | `providerExists` | Check if provider exists | -| `findSupported` | Find providers for product context | -| `findInterface` | Get provider interface definition | ### Mutations @@ -61,11 +62,11 @@ For tokenized products (NFTs): | Method | Description | |--------|-------------| -| `findTokenSurrogate` | Find token surrogate | -| `createTokenSurrogate` | Create token surrogate | -| `updateTokenSurrogate` | Update token surrogate | -| `deleteTokenSurrogate` | Delete token surrogate | -| `invalidateTokenSurrogates` | Invalidate surrogates for product | +| `findToken` | Find a token surrogate by ID | +| `findTokens` | Find token surrogates with a selector | +| `createTokens` | Insert token surrogates | +| `updateTokenOwnership` | Update token ownership | +| `invalidateToken` | Invalidate a token surrogate | ### Constants diff --git a/packages/core-worker/README.md b/packages/core-worker/README.md index faa08b1257..3411a56448 100644 --- a/packages/core-worker/README.md +++ b/packages/core-worker/README.md @@ -16,27 +16,25 @@ npm install @unchainedshop/core-worker ```typescript import { configureWorkerModule, WorkStatus } from '@unchainedshop/core-worker'; -const workerModule = await configureWorkerModule({ db }); +const workerModule = await configureWorkerModule({ db, migrationRepository }); // Add a work item to the queue -const workId = await workerModule.addWork({ - type: 'SEND_EMAIL', +const work = await workerModule.addWork({ + type: 'EMAIL', input: { to: 'user@example.com', - template: 'order-confirmation', + subject: 'Order confirmation', + text: 'Thank you for your order.', }, }); // Find pending work -const pendingWork = await workerModule.findWork({ - status: WorkStatus.NEW, +const pendingWork = await workerModule.findWorkQueue({ + status: [WorkStatus.NEW], }); -// Process work (typically done by worker plugins) -await workerModule.processWork(workId, { - success: true, - result: { messageId: 'abc123' }, -}); +// Read the queued work item +const queuedWork = await workerModule.findWork({ workId: work._id }); ``` ## API Overview @@ -61,7 +59,7 @@ await workerModule.processWork(workId, { |--------|-------------| | `addWork` | Add work item to queue | | `allocateWork` | Allocate work to a worker | -| `processWork` | Mark work as processed | +| `finishWork` | Record a work result | | `rescheduleWork` | Reschedule failed work | | `deleteWork` | Delete a work item | @@ -84,17 +82,18 @@ Work types are linked to worker plugins. Common built-in types: | Type | Description | |------|-------------| -| `SEND_EMAIL` | Send email notifications | +| `EMAIL` | Send email notifications | | `HEARTBEAT` | Keep-alive jobs | -| `EXTERNAL` | External service calls | +| `HTTP_REQUEST` | Make HTTP requests | ## Worker Plugins Workers process jobs by type. The plugin is responsible for: -- Handling retries on failure - Processing the work input - Returning success/failure results +The queue managers in `@unchainedshop/core` handle allocation and retry scheduling. Plugins return a `WorkResult` with a `success` flag. + ## Events | Event | Description | @@ -102,7 +101,10 @@ Workers process jobs by type. The plugin is responsible for: | `WORK_ADDED` | Work item added to queue | | `WORK_ALLOCATED` | Work allocated to worker | | `WORK_FINISHED` | Work processing completed | -| `WORK_FAILED` | Work processing failed | +| `WORK_RESCHEDULED` | Work scheduled for another attempt | +| `WORK_DELETED` | Work item deleted | + +`WORK_FINISHED` is emitted for both successful and failed work; inspect its `success` flag. ## License diff --git a/packages/core/README.md b/packages/core/README.md index 5bf0bc3266..a24cf36a5b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -28,10 +28,16 @@ const unchainedCore = await initCore({ const products = await unchainedCore.modules.products.findProducts({}); // Use services -const pricing = await unchainedCore.services.orders.pricingSheet(order); +const updatedCart = await unchainedCore.services.orders.updateCalculation(orderId); // Bulk import -await unchainedCore.bulkImporter.prepare('PRODUCT'); +const importer = unchainedCore.bulkImporter.createBulkImporter({}); +await importer.prepare({ + entity: 'PRODUCT', + operation: 'UPDATE', + payload: { _id: productId, specification: { tags: ['featured'] } }, +}, unchainedCore); +const importResult = await importer.execute(); ``` ## API Overview @@ -41,7 +47,7 @@ await unchainedCore.bulkImporter.prepare('PRODUCT'); | Export | Description | |--------|-------------| | `initCore` | Initialize the core with all modules and services | -| `getAllAdapters` | Get all registered adapters across all directors | +| `getAllAdapters` | Get adapters registered with the core plugin directors | ### Modules @@ -114,468 +120,69 @@ import { BaseAdapter, BaseDirector } from '@unchainedshop/utils'; ### Creating a Custom Adapter -All adapters extend from `BaseAdapter` and must implement: +Start with the base adapter for the relevant director. Spread its defaults, then override the methods your integration implements. Adapter objects provide `key`, `label`, and `version`; directors register them by key. ```typescript -const MyAdapter = { - key: 'my-adapter', // Unique identifier - label: 'My Custom Adapter', // Human-readable label - version: '1.0.0', // Adapter version +import { WorkerAdapter, WorkerDirector, schedule, type IWorkerAdapter } from '@unchainedshop/core'; - // Adapter-specific methods... -}; - -// Register with the appropriate director -SomeDirector.registerAdapter(MyAdapter); -``` - -### Payment Director - -Manages payment processing and orchestrates payment adapters. - -```typescript -import { PaymentDirector, type IPaymentAdapter } from '@unchainedshop/core'; - -const MyPaymentAdapter: IPaymentAdapter = { - key: 'my-payment', - label: 'My Payment Gateway', - version: '1.0.0', - - typeSupported(type) { - return type === 'CARD'; - }, - - actions(config, context) { - return { - configurationError() { return null; }, - isActive() { return true; }, - isPayLaterAllowed() { return false; }, - - async charge() { - // Process payment charge - return { transactionId: '...' }; - }, - async confirm() { - // Confirm payment - return { transactionId: '...' }; - }, - async cancel() { - // Cancel payment - return true; - }, - async register() { - // Register payment method - return { token: '...' }; - }, - async sign() { - // Sign payment request - return '...'; - }, - async validate(token) { - // Validate payment token - return true; - }, - }; - }, -}; - -PaymentDirector.registerAdapter(MyPaymentAdapter); -``` - -### Delivery Director - -Manages delivery operations and coordinates shipping adapters. - -```typescript -import { DeliveryDirector, type IDeliveryAdapter } from '@unchainedshop/core'; - -const MyDeliveryAdapter: IDeliveryAdapter = { - key: 'my-delivery', - label: 'My Shipping Provider', - version: '1.0.0', - - typeSupported(type) { - return type === 'SHIPPING'; - }, - - actions(config, context) { - return { - configurationError() { return null; }, - isActive() { return true; }, - isAutoReleaseAllowed() { return false; }, - - async send() { - // Trigger delivery - return { trackingNumber: '...' }; - }, - estimatedDeliveryThroughput(warehousingTime) { - // Return estimated delivery time in ms - return 3 * 24 * 60 * 60 * 1000; // 3 days - }, - async pickUpLocations() { - // Return available pickup locations - return []; - }, - async pickUpLocationById(locationId) { - return null; - }, - }; - }, -}; - -DeliveryDirector.registerAdapter(MyDeliveryAdapter); -``` +interface MyInput { message: string; } +interface MyOutput { processed: boolean; } -### Warehousing Director - -Manages inventory and stock operations, including NFT/token support. - -```typescript -import { WarehousingDirector, type IWarehousingAdapter } from '@unchainedshop/core'; - -const MyWarehousingAdapter: IWarehousingAdapter = { - key: 'my-warehouse', - label: 'My Inventory System', - version: '1.0.0', - - typeSupported(type) { - return type === 'PHYSICAL'; - }, - - actions(config, context) { - return { - configurationError() { return null; }, - isActive() { return true; }, - - async stock(referenceDate) { - // Return current stock quantity - return 100; - }, - async productionTime(quantity) { - // Return production time in ms - return 0; - }, - async commissioningTime(quantity) { - // Return commissioning time in ms - return 24 * 60 * 60 * 1000; // 1 day - }, - async estimatedStock() { - return 100; - }, - async estimatedDispatch() { - return new Date(); - }, - // For tokenized products (NFTs): - async tokenize() { return []; }, - async tokenMetadata(serial, date) { return {}; }, - async isInvalidateable(serial, date) { return false; }, - }; - }, -}; - -WarehousingDirector.registerAdapter(MyWarehousingAdapter); -``` - -### Worker Director - -Manages background job processing and scheduled tasks. - -```typescript -import { WorkerDirector, type IWorkerAdapter } from '@unchainedshop/core'; - -interface MyInput { email: string; subject: string; } -interface MyOutput { messageId: string; } - -const MyWorkerAdapter: IWorkerAdapter = { +const MyWorker: IWorkerAdapter = { + ...WorkerAdapter, key: 'my-worker', label: 'My Background Worker', version: '1.0.0', - type: 'MY_WORK_TYPE', // Work type identifier - external: false, // Runs in-process - maxParallelAllocations: 10, // Max concurrent executions - - async doWork(input, unchainedAPI, workId) { - // Process the work item - const { email, subject } = input; - - // Return result - return { - success: true, - result: { messageId: 'msg-123' }, - }; + type: 'MY_WORK_TYPE', + maxParallelAllocations: 10, + + async doWork(input) { + console.log(input.message); + return { success: true, result: { processed: true } }; }, }; -WorkerDirector.registerAdapter(MyWorkerAdapter); - -// Schedule recurring work +WorkerDirector.registerAdapter(MyWorker); WorkerDirector.configureAutoscheduling({ type: 'MY_WORK_TYPE', - input: { email: 'test@example.com', subject: 'Test' }, - schedule: '0 * * * *', // Every hour (cron syntax) + input: async () => ({ message: 'Scheduled work' }), + schedule: schedule.parse.cron('0 * * * *'), }); ``` -### Pricing Directors - -Pricing directors calculate prices using a chain of adapters. Each adapter can add, modify, or discount prices. - -```typescript -import { ProductPricingDirector, type IProductPricingAdapter } from '@unchainedshop/core'; - -const MyPricingAdapter: IProductPricingAdapter = { - key: 'my-pricing', - label: 'My Pricing Logic', - version: '1.0.0', - orderIndex: 10, // Lower numbers run first - - isActivatedFor(context) { - // Return true if this adapter should apply - return true; - }, - - actions(params) { - return { - calculate() { - // Add price calculations - this.calculation.addItem({ - category: 'BASE', - amount: 1000, // in smallest currency unit - }); - }, - }; - }, -}; - -ProductPricingDirector.registerAdapter(MyPricingAdapter); -``` - -### Discount Directors - -Discount directors manage coupon codes and automatic discounts. - -```typescript -import { OrderDiscountDirector, type IDiscountAdapter } from '@unchainedshop/core'; - -const MyDiscountAdapter: IDiscountAdapter = { - key: 'my-discount', - label: 'My Discount System', - version: '1.0.0', - orderIndex: 10, - - isManualAdditionAllowed(code) { - return code.startsWith('PROMO'); - }, - isManualRemovalAllowed() { - return true; - }, - - actions(context) { - return { - isValidForSystemTriggering() { - // Auto-apply discount? - return false; - }, - isValidForCodeTriggering(code) { - // Apply when code entered? - return code === 'PROMO10'; - }, - discountForPricingAdapterKey(params) { - // Return discount configuration for pricing adapter - return { - isNetPrice: false, - rate: 0.1, // 10% off - }; - }, - async reserve(code) { - // Reserve discount (e.g., decrement coupon balance) - }, - async release() { - // Release reservation on order cancellation - }, - }; - }, -}; - -OrderDiscountDirector.registerAdapter(MyDiscountAdapter); -``` - -### Filter Director - -Manages product filtering and search functionality. - -```typescript -import { FilterDirector, type IFilterAdapter } from '@unchainedshop/core'; - -const MyFilterAdapter: IFilterAdapter = { - key: 'my-filter', - label: 'My Search Filter', - version: '1.0.0', - orderIndex: 10, - - actions(context) { - return { - async aggregateProductIds(params) { - // Return product IDs matching filter - return ['product-1', 'product-2']; - }, - async searchProducts(params, options) { - // Search products - return { productIds: [], totalCount: 0 }; - }, - async searchAssortments(params, options) { - // Search assortments - return { assortmentIds: [], totalCount: 0 }; - }, - transformProductSelector(selector, options) { - // Modify MongoDB product selector - return selector; - }, - transformFilterSelector(selector, options) { - // Modify MongoDB filter selector - return selector; - }, - transformSortStage(sort, options) { - // Modify MongoDB sort stage - return sort; - }, - }; - }, -}; - -FilterDirector.registerAdapter(MyFilterAdapter); -``` +For complete contracts and implementations, see the [director and adapter sources](src/directors) and [official plugins](../plugins/README.md). Payment and delivery adapters receive `(configuration, context)` in `actions`; pricing, discount, filter, quotation, and enrollment adapters each have their own contracts. Their base adapters supply the required defaults. -### Messaging Director +### Messaging Templates -The Messaging Director uses a template resolver pattern instead of traditional adapters. +`MessagingDirector.registerTemplate` registers a resolver that returns worker inputs. The platform passes message data such as `orderId` and `locale`; the resolver can load records through the supplied API. ```typescript import { MessagingDirector } from '@unchainedshop/core'; -// Register a message template -MessagingDirector.registerTemplate('ORDER_CONFIRMATION', async (context) => { - const { order, user } = context; - - return [ - { - type: 'EMAIL', - input: { - to: user.email, - subject: `Order Confirmation #${order.orderNumber}`, - html: '

Thank you for your order!

', - }, +MessagingDirector.registerTemplate('CUSTOM_NOTIFICATION', async ({ recipient }) => [ + { + type: 'EMAIL', + input: { + to: recipient, + subject: 'Notification', + text: 'Your update is ready.', }, - { - type: 'SMS', - input: { - to: user.phone, - text: `Order #${order.orderNumber} confirmed!`, - }, - }, - ]; -}); -``` - -### Quotation Director - -Handles quotation/RFQ (Request for Quote) operations. - -```typescript -import { QuotationDirector, type IQuotationAdapter } from '@unchainedshop/core'; - -const MyQuotationAdapter: IQuotationAdapter = { - key: 'my-quotation', - label: 'My Quote System', - version: '1.0.0', - - isActivatedFor(quotationContext, unchainedAPI) { - return true; }, - - actions(context) { - return { - configurationError() { return null; }, - isManualProposalRequired() { return true; }, - isManualRequestVerificationRequired() { return false; }, - - async quote() { - // Generate quote - return { price: 1000, currency: 'CHF' }; - }, - async submitRequest(quotationContext) { - // Submit RFQ - }, - async verifyRequest(quotationContext) { - // Verify RFQ - }, - async rejectRequest(quotationContext) { - // Reject RFQ - }, - transformItemConfiguration(params) { - return params.configuration; - }, - }; - }, -}; - -QuotationDirector.registerAdapter(MyQuotationAdapter); +]); ``` -### Enrollment Director - -Manages subscription/enrollment plans and recurring billing. - -```typescript -import { EnrollmentDirector, type IEnrollmentAdapter } from '@unchainedshop/core'; - -const MyEnrollmentAdapter: IEnrollmentAdapter = { - key: 'my-enrollment', - label: 'My Subscription System', - version: '1.0.0', - - isActivatedFor(productPlan) { - return productPlan.type === 'PLAN_PRODUCT'; - }, - - transformOrderItemToEnrollmentPlan(orderPosition, unchainedAPI) { - return { - configuration: orderPosition.configuration, - }; - }, - - actions(context) { - return { - async nextPeriod() { - // Calculate next billing period - return { - start: new Date(), - end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - }; - }, - isValidForActivation() { - return true; - }, - isOverdue() { - return false; - }, - async configurationForOrder(period) { - // Return order configuration for period - return {}; - }, - }; - }, -}; - -EnrollmentDirector.registerAdapter(MyEnrollmentAdapter); -``` +An `EMAIL` worker adapter must be registered to deliver these messages. ### Bulk Importer | Method | Description | |--------|-------------| -| `bulkImporter.prepare` | Prepare bulk import for entity type | -| `bulkImporter.process` | Process prepared import data | +| `bulkImporter.createBulkImporter` | Create a batch importer with options | +| `importer.validate` | Validate an import event | +| `importer.prepare` | Prepare an event with the core API | +| `importer.execute` | Execute the prepared database operations | +| `bulkImporter.validateEventStream` | Validate a JSON event stream | +| `bulkImporter.pipeEventStream` | Prepare events from a JSON stream | ### Types @@ -583,7 +190,6 @@ EnrollmentDirector.registerAdapter(MyEnrollmentAdapter); |--------|-------------| | `UnchainedCore` | Core instance type | | `UnchainedCoreOptions` | Initialization options | -| `Modules` | All modules type | | `Services` | All services type | | `BulkImporter` | Bulk importer type | diff --git a/packages/core/src/directors/PaymentAdapter.ts b/packages/core/src/directors/PaymentAdapter.ts index de3f0d8cdf..8d4a16635e 100644 --- a/packages/core/src/directors/PaymentAdapter.ts +++ b/packages/core/src/directors/PaymentAdapter.ts @@ -43,7 +43,7 @@ export interface PaymentContext { userId?: string; order?: Order; orderPayment?: OrderPayment; - transactionContext?: any; // User for singing and charging a payment + transactionContext?: any; // Used for signing and charging a payment token?: any; // Used for validation meta?: any; } @@ -85,7 +85,7 @@ export const PaymentAdapter: Omit }, charge: async () => { - // if you return true, the status will be changed to PAID + // Return a charge result object to mark the payment as PAID. // if you return false, the order payment status stays the // same but the order status might change diff --git a/packages/core/src/directors/WorkerDirector.ts b/packages/core/src/directors/WorkerDirector.ts index 91fdc31e9e..fc37218c9f 100644 --- a/packages/core/src/directors/WorkerDirector.ts +++ b/packages/core/src/directors/WorkerDirector.ts @@ -106,7 +106,7 @@ export const WorkerDirector: IWorkerDirector = { const output = await adapter.doWork(input ?? {}, unchainedAPI, workId); return output; } catch (error) { - // DO not use this as flow control. The adapter should catch expected errors and return status: FAILED + // The adapter should catch expected errors and return { success: false, error }. logger.debug('DO not use this as flow control.'); logger.error(`WorkerDirector -> Error doing work ${type}: ${error.message}`); diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 0e733817e6..d2567fac59 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -63,7 +63,7 @@ import { createFileDownloadURLService } from './createFileDownloadURL.ts'; import { resolveTokenStatusService } from './resolveTokenStatus.ts'; import { isTokenInvalidateableService } from './isTokenInvalidateable.ts'; -// Auto-Inject Unchained API as last parameter +// Bind service methods to the modules object as their `this` context. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy function bindMethodsToModules(modules: Modules) { diff --git a/packages/core/src/utils/schedule.ts b/packages/core/src/utils/schedule.ts index 85430f4edf..c689c9bda6 100644 --- a/packages/core/src/utils/schedule.ts +++ b/packages/core/src/utils/schedule.ts @@ -18,14 +18,14 @@ export interface ScheduleData { D?: number[]; /** Months (1-12) */ M?: number[]; - /** Days of week (1-7, where 1 is Sunday) */ + /** Days of week (0-6, where 0 is Sunday) */ d?: number[]; }[]; } /** - * Parse a cron expression into ScheduleData - * Supports standard 5-field cron: minute hour day-of-month month day-of-week + * Parse one numeric cron field into its allowed values. + * Supports wildcards, comma-separated values, ranges, and steps. */ function parseCronField(field: string, min: number, max: number): number[] | undefined { if (field === '*') return undefined; // undefined means "all values" diff --git a/packages/events/README.md b/packages/events/README.md index 5fe0faba30..c4ab6984db 100644 --- a/packages/events/README.md +++ b/packages/events/README.md @@ -14,7 +14,15 @@ npm install @unchainedshop/events ## Usage ```typescript -import { emit, subscribe, registerEvents } from '@unchainedshop/events'; +import { EventEmitter } from 'node:events'; +import { emit, subscribe, registerEvents, setEmitAdapter } from '@unchainedshop/events'; + +// Configure delivery before subscribing (the platform normally configures an adapter). +const emitter = new EventEmitter(); +setEmitAdapter({ + publish: (name, data) => { emitter.emit(name, data); }, + subscribe: (name, callback) => { emitter.on(name, callback); }, +}); // Register custom events registerEvents(['ORDER_CREATED', 'ORDER_PAID']); diff --git a/packages/file-upload/README.md b/packages/file-upload/README.md index cacb52b328..d6b827f633 100644 --- a/packages/file-upload/README.md +++ b/packages/file-upload/README.md @@ -105,27 +105,27 @@ const S3Adapter: IFileAdapter = { version: '1.0.0', async createSignedURL(directoryName, fileName, unchainedAPI) { - // Generate presigned S3 URL + throw new Error('Implement: Generate presigned S3 URL'); }, async createDownloadURL(file, expiry) { - // Generate presigned download URL + throw new Error('Implement: Generate presigned download URL'); }, async uploadFileFromStream(directoryName, rawFile, unchainedAPI) { - // Stream upload to S3 + throw new Error('Implement: Stream upload to S3'); }, async removeFiles(files, unchainedContext) { - // Delete objects from S3 + throw new Error('Implement: Delete objects from S3'); }, async createDownloadStream(file, unchainedAPI) { - // Return S3 object as stream + throw new Error('Implement: Return S3 object as stream'); }, async uploadFileFromURL(directoryName, fileInput, unchainedAPI) { - // Fetch and upload from URL + throw new Error('Implement: Fetch and upload from URL'); }, }; ``` diff --git a/packages/logger/README.md b/packages/logger/README.md index ba582552d2..48bde8e907 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -9,7 +9,7 @@ A high-performance, feature-rich logging package for Unchained Engine with suppo - 🔍 **Debug Patterns**: Flexible DEBUG environment variable with wildcards and exclusions - 📊 **Log Levels**: Five log levels (trace, debug, info, warn, error) with environment-based filtering - 💪 **TypeScript**: Full TypeScript support with type definitions -- 🔧 **Zero Dependencies**: Core functionality with minimal external dependencies +- 🔧 **Zero Dependencies**: Uses Node.js built-ins without runtime npm dependencies ## Installation diff --git a/packages/logger/benchmarks/README.md b/packages/logger/benchmarks/README.md index c420f84e4f..d3f43c228a 100644 --- a/packages/logger/benchmarks/README.md +++ b/packages/logger/benchmarks/README.md @@ -1,8 +1,8 @@ # Logger Performance Benchmarks -Run with: `npm run benchmark` +Run from the repository root with: `npm run benchmark --workspace @unchainedshop/logger` -## Latest Results (October 7, 2025) +## Recorded Results (October 7, 2025) ``` Test Name | Ops/sec | Avg Time | Total Time @@ -17,7 +17,9 @@ Debug log (disabled) | 299.36M | 0.00 µs | 0.33 ms Pattern matching (createLogger) | 1.21M | 0.82 µs | 4.12 ms ``` -## Key Insights +These figures are a historical snapshot, not measurements of the current checkout. Run the benchmark locally before comparing performance. + +## Snapshot Insights - JSON format logging is **550.6% slower** than default format - Debug logging when disabled is **35.9x faster** (skips log processing entirely) @@ -29,4 +31,4 @@ Pattern matching (createLogger) | 1.21M | 0.82 µs | 4.12 ms The current implementation includes targeted optimizations: - **Regex pattern caching**: Compiled RegExp objects are cached to avoid recreation - **Pattern result caching**: DEBUG pattern matching results are cached per module -- **No-op functions**: Disabled log levels return empty functions for zero-cost logging \ No newline at end of file +- **No-op functions**: Disabled log levels return empty functions for zero-cost logging diff --git a/packages/logger/src/createLogger.ts b/packages/logger/src/createLogger.ts index 2524109578..2055779368 100644 --- a/packages/logger/src/createLogger.ts +++ b/packages/logger/src/createLogger.ts @@ -4,7 +4,7 @@ import { LogLevel } from './logger.types.ts'; /** * Performance optimization: Cache compiled regex patterns to avoid recreating them - * on every DEBUG pattern match. This provides ~190% improvement in pattern matching. + * on every DEBUG pattern match. */ const regexCache = new Map(); @@ -16,12 +16,12 @@ const debugPatternCache = new Map(); /** * Escapes all regex special characters except asterisk (*) which is used for wildcards. - * This prevents ReDoS attacks from malicious DEBUG patterns. + * User input is treated as literal text apart from wildcard expansion. */ const escapeRegexForDebug = (pattern: string): string => { // Escape all regex special characters except * (which we convert to .*) // Special chars: . + ? ^ $ { } ( ) | [ ] \ - // We also escape - but allow it as literal + // Hyphens are literal outside character classes and need no escaping. return pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars .replace(/\*/g, '.*'); // Convert * to .* @@ -32,7 +32,7 @@ const escapeRegexForDebug = (pattern: string): string => { * Supports wildcards (*), exclusions (-pattern), and comma-separated lists. * Results are cached for performance. * - * Security: Patterns are escaped to prevent ReDoS attacks. + * Regex metacharacters are escaped before wildcards are expanded. */ const debugStringContainsModule = (debugString: string, moduleName: string): boolean => { if (!debugString) return false; @@ -55,7 +55,7 @@ const debugStringContainsModule = (debugString: string, moduleName: string): boo const isExclusion = trimmedName.startsWith('-'); const patternToMatch = isExclusion ? trimmedName.slice(1) : trimmedName; - // Escape the pattern to prevent ReDoS, then convert * to .* + // Escape regex metacharacters, then convert * to .* const safePattern = escapeRegexForDebug(patternToMatch); regExp = new RegExp(`^${safePattern}$`); regexCache.set(trimmedName, regExp); diff --git a/packages/mongodb/README.md b/packages/mongodb/README.md index 150431691e..83b08bab95 100644 --- a/packages/mongodb/README.md +++ b/packages/mongodb/README.md @@ -14,21 +14,17 @@ npm install @unchainedshop/mongodb ## Usage ```typescript -import { initDb, startDb, stopDb, generateDbObjectId } from '@unchainedshop/mongodb'; +import { initDb, stopDb, generateDbObjectId } from '@unchainedshop/mongodb'; -// Initialize the database connection -const db = await initDb({ - connectionString: 'mongodb://localhost:27017/unchained', -}); +// Connect using MONGO_URL, or start a local MongoDB through mongodb-memory-server. +process.env.MONGO_URL = 'mongodb://localhost:27017/unchained'; +const db = await initDb(); -// Start the database -await startDb(db); - -// Generate a new ObjectId +// Generate a new string ID const id = generateDbObjectId(); // Stop the database when shutting down -await stopDb(db); +await stopDb(); ``` ## API Overview @@ -37,15 +33,16 @@ await stopDb(db); | Export | Description | |--------|-------------| -| `initDb` | Initialize MongoDB connection with connection string | -| `startDb` | Start the database connection | +| `createDatabaseResource` | Connect with automatic cleanup via `await using` | +| `initDb` | Connect using MONGO_URL or a managed local MongoDB instance | +| `startDb` | Start a managed local MongoDB instance and return its URL | | `stopDb` | Close the database connection | ### Query Utilities | Export | Description | |--------|-------------| -| `generateDbObjectId` | Generate a new MongoDB ObjectId | +| `generateDbObjectId` | Generate a random 24-digit hexadecimal string ID | | `generateDbFilterById` | Create a filter object for querying by ID | | `buildDbIndexes` | Create indexes for a collection | | `findPreservingIds` | Find documents while preserving ID order | @@ -82,7 +79,7 @@ Unchained uses the following collection naming patterns: | Plural lowercase | `products`, `orders`, `users` | Main entity collections | | Underscore-separated | `product_texts`, `product_media` | Related sub-collections | -**Note:** Some legacy collections may use different patterns. When creating new collections, prefer the underscore-separated pattern for sub-collections. +**Note:** Provider collections use hyphens (`payment-providers`, `delivery-providers`, `warehousing-providers`), and the assortment cache is `assortment_productId_cache`. Follow the existing collection names when querying or migrating data. ### Index Guidelines @@ -106,20 +103,20 @@ await buildDbIndexes(Products, [ Collections using soft delete should always include a `deleted` index: ```typescript -{ index: { deleted: 1 } } +const deletedIndex = { index: { deleted: 1 } }; ``` Queries should filter by `deleted: null` to exclude soft-deleted documents. #### Sparse Indexes -Use sparse indexes when the indexed field may be null/undefined for most documents: +Use sparse indexes when the indexed field is absent from most documents. Documents with an explicit `null` value are still indexed: ```typescript -{ +const sparseIndex = { index: { optionalField: 1 }, - options: { sparse: true } -} + options: { sparse: true }, +}; ``` Sparse indexes are smaller and more efficient when the field is rarely present. @@ -129,7 +126,7 @@ Sparse indexes are smaller and more efficient when the field is rarely present. For full-text search, create compound text indexes: ```typescript -{ +const textIndex = { index: { _id: 'text', name: 'text', @@ -143,10 +140,10 @@ For full-text search, create compound text indexes: }, name: 'fulltext_search', }, -} +}; ``` -**Note:** Text indexes are supported natively on MongoDB 4.4+, AWS DocumentDB 5.0+, and FerretDB 2.x. If you target AWS DocumentDB ≤4.0 or FerretDB 1.x, use an external search service instead (Elasticsearch, Algolia, Meilisearch). +The built-in search relies on MongoDB text indexes and `$text` queries. Verify these operations against your chosen MongoDB-compatible backend before deploying; index creation and query compatibility are separate requirements. ## License diff --git a/packages/mongodb/src/build-db-indexes.ts b/packages/mongodb/src/build-db-indexes.ts index 1a47433330..d6a983e3bd 100644 --- a/packages/mongodb/src/build-db-indexes.ts +++ b/packages/mongodb/src/build-db-indexes.ts @@ -1,8 +1,5 @@ -// TODO: Document collection naming conventions across the codebase: -// - Some use singular (e.g., 'products'), some use underscores (e.g., 'product_texts') -// - Consider standardizing in a major version -// TODO: Consider adding sparse index usage documentation -// TODO: Add DocumentDB compatibility checks documentation +// Collection naming conventions and sparse index examples are documented in ../README.md. +// MongoDB-compatible backends must support createIndex and the index types requested by each collection. import type { Collection, Document, CreateIndexesOptions, IndexSpecification } from 'mongodb'; import { createLogger } from '@unchainedshop/logger'; diff --git a/packages/mongodb/src/generate-db-object-id.ts b/packages/mongodb/src/generate-db-object-id.ts index a2dd4e5936..dcde371f8b 100644 --- a/packages/mongodb/src/generate-db-object-id.ts +++ b/packages/mongodb/src/generate-db-object-id.ts @@ -1,13 +1,9 @@ -/** - * @name Random.hexString - * @summary Return a random string of `n` hexadecimal digits. - * @locus Anywhere - * @param {Number} n Length of the string - */ +/** Convert a byte buffer to lowercase hexadecimal. */ function toHex(buffer) { return Array.prototype.map.call(buffer, (x) => x.toString(16).padStart(2, '0')).join(''); } +/** Generate a random hexadecimal string; defaults to 24 digits. */ export const generateDbObjectId = (digits = 24): string => { const numBytes = Math.ceil(digits / 2); const bytes = crypto.getRandomValues(new Uint8Array(numBytes)); diff --git a/packages/platform/README.md b/packages/platform/README.md index 1cea67fc3f..a01c3b1f28 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -15,18 +15,19 @@ npm install @unchainedshop/platform ```typescript import { startPlatform } from '@unchainedshop/platform'; +import { connect } from '@unchainedshop/api/express'; import express from 'express'; const app = express(); -const { unchainedAPI, graphqlHandler } = await startPlatform({ - express: app, +const engine = await startPlatform({ options: { // Platform options }, }); -// Platform is ready +// Mount GraphQL, sessions, uploads, and optional integrations +connect(app, engine); app.listen(4010); ``` @@ -40,20 +41,11 @@ app.listen(4010); | `runMigrations` | Run database migrations | | `printRuntimeConfiguration` | Log registered templates, events, and adapters | -### Context Helpers - -| Export | Description | -|--------|-------------| -| `setAccessToken` | Set access token for user session | -| `getAccessToken` | Get current access token | -| `invalidateAccessToken` | Invalidate/logout access token | - ### Templates | Export | Description | |--------|-------------| | `MessageTypes` | Available message/notification types | -| `setupTemplates` | Register message templates | ### Message Types @@ -65,65 +57,41 @@ app.listen(4010); | `ORDER_REJECTION` | Order rejection notifications | | `QUOTATION_STATUS` | Quotation status updates | | `ENROLLMENT_STATUS` | Subscription status updates | -| `FORWARD_DELIVERY` | Forward delivery notifications | - -## Quick Start - -```typescript -import express from 'express'; -import { startPlatform, MessageTypes } from '@unchainedshop/platform'; - -const app = express(); - -const { unchainedAPI, graphqlHandler } = await startPlatform({ - express: app, - options: { - modules: { - // Module-specific configuration - }, - plugins: [ - // Plugin imports - ], - }, - workQueueConfig: { - // Worker queue configuration - }, -}); +| `ERROR_REPORT` | Worker error reports | -// Access unchained API -const products = await unchainedAPI.modules.products.findProducts({}); +## Configuration -// Start server -app.use('/graphql', graphqlHandler); -app.listen(4010); -``` +Set `EMAIL_WEBSITE_NAME`, `EMAIL_WEBSITE_URL`, `EMAIL_FROM`, `ROOT_URL`, and `UNCHAINED_TOKEN_SECRET` before starting. The token secret must contain at least 32 characters. Set `MONGO_URL` to use an external MongoDB instance. -## Configuration +Module options are keyed directly by module name under `options`. Custom module/service implementations and bulk import handlers are top-level properties. Import plugins before starting the platform. ```typescript -const platform = await startPlatform({ - express: app, +import { schedule } from '@unchainedshop/core'; +import '@unchainedshop/plugins/worker/email.js'; + +const engine = await startPlatform({ options: { - modules: { - orders: { - // Order module options - }, - products: { - // Product module options - }, + orders: { + // Order module settings }, - services: { - // Custom services + products: { + // Product module settings }, - bulkImporter: { - handlers: { - // Custom import handlers - }, + }, + modules: { + // Custom module factories + }, + services: { + // Custom service functions + }, + bulkImporter: { + handlers: { + // Custom import handlers }, }, - workQueueConfig: { - batchSize: 10, - pollInterval: 1000, + workQueueOptions: { + batchCount: 10, + schedule: schedule.parse.text('every 2 seconds'), }, context: (defaultResolver) => async (props, req, res) => { const context = await defaultResolver(props, req, res); @@ -133,8 +101,13 @@ const platform = await startPlatform({ }; }, }); + +connect(app, engine, { adminUI: true }); +const products = await engine.unchainedAPI.modules.products.findProducts({}); ``` +`startPlatform` initializes the core and GraphQL handler; call the Express or Fastify `connect` adapter to mount HTTP routes. Queue managers and migrations start during setup unless the worker is disabled. Process signal and error handlers stop the queue, dispose the GraphQL handler, and close the database during shutdown. + ## Returns The `startPlatform` function returns: diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 9d40579fa5..6d3215c28a 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -17,94 +17,95 @@ npm install @unchainedshop/plugins | Plugin | Import Path | Description | |--------|-------------|-------------| -| Invoice | `payment/invoice` | Simple invoice-based payment | -| Invoice Prepaid | `payment/invoice-prepaid` | Prepaid invoice payment | -| Stripe | `payment/stripe` | Stripe payment integration | -| Datatrans V2 | `payment/datatrans-v2` | Datatrans payment gateway | -| Saferpay | `payment/saferpay` | Saferpay payment gateway | -| PayPal Checkout | `payment/paypal-checkout` | PayPal Checkout integration | -| Braintree | `payment/braintree` | Braintree payments | -| Payrexx | `payment/payrexx` | Payrexx payment gateway | -| Apple IAP | `payment/apple-iap` | Apple In-App Purchase | -| Cryptopay | `payment/cryptopay` | Cryptocurrency payments | +| Invoice | `payment/invoice.js` | Simple invoice-based payment | +| Invoice Prepaid | `payment/invoice-prepaid.js` | Prepaid invoice payment | +| Stripe | `payment/stripe/index.js` | Stripe payment integration | +| Datatrans V2 | `payment/datatrans-v2/index.js` | Datatrans payment gateway | +| Saferpay | `payment/saferpay/index.js` | Saferpay payment gateway | +| PayPal Checkout | `payment/paypal-checkout.js` | PayPal Checkout integration | +| Braintree | `payment/braintree.js` | Braintree payments | +| Payrexx | `payment/payrexx/index.js` | Payrexx payment gateway | +| Apple IAP | `payment/apple-iap/index.js` | Apple In-App Purchase | +| Cryptopay | `payment/cryptopay/index.js` | Cryptocurrency payments | ### Delivery Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Post | `delivery/post` | Standard postal delivery | -| Stores | `delivery/stores` | Store pickup delivery | -| Send Message | `delivery/send-message` | Digital delivery via messaging | +| Post | `delivery/post.js` | Standard postal delivery | +| Stores | `delivery/stores.js` | Store pickup delivery | +| Send Message | `delivery/send-message.js` | Digital delivery via messaging | ### Pricing Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Product Catalog Price | `pricing/product-catalog-price` | Base catalog pricing | -| Product Price Rate Conversion | `pricing/product-price-rateconversion` | Currency conversion | -| Product Round | `pricing/product-round` | Price rounding | -| Product Discount | `pricing/product-discount` | Product-level discounts | -| Order Items | `pricing/order-items` | Order item pricing | -| Order Delivery | `pricing/order-delivery` | Delivery pricing | -| Order Payment | `pricing/order-payment` | Payment fee pricing | -| Order Discount | `pricing/order-discount` | Order-level discounts | -| Order Round | `pricing/order-round` | Order total rounding | -| Free Delivery | `pricing/free-delivery` | Free delivery conditions | -| Free Payment | `pricing/free-payment` | Free payment processing | -| Swiss Tax (CH) | `pricing/tax/ch` | Swiss VAT calculation | +| Product Catalog Price | `pricing/product-catalog-price.js` | Base catalog pricing | +| Product Price Rate Conversion | `pricing/product-price-rateconversion.js` | Currency conversion | +| Product Round | `pricing/product-round.js` | Price rounding | +| Product Discount | `pricing/product-discount.js` | Product-level discounts | +| Order Items | `pricing/order-items.js` | Order item pricing | +| Order Delivery | `pricing/order-delivery.js` | Delivery pricing | +| Order Payment | `pricing/order-payment.js` | Payment fee pricing | +| Order Discount | `pricing/order-discount.js` | Order-level discounts | +| Order Round | `pricing/order-round.js` | Order total rounding | +| Free Delivery | `pricing/free-delivery.js` | Free delivery conditions | +| Free Payment | `pricing/free-payment.js` | Free payment processing | +| Swiss Product Tax | `pricing/product-swiss-tax.js` | Swiss VAT on products | +| Swiss Delivery Tax | `pricing/delivery-swiss-tax.js` | Swiss VAT on delivery | ### Filter Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Strict Equal | `filters/strict-equal` | Exact match filtering | -| Local Search | `filters/local-search` | Full-text search | +| Strict Equal | `filters/strict-equal.js` | Exact match filtering | +| Local Search | `filters/local-search.js` | Full-text search | ### File Storage Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| GridFS | `files/gridfs` | MongoDB GridFS storage | -| MinIO | `files/minio` | MinIO/S3-compatible storage | +| GridFS | `files/gridfs/index.js` | MongoDB GridFS storage | +| MinIO | `files/minio/index.js` | MinIO/S3-compatible storage | ### Worker Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Email | `worker/email` | Email sending worker | -| Heartbeat | `worker/heartbeat` | Keep-alive heartbeat | -| HTTP Request | `worker/http-request` | HTTP request worker | -| Bulk Import | `worker/bulk-import` | Bulk data import | -| External | `worker/external` | External service calls | -| Twilio | `worker/twilio` | Twilio SMS integration | -| Push Notification | `worker/push-notification` | Push notifications | -| Update ECB Rates | `worker/update-ecb-rates` | ECB exchange rates | -| Update Coinbase Rates | `worker/update-coinbase-rates` | Crypto exchange rates | -| Update Token Ownership | `worker/update-token-ownership` | NFT ownership sync | -| Zombie Killer | `worker/zombie-killer` | Stale job cleanup | -| Error Notifications | `worker/error-notifications` | Error alerting | +| Email | `worker/email.js` | Email sending worker | +| Heartbeat | `worker/heartbeat.js` | Keep-alive heartbeat | +| HTTP Request | `worker/http-request.js` | HTTP request worker | +| Bulk Import | `worker/bulk-import.js` | Bulk data import | +| External | `worker/external.js` | External service calls | +| Twilio | `worker/twilio.js` | Twilio SMS integration | +| Push Notification | `worker/push-notification.js` | Push notifications | +| Update ECB Rates | `worker/update-ecb-rates.js` | ECB exchange rates | +| Update Coinbase Rates | `worker/update-coinbase-rates.js` | Crypto exchange rates | +| Update Token Ownership | `worker/update-token-ownership.js` | NFT ownership sync | +| Zombie Killer | `worker/zombie-killer.js` | Remove carts whose owners no longer exist | +| Error Notifications | `worker/error-notifications.js` | Error alerting | ### Event Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Node Event Emitter | `events/node-event-emitter` | Default Node.js emitter | -| Redis | `events/redis` | Redis pub/sub | -| AWS EventBridge | `events/aws-eventbridge` | AWS EventBridge | +| Node Event Emitter | `events/node-event-emitter.js` | Default Node.js emitter | +| Redis | `events/redis.js` | Redis pub/sub | +| AWS EventBridge | `events/aws-eventbridge.js` | AWS EventBridge | ### Warehousing Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Store | `warehousing/store` | Basic inventory | -| ETH Minter | `warehousing/eth-minter` | Ethereum NFT minting | +| Store | `warehousing/store.js` | Basic inventory | +| ETH Minter | `warehousing/eth-minter.js` | Ethereum NFT minting | ### Other Adapters | Plugin | Import Path | Description | |--------|-------------|-------------| -| Licensed Enrollment | `enrollments/licensed` | License-based subscriptions | -| Manual Quotation | `quotations/manual` | Manual quote handling | +| Licensed Enrollment | `enrollments/licensed.js` | License-based subscriptions | +| Manual Quotation | `quotations/manual.js` | Manual quote handling | ## Usage @@ -114,9 +115,9 @@ Import and register plugins during platform initialization: import { startPlatform } from '@unchainedshop/platform'; // Import specific plugins -import '@unchainedshop/plugins/payment/stripe'; -import '@unchainedshop/plugins/delivery/post'; -import '@unchainedshop/plugins/pricing/product-catalog-price'; +import '@unchainedshop/plugins/payment/stripe/index.js'; +import '@unchainedshop/plugins/delivery/post.js'; +import '@unchainedshop/plugins/pricing/product-catalog-price.js'; const platform = await startPlatform({ // ... @@ -125,38 +126,17 @@ const platform = await startPlatform({ ## Security -### Payment Plugin Security +Payment integrations use different mechanisms: Stripe uses PaymentIntent/SetupIntent references, PayPal uses order references, and Cryptopay derives wallet addresses. Payment compliance and cryptography requirements depend on the plugins and deployment; see [SECURITY.md](../../SECURITY.md). -All payment plugins implement secure tokenization patterns for PCI DSS SAQ-A eligibility: +Install the optional peer dependencies needed by your selected plugins. Import paths include `.js` (and `/index.js` for directory entry points) because the package exports compiled files directly. -| Plugin | Security Method | -|--------|-----------------| -| Stripe | PaymentIntent/SetupIntent tokenization | -| Datatrans | Secure Fields with HMAC-SHA-256 signatures | -| Saferpay | Redirect with SHA-256 transaction signatures | -| PayPal | Order ID references | -| Braintree | Client SDK tokenization | -| Cryptopay | BIP-32 HD wallet address derivation | +## PostFinance Checkout -**Signature Algorithms:** -- HMAC-SHA-256: Datatrans, Payrexx, GridFS file uploads -- HMAC-SHA-512: PostFinance Checkout -- SHA-256: Saferpay +The PostFinance Checkout plugin is included in compilation and uses the bundled API client. Import it with: -### FIPS 140-3 Compatibility - -All cryptographic operations use FIPS-approved algorithms. When deployed on FIPS-enabled Node.js (e.g., Chainguard node-fips), plugins operate in FIPS-compliant mode. - -See [SECURITY.md](../../SECURITY.md) for complete security documentation. - -## Notes - -### Postfinance Checkout Plugin - -Due to a TypeScript issue with the upstream "postfinancecheckout" package, the Postfinance plugin has been disabled from transpilation. To use it: -1. Import the source TypeScript files directly from `src` -2. Enable `node_modules` TypeScript compilation, or -3. Copy `src/payment/postfinance-checkout` to your project +```typescript +import '@unchainedshop/plugins/payment/postfinance-checkout/index.js'; +``` ## License diff --git a/packages/plugins/src/payment/cryptopay/README.md b/packages/plugins/src/payment/cryptopay/README.md index 562835ceda..527ed1baf5 100644 --- a/packages/plugins/src/payment/cryptopay/README.md +++ b/packages/plugins/src/payment/cryptopay/README.md @@ -1,6 +1,12 @@ # Cryptopay -curl -X POST http://localhost:4010/payment/cryptopay -d '{ "secret": "secret", "address": "0xF5F72AE7fa1fa990ebaF163208Ed7aD6a3f42DEA", "blockHeight": 7469853, "amount": "50000000000000000", "decimals": 18, "currency": "ETH"}' -H 'Content-Type: application/json' +With `CRYPTOPAY_SECRET` configured, send wallet updates in the `wallet` object: + +```bash +curl -X POST http://localhost:4010/payment/cryptopay \ + -H 'Content-Type: application/json' \ + -d '{"secret":"your-configured-secret","wallet":{"address":"0xF5F72AE7fa1fa990ebaF163208Ed7aD6a3f42DEA","blockHeight":7469853,"amount":"50000000000000000","decimals":18,"currencyCode":"ETH"}}' +``` ## HD Key Requirements diff --git a/packages/plugins/src/payment/paypal-checkout.ts b/packages/plugins/src/payment/paypal-checkout.ts index 26078f30c4..ff2660e7fb 100644 --- a/packages/plugins/src/payment/paypal-checkout.ts +++ b/packages/plugins/src/payment/paypal-checkout.ts @@ -26,7 +26,7 @@ const { PAYPAL_CLIENT_ID, PAYPAL_SECRET, PAYPAL_ENVIRONMENT = 'sandbox' } = proc /** * - * Set up and return PayPal JavaScript SDK environment with PayPal access credentials. + * Set up and return the PayPal server SDK client with the configured credentials. * This sample uses SandboxEnvironment. In production, use ProductionEnvironment. * */ diff --git a/packages/plugins/src/payment/stripe/README.md b/packages/plugins/src/payment/stripe/README.md index 1ae89bccb0..d50dd531c0 100644 --- a/packages/plugins/src/payment/stripe/README.md +++ b/packages/plugins/src/payment/stripe/README.md @@ -9,4 +9,4 @@ stripe trigger payment_intent.succeeded ## Configure the Payment Statements: -Payment Provider configuration: `[{ key: "descriptorPrefix", value: "Book Shop" }]` \ No newline at end of file +Payment Provider configuration: `[{ key: "descriptorPrefix", value: "Book Shop" }]` diff --git a/packages/roles/README.md b/packages/roles/README.md index 8cdd12d6ef..5e09223c32 100755 --- a/packages/roles/README.md +++ b/packages/roles/README.md @@ -18,6 +18,9 @@ npm install @unchainedshop/roles ```typescript import { Roles, Role } from '@unchainedshop/roles'; +// Register the action before adding an allow rule +Roles.registerAction('updateProduct'); + // Create a new role const editorRole = new Role('editor'); @@ -70,7 +73,7 @@ const allowed = await Roles.userHasPermission( | `__all__` | Automatically assigned to everyone | | `__loggedIn__` | Automatically assigned to authenticated users | | `__notLoggedIn__` | Automatically assigned to anonymous users | -| `__notAdmin__` | Automatically assigned to non-admin users | +| `__notAdmin__` | Automatically assigned to authenticated non-admin users | ### Utility Functions @@ -99,7 +102,7 @@ const config: IRoleOptionConfig = { additionalRoles: { customRole: (roles, actions) => { const role = new Role('customRole'); - role.allow(actions.READ, () => true); + role.allow(actions.CUSTOM_ACTION, async () => true); }, }, additionalActions: ['CUSTOM_ACTION'], diff --git a/packages/ticketing/README.md b/packages/ticketing/README.md index ea264e0821..974fdd81a2 100644 --- a/packages/ticketing/README.md +++ b/packages/ticketing/README.md @@ -14,9 +14,11 @@ npm install @unchainedshop/ticketing ## Usage ```typescript +import express from 'express'; import { startPlatform } from '@unchainedshop/platform'; -import setupTicketing, { ticketingModules, ticketingServices } from '@unchainedshop/ticketing'; +import setupTicketing, { ticketingModules, ticketingServices, type TicketingAPI } from '@unchainedshop/ticketing'; import { connect } from '@unchainedshop/api/express'; +import mountTicketRoutes from '@unchainedshop/ticketing/lib/express.js'; const app = express(); @@ -25,10 +27,11 @@ const engine = await startPlatform({ services: ticketingServices, }); -connect(app, engine, { corsOrigins: [] }); +connect(app, engine); +mountTicketRoutes(app); // Setup ticketing with your renderers -setupTicketing(engine.unchainedAPI, { +setupTicketing(engine.unchainedAPI as TicketingAPI, { renderOrderPDF, createAppleWalletPass, createGoogleWalletPass, @@ -56,8 +59,8 @@ setupTicketing(engine.unchainedAPI, { | Import Path | Description | |-------------|-------------| -| `@unchainedshop/ticketing/express` | Express route handlers | -| `@unchainedshop/ticketing/fastify` | Fastify route handlers | +| `@unchainedshop/ticketing/lib/express.js` | Express route handlers | +| `@unchainedshop/ticketing/lib/fastify.js` | Fastify route handlers | ### Renderer Types @@ -74,7 +77,7 @@ setupTicketing(engine.unchainedAPI, { | `TicketingAPI` | Ticketing API context type | | `TicketingModule` | Module interface type | | `TicketingServices` | Services interface type | -| `RendererTypes` | Renderer type constants | +| `RendererTypes` | Renderer type union; runtime constants are in the template registry | ## Apple Wallet Setup @@ -91,7 +94,6 @@ openssl pkcs12 -in Certificates.p12 -legacy -clcerts -out cert_and_key.pem ```bash PASS_CERTIFICATE_PATH=./cert_and_key.pem PASS_CERTIFICATE_SECRET=YOUR_PEM_PASSPHRASE -PASS_TEAM_ID=SSCB95CV6U ``` ## Renderer Implementation @@ -115,53 +117,34 @@ export default async ({ orderId, variant }, { modules }) => { }; ``` -### Apple Wallet Renderer +### Wallet Renderers -```typescript -import { Template, constants } from '@walletpass/pass-js'; - -export default async (token, unchainedAPI) => { - const template = new Template('eventTicket', /* ... */); - const pass = await template.createPass(/* ... */); - return pass; -}; -``` - -### Google Wallet Renderer +Supply renderers through `setupTicketing` or `setupMobileTickets`. Each receives `(token, unchainedAPI)` and returns an object with asynchronous `asURL()` and `asBuffer()` methods. Apple passes also provide `serialNumber` and `passTypeIdentifier`. Configure issuer/team identifiers and signing credentials in your renderer. -```typescript -import { google } from 'googleapis'; -import jwt from 'jsonwebtoken'; - -export default async (token, unchainedAPI) => { - // Upsert class and object - const asURL = async () => createJwtNewObjects(issuerId, productId, token.tokenSerialNumber); - return { asURL }; -}; -``` +The core package does not provide `createAppleWalletPass` or `createGoogleWalletPass` implementations. See the [renderer contracts](src/template-registry.ts) and the [ticketing example](../../examples/ticketing/README.md). ## Magic Key Order Access -Allow users to access orders and tickets without logging in via a one-time magic key: +Allow users to access orders and tickets without logging in via a deterministic magic key: ```typescript // Generate magic key const magicKey = await modules.passes.buildMagicKey(orderId); -// Use in URL: https://my-shop/:orderId?otp=:magicKey -// Send via x-magic-key HTTP header for API access +// Send via the x-magic-key HTTP header for API access ``` +The key is a SHA-256 digest of the order ID and `UNCHAINED_SECRET`. It is reusable and has no built-in expiration or consumption; rotating the secret changes all generated keys. + Protected actions: `viewOrder`, `updateToken`, `viewToken` ## Environment Variables | Variable | Description | |----------|-------------| -| `UNCHAINED_SECRET` | Required for magic key encryption | +| `UNCHAINED_SECRET` | Required for magic key derivation | | `PASS_CERTIFICATE_PATH` | Path to Apple pass certificate | | `PASS_CERTIFICATE_SECRET` | PEM passphrase | -| `PASS_TEAM_ID` | Apple Developer Team ID | ## License diff --git a/packages/utils/src/generate-random-hash.ts b/packages/utils/src/generate-random-hash.ts index 7c25733363..2eb8705e8e 100644 --- a/packages/utils/src/generate-random-hash.ts +++ b/packages/utils/src/generate-random-hash.ts @@ -1,6 +1,6 @@ import { randomInt } from 'node:crypto'; -// Unambiguous charset: no O/0, I/1 — matches the alphabet historically used for +// Alphabet excludes O, 0, and 1, and matches the characters historically used for // order/quotation/enrollment numbers, so new ids are visually indistinguishable // from existing ones. const ALPHABET = 'ABCDEFGHIJKLMNPQRSTUVWXYZ23456789'; diff --git a/tests/auth-webauthn.test.js b/tests/auth-webauthn.test.js index 1e1b4bfe00..3fdee9a597 100644 --- a/tests/auth-webauthn.test.js +++ b/tests/auth-webauthn.test.js @@ -180,8 +180,7 @@ class VirtualAuthenticator { sign.update(data); const derSignature = sign.sign(privateKey); - // Convert DER signature to raw r||s format for WebAuthn - // Then convert back to ASN.1 as that's what the library expects + // Return the ASN.1 DER signature encoded as base64url for WebAuthn return this.toBase64url(derSignature); } diff --git a/tests/seeds/users.js b/tests/seeds/users.js index 5c174fa528..978f02d5ae 100644 --- a/tests/seeds/users.js +++ b/tests/seeds/users.js @@ -1,5 +1,5 @@ // Access tokens: sha256 hash of the plain token -// Each user has a unique token for proper permission testing +// Admin, regular-user, and guest fixtures use separate permission-test tokens export const ADMIN_TOKEN = 'Bearer admin-secret'; export const USER_TOKEN = 'Bearer user-secret'; export const GUEST_TOKEN = 'Bearer guest-secret'; diff --git a/tests/user-remove.test.js b/tests/user-remove.test.js index 8bd5d6cf53..b4ca4c71c8 100644 --- a/tests/user-remove.test.js +++ b/tests/user-remove.test.js @@ -117,7 +117,7 @@ test.describe('User Removal', () => { const userId = createUser.user._id; // Set a known token secret for the new user so we can authenticate as them - // The token format is sha256(username:plainSecret) + // This fixture uses username:plainSecret as its opaque token and stores its SHA-256 hash const plainSecret = 'testsecret'; const crypto = await import('node:crypto'); const hashedSecret = crypto diff --git a/tools/demo-data-cli/README.md b/tools/demo-data-cli/README.md index c56ce8e1ea..0728109f2c 100644 --- a/tools/demo-data-cli/README.md +++ b/tools/demo-data-cli/README.md @@ -4,8 +4,8 @@ A CLI tool for populating demo data into an Unchained Engine e-commerce platform ## Features -- Generates 1000+ electronics store products with extensive multi-language descriptions -- Creates 40+ hierarchical category assortments (expandable) +- Generates electronics store products with extensive multi-language descriptions (default target: 1000) +- Creates 135 hierarchical category assortments - Defines 10 faceted filters for product navigation - Supports 3 languages: English, German, French - Uses 2 currencies: CHF and USD @@ -13,6 +13,8 @@ A CLI tool for populating demo data into an Unchained Engine e-commerce platform ## Installation +Use Node.js 26.8.2 or newer; the repository `.nvmrc` pins 26.8.2. + ```bash cd tools/demo-data-cli npm install @@ -24,14 +26,14 @@ npm run build ### Basic Usage ```bash -# With authentication token (the plainSecret passed to setAccessToken) +# With the token returned by modules.users.createAccessToken(username) node dist/index.js populateDemoData --token YOUR_AUTH_TOKEN # Using environment variable UNCHAINED_TOKEN=YOUR_AUTH_TOKEN node dist/index.js populateDemoData -# Example: If your server calls setAccessToken('admin', 'secret'), use: -node dist/index.js populateDemoData --token secret +# The kitchensink example logs a newly generated admin token at startup +node dist/index.js populateDemoData --token TOKEN_FROM_SERVER_LOG ``` ### Options @@ -40,7 +42,7 @@ node dist/index.js populateDemoData --token secret |--------|-------|---------|-------------| | `--endpoint ` | `-e` | `http://localhost:4010/bulk-import` | API endpoint URL | | `--token ` | `-t` | - | Bearer authentication token (required unless --dry-run) | -| `--products ` | `-p` | `1000` | Number of products to generate | +| `--products ` | `-p` | `1000` | Target number of products to generate | | `--chunk-size ` | `-c` | `500` | Events per API request | | `--dry-run` | `-d` | `false` | Generate JSON without sending to API | | `--output ` | `-o` | - | Write generated JSON to file | @@ -49,7 +51,7 @@ node dist/index.js populateDemoData --token secret ### Examples ```bash -# Generate 500 products and send to local server +# Request up to 500 products and send to the local server node dist/index.js populateDemoData -t YOUR_TOKEN -p 500 # Dry run with file output @@ -67,7 +69,9 @@ node dist/index.js populateDemoData -t YOUR_TOKEN -p 5000 -c 200 ## Generated Data -### Products (~1000+) +### Products + +The `--products` option sets a target. Templates without matching brands are skipped, so the actual count can be lower; the current default target of 1000 produces 912 products. The CLI reports the generated count. Electronics store products across categories: - Laptops (gaming, business, ultrabooks) @@ -90,7 +94,7 @@ Each product includes: - Weight specifications - Category and brand tags -### Assortments (~43) +### Assortments (135) Hierarchical category structure: ``` @@ -111,6 +115,9 @@ Electronics Store (root) |-- Gaming (Consoles, Accessories, Monitors) |-- Home Office (Printers, Routers, Storage) |-- Cameras (Digital, Action, Accessories) + |-- Smart Home + |-- Networking + |-- Components ``` ### Filters (10) @@ -118,7 +125,7 @@ Electronics Store (root) | Filter | Type | Description | |--------|------|-------------| | Brand | MULTI_CHOICE | Apple, Samsung, Sony, Dell, HP, etc. | -| Price Range | MULTI_CHOICE | Under $100, $100-250, $250-500, etc. | +| Price Range | MULTI_CHOICE | Buckets calculated from CHF prices: under 100, 100–250, 250–500, etc. | | In Stock | SWITCH | Availability toggle | | Rating | MULTI_CHOICE | Customer ratings | | Color | MULTI_CHOICE | Black, white, silver, blue, etc. | @@ -147,7 +154,10 @@ The target Unchained Engine must: 1. Have the bulk-import endpoint enabled 2. Accept Bearer token authentication 3. Have `bulkImport` permission granted to the authenticated user +4. Have CHF/CH and USD/US configured for the generated country-specific prices (the Fastify kitchensink seeds both) + +Generated USD prices use a fixed fixture conversion factor rather than a live exchange rate. The importer sends filters first, then products, then assortments with children before their parents. -## License +## Package status -Private - part of Unchained Engine +This tool is part of the Unchained Engine repository. Its `private: true` package setting prevents npm publication; it is not a license declaration. diff --git a/tools/demo-data-cli/src/config.ts b/tools/demo-data-cli/src/config.ts index 4dc91eeb80..8ee26f0d04 100644 --- a/tools/demo-data-cli/src/config.ts +++ b/tools/demo-data-cli/src/config.ts @@ -1,4 +1,4 @@ -// Configuration defaults and types +// Shared configuration values and types. CLI option defaults are currently defined in cli.ts. export const DEFAULT_CONFIG = { endpoint: 'http://localhost:4010/bulk-import', diff --git a/tools/demo-data-cli/src/generators/assortments.ts b/tools/demo-data-cli/src/generators/assortments.ts index 5ae6daad6c..591c61aec0 100644 --- a/tools/demo-data-cli/src/generators/assortments.ts +++ b/tools/demo-data-cli/src/generators/assortments.ts @@ -403,7 +403,7 @@ function buildProductAssortmentMap(products: GeneratedProduct[]): Map(); const nodeMap = new Map(); @@ -446,7 +446,7 @@ export function generateAssortments(products: GeneratedProduct[]): BulkImportEve const events: BulkImportEvent[] = []; const productMap = buildProductAssortmentMap(products); - // Sort nodes so parents come before children + // Create child assortments before parents that reference them. const sortedNodes = sortByHierarchyDepth(categoryHierarchy); for (let i = 0; i < sortedNodes.length; i++) { diff --git a/tools/demo-data-cli/src/generators/products.ts b/tools/demo-data-cli/src/generators/products.ts index ac7b0c701e..518323c656 100644 --- a/tools/demo-data-cli/src/generators/products.ts +++ b/tools/demo-data-cli/src/generators/products.ts @@ -46,7 +46,7 @@ export function generateProducts(targetCount: number): GeneratedProduct[] { const products: GeneratedProduct[] = []; let globalIndex = 0; - // Calculate how many products per template to reach target + // Allocate the target across all templates; templates without matching brands are skipped. const productsPerTemplate = Math.ceil(targetCount / productTemplates.length); for (const template of productTemplates) { @@ -227,6 +227,6 @@ export function generateProducts(targetCount: number): GeneratedProduct[] { if (products.length >= targetCount) break; } - // Trim to exact count + // Cap at the requested target; skipped templates can leave fewer products. return products.slice(0, targetCount); } diff --git a/tools/demo-data-cli/src/utils/price-generator.ts b/tools/demo-data-cli/src/utils/price-generator.ts index 35ef3f156a..2bd8fd4457 100644 --- a/tools/demo-data-cli/src/utils/price-generator.ts +++ b/tools/demo-data-cli/src/utils/price-generator.ts @@ -1,6 +1,6 @@ // Price generation utilities with multi-currency support -// Exchange rate: 1 CHF = 0.92 USD (approximate) +// Fixed demo conversion factor for repeatable fixture prices; not a market exchange rate. const CHF_TO_USD_RATE = 0.92; export interface PriceConfig { From b50b32b2e7675b5dcb5ad1a5e508e9e875532b40 Mon Sep 17 00:00:00 2001 From: Pascal Kaufmann Date: Mon, 14 Sep 2026 12:01:37 +0200 Subject: [PATCH 2/2] fix: enforce CI gates and align runtime, Docker, and MCP schemas Require nonmutating lint and successful tests in Jenkins and Forgejo. Keep hardware-dependent scheduler benchmarks opt-in, use free MongoDB test ports, and resolve the Admin UI errors exposed by its lint gate. Preserve workspace paths in Docker builds, retain required production dependencies, serve static exports with nginx, and validate real health responses. Derive MCP provider types and order statuses from domain enums. Align docs license metadata and package/development/container Node versions. Validated with 711 unit tests, 1,017 integration tests (5 skipped), 4 Docker healthcheck tests, lint, package/static builds, all eight image builds, and runtime smoke checks. Optional scheduler benchmarks also pass separately. BREAKING CHANGE: repository packages now require Node.js >=26.8.2. --- .dockerignore | 25 +++- .forgejo/workflows/admin-ui.yml | 18 ++- .forgejo/workflows/ci.yml | 15 +- .nvmrc | 2 +- Dockerfile | 56 +++---- Jenkinsfile | 6 +- admin-ui/.nvmrc | 2 +- admin-ui/Dockerfile | 66 ++++----- admin-ui/Jenkinsfile | 8 +- admin-ui/codegen.ts | 5 +- admin-ui/eslint.config.mjs | 2 + admin-ui/package.json | 3 +- admin-ui/src/gql/types.ts | 3 +- .../order/components/OrderStatusBadge.tsx | 7 +- docker/healthcheck.mjs | 19 +++ docker/healthcheck.test.mjs | 52 +++++++ docker/smoke-static.mjs | 44 ++++++ docker/static.conf.template | 17 +++ docs/.nvmrc | 2 +- docs/Dockerfile | 49 ++----- docs/Dockerfile.dockerignore | 11 ++ docs/package-lock.json | 4 +- docs/package.json | 4 +- examples/kitchensink-express/.nvmrc | 2 +- examples/kitchensink-express/Dockerfile | 63 ++++---- examples/kitchensink-express/package.json | 2 +- examples/kitchensink/.nvmrc | 2 +- examples/kitchensink/Dockerfile | 63 ++++---- examples/kitchensink/package.json | 3 +- examples/minimal/.nvmrc | 2 +- examples/minimal/Dockerfile | 41 ++++-- examples/minimal/package.json | 4 +- examples/oidc/.nvmrc | 2 +- examples/oidc/Dockerfile | 63 ++++---- examples/oidc/package.json | 3 +- examples/ticketing/.nvmrc | 2 +- examples/ticketing/Dockerfile | 63 ++++---- examples/ticketing/package.json | 2 +- package-lock.json | 117 ++++++++++++--- package.json | 6 +- packages/api/package.json | 3 + packages/api/src/mcp/tools/order/schemas.ts | 6 +- .../api/src/mcp/tools/provider/schemas.ts | 96 ++++++------ .../tools/provider/utils/getProviderConfig.ts | 3 +- .../api/src/mcp/utils/providerSchemas.test.ts | 137 ++++++++++++++++++ packages/api/src/mcp/utils/providerSchemas.ts | 30 ++++ packages/api/src/mcp/utils/sharedSchemas.ts | 7 +- packages/core-assortments/package.json | 3 + packages/core-bookmarks/package.json | 3 + packages/core-countries/package.json | 3 + packages/core-currencies/package.json | 3 + packages/core-delivery/package.json | 3 + packages/core-enrollments/package.json | 3 + packages/core-events/package.json | 3 + packages/core-files/package.json | 3 + packages/core-filters/package.json | 3 + packages/core-languages/package.json | 3 + packages/core-orders/package.json | 3 + packages/core-payment/package.json | 3 + packages/core-products/package.json | 3 + packages/core-quotations/package.json | 3 + packages/core-users/package.json | 3 + .../configureUsersWebAuthnModule.test.ts | 2 +- packages/core-warehousing/package.json | 3 + packages/core-worker/package.json | 3 + packages/core/package.json | 3 + packages/core/src/utils/schedule.test.ts | 35 +++-- packages/events/package.json | 3 + packages/file-upload/package.json | 3 + packages/logger/package.json | 3 + packages/mongodb/package.json | 3 + packages/platform/package.json | 2 +- packages/plugins/package.json | 3 + packages/roles/package.json | 3 + packages/shared/package.json | 3 + packages/ticketing/package.json | 3 + packages/utils/package.json | 3 + tools/demo-data-cli/package-lock.json | 2 +- tools/demo-data-cli/package.json | 2 +- 79 files changed, 880 insertions(+), 386 deletions(-) create mode 100644 docker/healthcheck.mjs create mode 100644 docker/healthcheck.test.mjs create mode 100644 docker/smoke-static.mjs create mode 100644 docker/static.conf.template create mode 100644 docs/Dockerfile.dockerignore create mode 100644 packages/api/src/mcp/utils/providerSchemas.test.ts create mode 100644 packages/api/src/mcp/utils/providerSchemas.ts diff --git a/.dockerignore b/.dockerignore index 2643085181..b4e8dce12d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,25 @@ .git +.context +.claude +.agents .github .db -.next -.env .tmp .build -.claude -docs -node_modules typedocs -examples/*/lib -examples/*/.db +docs +**/node_modules +**/.next +**/.docusaurus +**/.db +**/coverage +**/out +env +**/.env* +!.env.tests +!**/.env.defaults +!admin-ui/.env +!admin-ui/.env.development packages/*/lib +examples/*/lib +**/*.tsbuildinfo diff --git a/.forgejo/workflows/admin-ui.yml b/.forgejo/workflows/admin-ui.yml index 44234d7bf0..dbad44280c 100644 --- a/.forgejo/workflows/admin-ui.yml +++ b/.forgejo/workflows/admin-ui.yml @@ -1,8 +1,8 @@ name: admin-ui # Forgejo Actions pipeline for the admin-ui image (replaces admin-ui/Jenkinsfile). -# Scoped by `paths` so it only rebuilds when admin-ui/ changes. The admin-ui -# Dockerfile runs `npm run lint && npm run build` internally, so building the +# Scoped to the admin UI and its shared workspace/build inputs. The admin-ui +# Dockerfile runs `npm run lint:check && npm run build` internally, so building the # image is its own lint/build gate; a Trivy scan of admin-ui/ gates the publish. # Semver tags mirror the Jenkinsfile and ARE consumed in prod (adminui:1|v2|v3). @@ -12,10 +12,22 @@ on: tags: ['*'] paths: - 'admin-ui/**' + - 'packages/**' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + - '.dockerignore' + - 'docker/**' - '.forgejo/workflows/admin-ui.yml' pull_request: paths: - 'admin-ui/**' + - 'packages/**' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + - '.dockerignore' + - 'docker/**' - '.forgejo/workflows/admin-ui.yml' workflow_dispatch: @@ -108,7 +120,7 @@ jobs: docker build --platform linux/amd64 \ --label "org.opencontainers.image.source=https://git.ucc.dev/${GITHUB_REPOSITORY}" \ --build-arg GIT_COMMIT="${GITHUB_SHA}" \ - $TAG_ARGS -f ./admin-ui/Dockerfile ./admin-ui + $TAG_ARGS -f ./admin-ui/Dockerfile . for t in $TAGS; do echo "==> Pushing ${IMAGE}:${t}" docker push "${IMAGE}:${t}" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 16ff485c39..32178aa296 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -19,10 +19,9 @@ env: REGISTRY_REPO: unchained/unchained jobs: - # Faithful port of the Jenkins Test stage: build the mongo-based CI image - # (root Dockerfile: mongo:8.2.12 + Node via nvm + MONGOMS_SYSTEM_BINARY, runs - # `npm ci` + `npm run build`), then lint (gate) and test (non-blocking, exactly - # as Jenkins ran `npm run test || :`). Uses the host docker daemon (docker.sock). + # Build the mongo-based CI image with the pinned Node version and workspace + # dependencies, then require nonmutating lint and all test suites to pass. + # Uses the host docker daemon (docker.sock). test: name: Lint + tests (mongo-backed) runs-on: docker @@ -38,9 +37,9 @@ jobs: - name: Build CI image run: docker build -t unchained-ci:${{ github.sha }} . - name: Lint - run: docker run --rm unchained-ci:${{ github.sha }} npm run lint - - name: Tests (non-blocking, as in Jenkins) - run: docker run --rm unchained-ci:${{ github.sha }} sh -c "npm run test || :" + run: docker run --rm unchained-ci:${{ github.sha }} npm run lint:check + - name: Unit, integration, and healthcheck tests + run: docker run --rm unchained-ci:${{ github.sha }} npm test # Source-dependency scan across the whole monorepo (incl. admin-ui). # Hard-gates on fixable HIGH/CRITICAL vulnerabilities. @@ -116,7 +115,7 @@ jobs: docker build --platform linux/amd64 \ --label "org.opencontainers.image.source=https://git.ucc.dev/${GITHUB_REPOSITORY}" \ --build-arg GIT_COMMIT="${GITHUB_SHA}" \ - $TAG_ARGS -f ./docs/Dockerfile ./docs + $TAG_ARGS -f ./docs/Dockerfile . for t in $TAGS; do echo "==> Pushing ${IMAGE}:${t}" docker push "${IMAGE}:${t}" diff --git a/.nvmrc b/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/Dockerfile b/Dockerfile index b7f7edb54f..9909b1d537 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,26 @@ -FROM mongo:8.2.12 +# Build from the repository root. This image contains MongoDB for integration tests. +FROM node:26.8.2-bookworm-slim AS node +FROM mongo:8.2.12 AS ci -# Install app dependencies -RUN mkdir -p /source -WORKDIR /source - -ENV HOME=/root -ENV NVM_DIR=$HOME/.nvm - -RUN apt update -y && apt install -y curl unzip libatomic1 && \ - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash && \ - chmod +x $NVM_DIR/nvm.sh && \ - . $NVM_DIR/nvm.sh && \ - nvm install 26.8.2 && \ - nvm alias default 26.8.2 && \ - nvm use 26.8.2 - -ENV PATH=/root/.nvm/versions/node/v26.8.2/bin:$NVM_DIR:$PATH - -ADD packages /source/ -ADD package* /source/ -ADD examples/kitchensink/package* /source/examples/kitchensink/ -ADD examples/kitchensink-express/package* /source/examples/kitchensink-express/ -ADD examples/minimal/package* /source/examples/minimal/ -ADD examples/oidc/package* /source/examples/oidc/ -ADD examples/ticketing/package* /source/examples/ticketing/ - -ENV MONGOMS_VERSION=8.2.12 -ENV MONGOMS_SYSTEM_BINARY=/usr/bin/mongod -ENV NODE_NO_WARNINGS=1 -ENV NODE_ENV=test -RUN npm ci - -ADD . /source/ +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN apt-get update && apt-get install -y --no-install-recommends libatomic1 ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +WORKDIR /source +ENV MONGOMS_VERSION=8.2.12 \ + MONGOMS_SYSTEM_BINARY=/usr/bin/mongod \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + CYPRESS_INSTALL_BINARY=0 \ + NEXT_TELEMETRY_DISABLED=1 \ + NODE_NO_WARNINGS=1 \ + NODE_ENV=test + +# Keep every workspace at the path recorded in the root lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund RUN npm run build -CMD ["npm"] \ No newline at end of file +CMD ["npm"] diff --git a/Jenkinsfile b/Jenkinsfile index 707073a6d5..ee32c38818 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -14,8 +14,8 @@ pipeline { sh 'touch ./env && chmod 666 ./env' sh 'cp ${DOTENV_PATH} ./env' docker.build("ci:latest") - sh 'docker run ci:latest npm run lint' - sh 'docker run -t ci:latest sh -c "npm run test || :"' + sh 'docker run --rm ci:latest npm run lint:check' + sh 'docker run --rm ci:latest npm test' } } } @@ -29,7 +29,7 @@ pipeline { stage('Building') { steps{ script { - docs = docker.build("registry.ucc.dev/unchained/docs",'-f ./docs/Dockerfile ./docs') + docs = docker.build("registry.ucc.dev/unchained/docs",'-f ./docs/Dockerfile .') } } } diff --git a/admin-ui/.nvmrc b/admin-ui/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/admin-ui/.nvmrc +++ b/admin-ui/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/admin-ui/Dockerfile b/admin-ui/Dockerfile index d67f7d6a1d..f8fdf2147a 100644 --- a/admin-ui/Dockerfile +++ b/admin-ui/Dockerfile @@ -1,41 +1,27 @@ -FROM node:26-alpine AS bundler - -# Create app directory -RUN mkdir -p /usr/src/app -WORKDIR /usr/src/app - -# Install app dependencies -COPY package.json /usr/src/app/ -COPY package-lock.json /usr/src/app/ - -ENV NEXT_TELEMETRY_DISABLED="1" -ENV TZ="Europe/Amsterdam" -ENV NODE_ENV="production" -RUN NODE_ENV="development" npm ci - -# Build app -COPY . /usr/src/app/ - -RUN npm run lint -RUN npm run build -RUN rm -Rf node_modules - - -FROM node:26-alpine AS runtime - -COPY --from=bundler /usr/src/app /usr/src - -WORKDIR /usr/src/app - -ENV NEXT_TELEMETRY_DISABLED="1" -ENV TZ="Europe/Amsterdam" -ENV NODE_ENV="production" - -RUN npm ci - -HEALTHCHECK --timeout=1s --start-period=2s \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000 || exit 1 - +# docker build -f admin-ui/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder +WORKDIR /source +ENV CYPRESS_INSTALL_BINARY=0 \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + NEXT_TELEMETRY_DISABLED=1 + +# Admin UI dependencies belong to the monorepo lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund +RUN npm run lint:check --workspace @unchainedshop/admin-ui + +# Public URLs are compiled into the export, so configure them at build time. +ARG NEXT_PUBLIC_GRAPHQL_ENDPOINT +ARG NEXT_PUBLIC_CHAT_URL +ARG NEXT_PUBLIC_TEMP_FILE_UPLOAD_URL +ARG NEXT_PUBLIC_LOGO=/logo-light.svg +RUN npm run build --workspace @unchainedshop/admin-ui + +FROM nginx:1.30.4-alpine AS runtime +COPY LICENSE /licenses/unchained/LICENSE +ENV PORT=3000 +COPY docker/static.conf.template /etc/nginx/templates/default.conf.template +COPY --from=builder /source/admin-ui/out /usr/share/nginx/html +HEALTHCHECK --start-period=5s --interval=20s --timeout=3s --retries=3 \ + CMD wget -q --spider --tries=1 "http://127.0.0.1:${PORT}/" || exit 1 EXPOSE 3000 - -CMD ["npm", "start"] diff --git a/admin-ui/Jenkinsfile b/admin-ui/Jenkinsfile index 682a3677aa..d17ae94693 100644 --- a/admin-ui/Jenkinsfile +++ b/admin-ui/Jenkinsfile @@ -21,7 +21,7 @@ pipeline { stage('Building') { steps{ script { - adminui = docker.build("registry.ucc.dev/unchained/adminui:$GIT_BRANCH-$GIT_COMMIT","-f ./Dockerfile .") + adminui = docker.build("registry.ucc.dev/unchained/adminui:$GIT_BRANCH-$GIT_COMMIT","-f ./admin-ui/Dockerfile .") } } } @@ -46,7 +46,7 @@ pipeline { when { anyOf { branch 'master'; branch 'v?.x' } } steps { script { - def packageJson = readJSON file: './package.json' + def packageJson = readJSON file: './admin-ui/package.json' def packageVersion = packageJson.version def versionChunks = packageVersion.split(/\./) def majorVersion = versionChunks[0] @@ -63,7 +63,7 @@ pipeline { when { branch 'v?.*.x' } steps { script { - def packageJson = readJSON file: './package.json' + def packageJson = readJSON file: './admin-ui/package.json' def packageVersion = packageJson.version def versionChunks = packageVersion.split(/\./) def majorVersion = versionChunks[0] @@ -79,7 +79,7 @@ pipeline { when { buildingTag() } steps { script { - def packageJson = readJSON file: './package.json' + def packageJson = readJSON file: './admin-ui/package.json' def packageVersion = packageJson.version adminui.push("v$packageVersion") diff --git a/admin-ui/codegen.ts b/admin-ui/codegen.ts index cfbec08fa7..9a071b712a 100644 --- a/admin-ui/codegen.ts +++ b/admin-ui/codegen.ts @@ -4,7 +4,10 @@ const config: CodegenConfig = { overwrite: true, schema: 'http://localhost:4010/graphql', // UnchainedContextWrapper builds its fragment via runtime interpolation — not pluckable - documents: ['./src/modules/**/*.{ts,tsx}', '!./src/modules/UnchainedContext/UnchainedContextWrapper.tsx'], + documents: [ + './src/modules/**/*.{ts,tsx}', + '!./src/modules/UnchainedContext/UnchainedContextWrapper.tsx', + ], generates: { './src/gql/types.ts': { plugins: ['typescript', 'typescript-operations'], diff --git a/admin-ui/eslint.config.mjs b/admin-ui/eslint.config.mjs index 04ff2d6bea..82ba5ded36 100644 --- a/admin-ui/eslint.config.mjs +++ b/admin-ui/eslint.config.mjs @@ -86,6 +86,8 @@ export default [ ...tseslint.configs.recommended.rules, 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off', + // Apollo's module augmentation uses ambient namespaces; runtime namespaces remain disallowed. + '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-wrapper-object-types': 'off', '@typescript-eslint/no-this-alias': 'off', diff --git a/admin-ui/package.json b/admin-ui/package.json index 3cec96ab41..4f06b73349 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -14,6 +14,7 @@ "build": "node ./generate-permissions.js && NODE_ENV=production next build", "start": "next start", "lint": "eslint .", + "lint:check": "eslint .", "format": "eslint --fix .", "extract-translation": "formatjs extract 'src/(pages|modules|components)/**/*.{ts,tsx,jsx}' --out-file src/lang/en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'", "compile-translation": "formatjs compile ./src/lang/en.json --out-file ./src/i18n/en.json --format custom-formatter.js && node extract-missing-translation-keys.js", @@ -30,7 +31,7 @@ "codegen": "graphql-codegen --config codegen.ts" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "contributors": [ diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index 3276b78132..eecff54068 100644 --- a/admin-ui/src/gql/types.ts +++ b/admin-ui/src/gql/types.ts @@ -328,8 +328,7 @@ export type IColor = { }; export type IConfigurableOrBundleProduct = - | IBundleProduct - | IConfigurableProduct; + IBundleProduct | IConfigurableProduct; /** Configurable Product (Proxy) */ export type IConfigurableProduct = IProduct & { diff --git a/admin-ui/src/modules/order/components/OrderStatusBadge.tsx b/admin-ui/src/modules/order/components/OrderStatusBadge.tsx index 47754a3250..9b3cc16499 100644 --- a/admin-ui/src/modules/order/components/OrderStatusBadge.tsx +++ b/admin-ui/src/modules/order/components/OrderStatusBadge.tsx @@ -4,12 +4,7 @@ import Badge from '../../common/components/Badge'; import { ORDER_STATUSES } from '../../common/data/miscellaneous'; type OrderStatus = - | 'PENDING' - | 'CONFIRMED' - | 'OPEN' - | 'FULFILLED' - | 'REJECTED' - | string; + 'PENDING' | 'CONFIRMED' | 'OPEN' | 'FULFILLED' | 'REJECTED' | string; type OrderStatusBadgeProps = { status: OrderStatus; diff --git a/docker/healthcheck.mjs b/docker/healthcheck.mjs new file mode 100644 index 0000000000..36fc9e455b --- /dev/null +++ b/docker/healthcheck.mjs @@ -0,0 +1,19 @@ +const endpoint = new URL( + process.env.GRAPHQL_API_PATH || '/graphql', + `http://127.0.0.1:${process.env.PORT || 3000}`, +); + +try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ shopInfo { _id } }' }), + signal: AbortSignal.timeout(2000), + }); + const result = await response.json(); + if (!response.ok || result.errors?.length || !result.data?.shopInfo?._id) { + process.exitCode = 1; + } +} catch { + process.exitCode = 1; +} diff --git a/docker/healthcheck.test.mjs b/docker/healthcheck.test.mjs new file mode 100644 index 0000000000..3bfe4c619a --- /dev/null +++ b/docker/healthcheck.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; + +for (const scenario of [ + { + name: 'accepts a successful GraphQL response', + status: 200, + body: { data: { shopInfo: { _id: 'shop' } } }, + expected: 0, + }, + { + name: 'rejects GraphQL errors in an HTTP 200 response', + status: 200, + body: { errors: [{ message: 'Unavailable' }] }, + expected: 1, + }, + { + name: 'rejects unsuccessful HTTP responses', + status: 503, + body: { data: { shopInfo: { _id: 'shop' } } }, + expected: 1, + }, + { name: 'rejects an unrelated JSON response', status: 200, body: {}, expected: 1 }, +]) { + test(scenario.name, async (t) => { + let request; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + request = { method: req.method, url: req.url, body: JSON.parse(body) }; + res.writeHead(scenario.status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(scenario.body)); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => server.close()); + const child = spawn(process.execPath, [new URL('./healthcheck.mjs', import.meta.url).pathname], { + env: { ...process.env, PORT: String(server.address().port), GRAPHQL_API_PATH: '/custom-graphql' }, + stdio: 'inherit', + }); + const [code] = await once(child, 'exit'); + assert.equal(code, scenario.expected); + assert.deepEqual(request, { + method: 'POST', + url: '/custom-graphql', + body: { query: '{ shopInfo { _id } }' }, + }); + }); +} diff --git a/docker/smoke-static.mjs b/docker/smoke-static.mjs new file mode 100644 index 0000000000..4cb3a997b1 --- /dev/null +++ b/docker/smoke-static.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; + +const [image, route = '/'] = process.argv.slice(2); +if (!image) throw new Error('Usage: node docker/smoke-static.mjs IMAGE [NESTED_ROUTE]'); +const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim(); +let container; +try { + container = docker('run', '--detach', '--publish', '127.0.0.1::3000', image); + const address = docker('port', container, '3000/tcp'); + const origin = `http://${address}`; + const deadline = Date.now() + 60_000; + let healthy = false; + while (Date.now() < deadline) { + const status = docker('inspect', '--format', '{{.State.Health.Status}}', container); + if (status === 'healthy') { + healthy = true; + break; + } + if (status === 'unhealthy') throw new Error('Image health check failed'); + await delay(500); + } + assert.ok(healthy, 'Image must become healthy within 60 seconds'); + for (const path of ['/', route]) { + const response = await fetch(new URL(path, origin)); + assert.equal(response.status, 200, path); + assert.match(response.headers.get('content-type') ?? '', /text\/html/); + const html = await response.text(); + const asset = html.match(/]*\bsrc="([^"]+)"/)?.[1]; + assert.ok(asset, `${path} must load a JavaScript asset`); + const assetResponse = await fetch(new URL(asset, response.url)); + assert.equal(assetResponse.status, 200, asset); + assert.match(assetResponse.headers.get('content-type') ?? '', /javascript/); + } + const missing = await fetch(`${origin}/missing-${crypto.randomUUID()}`); + assert.equal(missing.status, 404, 'Unknown paths must return a real 404'); + console.log(`${image}: healthy; root, ${route}, JavaScript assets, and 404 verified`); +} catch (error) { + if (container) console.error(docker('logs', container)); + throw error; +} finally { + if (container) docker('rm', '--force', container); +} diff --git a/docker/static.conf.template b/docker/static.conf.template new file mode 100644 index 0000000000..9cbc3a276d --- /dev/null +++ b/docker/static.conf.template @@ -0,0 +1,17 @@ +server { + listen ${PORT}; + listen [::]:${PORT}; + server_name _; + absolute_redirect off; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri.html $uri/ =404; + } + + error_page 404 /404.html; + location = /404.html { + internal; + } +} diff --git a/docs/.nvmrc b/docs/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/docs/Dockerfile b/docs/Dockerfile index ab8981815d..8c7e79466f 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,35 +1,18 @@ -FROM node:26-alpine AS bundler - -# Install app dependencies -RUN mkdir -p /source -WORKDIR /source - -ADD package* /source/ -RUN NODE_ENV=development npm ci - -ADD . /source/ - -# Build -RUN npm run build || : -RUN rm -Rf node_modules - -FROM node:26-alpine AS runtime - -RUN apk add --no-cache --update curl - -# Copy the app -WORKDIR /webapp -COPY --from=bundler /source /webapp -RUN NODE_ENV=production npm ci --omit=dev - -ARG GIT_COMMIT="n/a" -RUN echo "${GIT_COMMIT}" > /webapp/static/version - +# docker build -f docs/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder +WORKDIR /source/docs +COPY docs/package.json docs/package-lock.json ./ +RUN npm ci --include=dev --no-audit --no-fund +COPY docs/ ./ +RUN npm run build +ARG GIT_COMMIT=n/a +RUN printf '%s\n' "$GIT_COMMIT" > build/version + +FROM nginx:1.30.4-alpine AS runtime +COPY LICENSE /licenses/unchained/LICENSE ENV PORT=3000 -ENV NODE_ENV=production - -HEALTHCHECK --start-period=10s --interval=20s --timeout=2s \ - CMD curl -f http://localhost:3000 -H 'content-type: application/json' --data-binary '{"operationName":null,"variables":{},"query":"{\n shopInfo {\n _id\n }\n}\n"}' || exit - +COPY docker/static.conf.template /etc/nginx/templates/default.conf.template +COPY --from=builder /source/docs/build /usr/share/nginx/html +HEALTHCHECK --start-period=5s --interval=20s --timeout=3s --retries=3 \ + CMD wget -q --spider --tries=1 "http://127.0.0.1:${PORT}/" || exit 1 EXPOSE 3000 -CMD ["npm", "start"] diff --git a/docs/Dockerfile.dockerignore b/docs/Dockerfile.dockerignore new file mode 100644 index 0000000000..fe3b60a798 --- /dev/null +++ b/docs/Dockerfile.dockerignore @@ -0,0 +1,11 @@ +** +!LICENSE +!docs/ +!docs/** +!docker/ +!docker/static.conf.template +docs/node_modules +docs/build +docs/.docusaurus +docs/.cache +docs/.env* diff --git a/docs/package-lock.json b/docs/package-lock.json index 4728e1724a..ce47a05069 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "docs", "version": "4.0.0", - "license": "ISC", + "license": "EUPL-1.2", "dependencies": { "@docusaurus/core": "^3.10.2", "@docusaurus/preset-classic": "^3.10.2", @@ -34,7 +34,7 @@ "typedoc-plugin-markdown": "^4.12.0" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, diff --git a/docs/package.json b/docs/package.json index b5d1c320d7..34d8efc852 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,7 +3,7 @@ "description": "Docs for Unchained Engine", "private": true, "version": "4.0.0", - "license": "ISC", + "license": "EUPL-1.2", "scripts": { "dev": "docusaurus start", "build": "docusaurus build", @@ -62,7 +62,7 @@ ] }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } } diff --git a/examples/kitchensink-express/.nvmrc b/examples/kitchensink-express/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/examples/kitchensink-express/.nvmrc +++ b/examples/kitchensink-express/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/examples/kitchensink-express/Dockerfile b/examples/kitchensink-express/Dockerfile index be3a5ff521..c9cd4cdfbf 100644 --- a/examples/kitchensink-express/Dockerfile +++ b/examples/kitchensink-express/Dockerfile @@ -1,35 +1,34 @@ -FROM node:26-alpine AS bundler - -# Install app dependencies -RUN mkdir -p /source +# docker build -f examples/kitchensink-express/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder WORKDIR /source - -ADD package* /source/ -RUN NODE_ENV=development npm ci - -ADD . /source/ - -# Build -RUN npm run build || : -RUN rm -Rf node_modules - -FROM node:26-alpine AS runtime - -RUN apk add --no-cache --update curl - -# Copy the app -WORKDIR /webapp -COPY --from=bundler /source /webapp -RUN NODE_ENV=production npm ci --omit=dev - -ARG GIT_COMMIT="n/a" -RUN echo "${GIT_COMMIT}" > /webapp/version.txt - -ENV PORT=3000 -ENV NODE_ENV=production - -HEALTHCHECK --start-period=10s --interval=20s --timeout=2s \ - CMD curl -f http://localhost:3000/graphql -H 'content-type: application/json' --data-binary '{"operationName":null,"variables":{},"query":"{\n shopInfo {\n _id\n }\n}\n"}' || exit - +ENV CYPRESS_INSTALL_BINARY=0 \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + NEXT_TELEMETRY_DISABLED=1 + +# Preserve workspace paths and install from the shared root lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts --no-audit --no-fund + +FROM node:26.8.2-bookworm-slim AS runtime +COPY LICENSE /licenses/unchained/LICENSE +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /source +COPY --from=builder /source/package.json ./package.json +COPY --from=builder /source/node_modules ./node_modules +COPY --from=builder /source/packages ./packages +COPY --from=builder /source/examples ./examples +COPY --from=builder /source/admin-ui/package.json ./admin-ui/package.json +COPY --from=builder /source/admin-ui/out ./admin-ui/out +COPY docker/healthcheck.mjs /healthcheck.mjs + +WORKDIR /source/examples/kitchensink-express +ARG GIT_COMMIT=n/a +RUN printf '%s\n' "$GIT_COMMIT" > version.txt +USER node +HEALTHCHECK --start-period=20s --interval=20s --timeout=3s --retries=3 \ + CMD ["node", "/healthcheck.mjs"] EXPOSE 3000 CMD ["npm", "start"] diff --git a/examples/kitchensink-express/package.json b/examples/kitchensink-express/package.json index 07ba9e4bbf..e5de8e4298 100644 --- a/examples/kitchensink-express/package.json +++ b/examples/kitchensink-express/package.json @@ -22,7 +22,7 @@ }, "engineStrict": true, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "scripts": { diff --git a/examples/kitchensink/.nvmrc b/examples/kitchensink/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/examples/kitchensink/.nvmrc +++ b/examples/kitchensink/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/examples/kitchensink/Dockerfile b/examples/kitchensink/Dockerfile index be3a5ff521..6b3807d8ef 100644 --- a/examples/kitchensink/Dockerfile +++ b/examples/kitchensink/Dockerfile @@ -1,35 +1,34 @@ -FROM node:26-alpine AS bundler - -# Install app dependencies -RUN mkdir -p /source +# docker build -f examples/kitchensink/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder WORKDIR /source - -ADD package* /source/ -RUN NODE_ENV=development npm ci - -ADD . /source/ - -# Build -RUN npm run build || : -RUN rm -Rf node_modules - -FROM node:26-alpine AS runtime - -RUN apk add --no-cache --update curl - -# Copy the app -WORKDIR /webapp -COPY --from=bundler /source /webapp -RUN NODE_ENV=production npm ci --omit=dev - -ARG GIT_COMMIT="n/a" -RUN echo "${GIT_COMMIT}" > /webapp/version.txt - -ENV PORT=3000 -ENV NODE_ENV=production - -HEALTHCHECK --start-period=10s --interval=20s --timeout=2s \ - CMD curl -f http://localhost:3000/graphql -H 'content-type: application/json' --data-binary '{"operationName":null,"variables":{},"query":"{\n shopInfo {\n _id\n }\n}\n"}' || exit - +ENV CYPRESS_INSTALL_BINARY=0 \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + NEXT_TELEMETRY_DISABLED=1 + +# Preserve workspace paths and install from the shared root lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts --no-audit --no-fund + +FROM node:26.8.2-bookworm-slim AS runtime +COPY LICENSE /licenses/unchained/LICENSE +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /source +COPY --from=builder /source/package.json ./package.json +COPY --from=builder /source/node_modules ./node_modules +COPY --from=builder /source/packages ./packages +COPY --from=builder /source/examples ./examples +COPY --from=builder /source/admin-ui/package.json ./admin-ui/package.json +COPY --from=builder /source/admin-ui/out ./admin-ui/out +COPY docker/healthcheck.mjs /healthcheck.mjs + +WORKDIR /source/examples/kitchensink +ARG GIT_COMMIT=n/a +RUN printf '%s\n' "$GIT_COMMIT" > version.txt +USER node +HEALTHCHECK --start-period=20s --interval=20s --timeout=3s --retries=3 \ + CMD ["node", "/healthcheck.mjs"] EXPOSE 3000 CMD ["npm", "start"] diff --git a/examples/kitchensink/package.json b/examples/kitchensink/package.json index ff733c81e6..d6a6728914 100644 --- a/examples/kitchensink/package.json +++ b/examples/kitchensink/package.json @@ -22,7 +22,7 @@ }, "engineStrict": true, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "scripts": { @@ -40,6 +40,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.1.1", "@fastify/session": "^11.1.0", + "@fastify/static": "^10.1.3", "@modelcontextprotocol/server": "^2.0.0", "@scure/bip32": "^2.0.0", "@scure/btc-signer": "^2.0.0", diff --git a/examples/minimal/.nvmrc b/examples/minimal/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/examples/minimal/.nvmrc +++ b/examples/minimal/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/examples/minimal/Dockerfile b/examples/minimal/Dockerfile index 20ca50bc2e..5d1087c708 100644 --- a/examples/minimal/Dockerfile +++ b/examples/minimal/Dockerfile @@ -1,19 +1,34 @@ -FROM node:26-alpine AS bundler - -# Install app dependencies -RUN mkdir -p /source +# docker build -f examples/minimal/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder WORKDIR /source +ENV CYPRESS_INSTALL_BINARY=0 \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + NEXT_TELEMETRY_DISABLED=1 -ADD package* /source/ -RUN NODE_ENV=development npm ci - -ADD . /source/ +# Preserve workspace paths and install from the shared root lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts --no-audit --no-fund -ENV PORT=3000 -ENV NODE_ENV=production - -HEALTHCHECK --start-period=10s --interval=20s --timeout=2s \ - CMD curl -f http://localhost:3000/graphql -H 'content-type: application/json' --data-binary '{"operationName":null,"variables":{},"query":"{\n shopInfo {\n _id\n }\n}\n"}' || exit +FROM node:26.8.2-bookworm-slim AS runtime +COPY LICENSE /licenses/unchained/LICENSE +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /source +COPY --from=builder /source/package.json ./package.json +COPY --from=builder /source/node_modules ./node_modules +COPY --from=builder /source/packages ./packages +COPY --from=builder /source/examples ./examples +COPY --from=builder /source/admin-ui/package.json ./admin-ui/package.json +COPY --from=builder /source/admin-ui/out ./admin-ui/out +COPY docker/healthcheck.mjs /healthcheck.mjs +WORKDIR /source/examples/minimal +ARG GIT_COMMIT=n/a +RUN printf '%s\n' "$GIT_COMMIT" > version.txt +USER node +HEALTHCHECK --start-period=20s --interval=20s --timeout=3s --retries=3 \ + CMD ["node", "/healthcheck.mjs"] EXPOSE 3000 CMD ["npm", "start"] diff --git a/examples/minimal/package.json b/examples/minimal/package.json index 6a2b757aba..ffbecb8abe 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -22,7 +22,7 @@ }, "engineStrict": true, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "scripts": { @@ -33,6 +33,8 @@ "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.1.1", "@fastify/session": "^11.1.0", + "@fastify/static": "^10.1.3", + "@unchainedshop/admin-ui": "^4.8.27", "@unchainedshop/platform": "^4.8.27", "@unchainedshop/plugins": "^4.8.27", "fastify": "^5.12.1" diff --git a/examples/oidc/.nvmrc b/examples/oidc/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/examples/oidc/.nvmrc +++ b/examples/oidc/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/examples/oidc/Dockerfile b/examples/oidc/Dockerfile index be3a5ff521..9db0f6eb6d 100644 --- a/examples/oidc/Dockerfile +++ b/examples/oidc/Dockerfile @@ -1,35 +1,34 @@ -FROM node:26-alpine AS bundler - -# Install app dependencies -RUN mkdir -p /source +# docker build -f examples/oidc/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder WORKDIR /source - -ADD package* /source/ -RUN NODE_ENV=development npm ci - -ADD . /source/ - -# Build -RUN npm run build || : -RUN rm -Rf node_modules - -FROM node:26-alpine AS runtime - -RUN apk add --no-cache --update curl - -# Copy the app -WORKDIR /webapp -COPY --from=bundler /source /webapp -RUN NODE_ENV=production npm ci --omit=dev - -ARG GIT_COMMIT="n/a" -RUN echo "${GIT_COMMIT}" > /webapp/version.txt - -ENV PORT=3000 -ENV NODE_ENV=production - -HEALTHCHECK --start-period=10s --interval=20s --timeout=2s \ - CMD curl -f http://localhost:3000/graphql -H 'content-type: application/json' --data-binary '{"operationName":null,"variables":{},"query":"{\n shopInfo {\n _id\n }\n}\n"}' || exit - +ENV CYPRESS_INSTALL_BINARY=0 \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + NEXT_TELEMETRY_DISABLED=1 + +# Preserve workspace paths and install from the shared root lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts --no-audit --no-fund + +FROM node:26.8.2-bookworm-slim AS runtime +COPY LICENSE /licenses/unchained/LICENSE +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /source +COPY --from=builder /source/package.json ./package.json +COPY --from=builder /source/node_modules ./node_modules +COPY --from=builder /source/packages ./packages +COPY --from=builder /source/examples ./examples +COPY --from=builder /source/admin-ui/package.json ./admin-ui/package.json +COPY --from=builder /source/admin-ui/out ./admin-ui/out +COPY docker/healthcheck.mjs /healthcheck.mjs + +WORKDIR /source/examples/oidc +ARG GIT_COMMIT=n/a +RUN printf '%s\n' "$GIT_COMMIT" > version.txt +USER node +HEALTHCHECK --start-period=20s --interval=20s --timeout=3s --retries=3 \ + CMD ["node", "/healthcheck.mjs"] EXPOSE 3000 CMD ["npm", "start"] diff --git a/examples/oidc/package.json b/examples/oidc/package.json index 778fef2fa1..97e31fb29e 100644 --- a/examples/oidc/package.json +++ b/examples/oidc/package.json @@ -22,7 +22,7 @@ }, "engineStrict": true, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "scripts": { @@ -38,6 +38,7 @@ "@fastify/multipart": "^10.1.1", "@fastify/oauth2": "^8.3.0", "@fastify/session": "^11.1.0", + "@fastify/static": "^10.1.3", "@modelcontextprotocol/server": "^2.0.0", "@unchainedshop/admin-ui": "^4.8.27", "@unchainedshop/platform": "^4.8.27", diff --git a/examples/ticketing/.nvmrc b/examples/ticketing/.nvmrc index 978b4e8e51..707210db8c 100644 --- a/examples/ticketing/.nvmrc +++ b/examples/ticketing/.nvmrc @@ -1 +1 @@ -26 \ No newline at end of file +26.8.2 diff --git a/examples/ticketing/Dockerfile b/examples/ticketing/Dockerfile index be3a5ff521..8730228c46 100644 --- a/examples/ticketing/Dockerfile +++ b/examples/ticketing/Dockerfile @@ -1,35 +1,34 @@ -FROM node:26-alpine AS bundler - -# Install app dependencies -RUN mkdir -p /source +# docker build -f examples/ticketing/Dockerfile . +FROM node:26.8.2-bookworm-slim AS builder WORKDIR /source - -ADD package* /source/ -RUN NODE_ENV=development npm ci - -ADD . /source/ - -# Build -RUN npm run build || : -RUN rm -Rf node_modules - -FROM node:26-alpine AS runtime - -RUN apk add --no-cache --update curl - -# Copy the app -WORKDIR /webapp -COPY --from=bundler /source /webapp -RUN NODE_ENV=production npm ci --omit=dev - -ARG GIT_COMMIT="n/a" -RUN echo "${GIT_COMMIT}" > /webapp/version.txt - -ENV PORT=3000 -ENV NODE_ENV=production - -HEALTHCHECK --start-period=10s --interval=20s --timeout=2s \ - CMD curl -f http://localhost:3000/graphql -H 'content-type: application/json' --data-binary '{"operationName":null,"variables":{},"query":"{\n shopInfo {\n _id\n }\n}\n"}' || exit - +ENV CYPRESS_INSTALL_BINARY=0 \ + MONGOMS_DISABLE_POSTINSTALL=1 \ + NEXT_TELEMETRY_DISABLED=1 + +# Preserve workspace paths and install from the shared root lockfile. +COPY . . +RUN npm ci --include=dev --no-audit --no-fund +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts --no-audit --no-fund + +FROM node:26.8.2-bookworm-slim AS runtime +COPY LICENSE /licenses/unchained/LICENSE +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /source +COPY --from=builder /source/package.json ./package.json +COPY --from=builder /source/node_modules ./node_modules +COPY --from=builder /source/packages ./packages +COPY --from=builder /source/examples ./examples +COPY --from=builder /source/admin-ui/package.json ./admin-ui/package.json +COPY --from=builder /source/admin-ui/out ./admin-ui/out +COPY docker/healthcheck.mjs /healthcheck.mjs + +WORKDIR /source/examples/ticketing +ARG GIT_COMMIT=n/a +RUN printf '%s\n' "$GIT_COMMIT" > version.txt +USER node +HEALTHCHECK --start-period=20s --interval=20s --timeout=3s --retries=3 \ + CMD ["node", "/healthcheck.mjs"] EXPOSE 3000 CMD ["npm", "start"] diff --git a/examples/ticketing/package.json b/examples/ticketing/package.json index 7875de1e7a..a976aa74f5 100644 --- a/examples/ticketing/package.json +++ b/examples/ticketing/package.json @@ -22,7 +22,7 @@ }, "engineStrict": true, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "scripts": { diff --git a/package-lock.json b/package-lock.json index 22d5a9ce30..e547d0a096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "wait-on": "^9.0.1" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -134,7 +134,7 @@ "validator": "^13.15.23" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -149,6 +149,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.1.1", "@fastify/session": "^11.1.0", + "@fastify/static": "^10.1.3", "@modelcontextprotocol/server": "^2.0.0", "@scure/bip32": "^2.0.0", "@scure/btc-signer": "^2.0.0", @@ -175,7 +176,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -216,7 +217,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -228,6 +229,8 @@ "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.1.1", "@fastify/session": "^11.1.0", + "@fastify/static": "^10.1.3", + "@unchainedshop/admin-ui": "^4.8.27", "@unchainedshop/platform": "^4.8.27", "@unchainedshop/plugins": "^4.8.27", "fastify": "^5.12.1" @@ -236,7 +239,7 @@ "mongodb-memory-server": "^11.0.0" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -249,6 +252,7 @@ "@fastify/multipart": "^10.1.1", "@fastify/oauth2": "^8.3.0", "@fastify/session": "^11.1.0", + "@fastify/static": "^10.1.3", "@modelcontextprotocol/server": "^2.0.0", "@unchainedshop/admin-ui": "^4.8.27", "@unchainedshop/platform": "^4.8.27", @@ -263,7 +267,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -287,7 +291,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -1220,7 +1224,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", - "dev": true, "funding": [ { "type": "github", @@ -1472,7 +1475,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", - "dev": true, "funding": [ { "type": "github", @@ -1496,7 +1498,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -1529,7 +1530,6 @@ "version": "10.1.3", "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz", "integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==", - "dev": true, "funding": [ { "type": "github", @@ -4399,7 +4399,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6919,7 +6918,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -7134,7 +7132,6 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7855,7 +7852,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -10688,7 +10684,6 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -14306,7 +14301,6 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -14356,7 +14350,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -15364,7 +15357,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -15381,7 +15373,6 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -19214,6 +19205,9 @@ "passport": "^0.7.0", "typescript": "^5.8.3" }, + "engines": { + "node": ">=26.8.2" + }, "peerDependencies": { "@ai-sdk/mcp": "^2.0.29", "@fastify/cookie": ">= 11 < 12", @@ -19312,6 +19306,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-assortments": { @@ -19327,6 +19324,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-bookmarks": { @@ -19340,6 +19340,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-countries": { @@ -19354,6 +19357,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-currencies": { @@ -19368,6 +19374,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-delivery": { @@ -19382,6 +19391,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-enrollments": { @@ -19396,6 +19408,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-events": { @@ -19411,6 +19426,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-files": { @@ -19425,6 +19443,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-filters": { @@ -19440,6 +19461,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-languages": { @@ -19454,6 +19478,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-orders": { @@ -19468,6 +19495,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-payment": { @@ -19482,6 +19512,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-products": { @@ -19496,6 +19529,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-quotations": { @@ -19510,6 +19546,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-users": { @@ -19530,6 +19569,9 @@ "@types/node": "^26.2.0", "typescript": "^5.8.3" }, + "engines": { + "node": ">=26.8.2" + }, "peerDependencies": { "@noble/curves": "^2.0.0", "@noble/hashes": "^2.0.0" @@ -19555,6 +19597,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/core-worker": { @@ -19570,6 +19615,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/events": { @@ -19582,6 +19630,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/file-upload": { @@ -19594,6 +19645,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/logger": { @@ -19603,6 +19657,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/mongodb": { @@ -19617,6 +19674,9 @@ "@types/node": "^26.2.0", "typescript": "^5.8.3" }, + "engines": { + "node": ">=26.8.2" + }, "peerDependencies": { "@mongodb-js/zstd": ">= 7 < 8", "mongodb": ">= 7 < 8", @@ -19658,7 +19718,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" } }, @@ -19704,6 +19764,9 @@ "web-push": "^3.6.7", "xml-js": "^1.6.11" }, + "engines": { + "node": ">=26.8.2" + }, "peerDependencies": { "@aws-sdk/client-eventbridge": ">= 3.714 < 4", "@noble/curves": "^2.0.0", @@ -19777,6 +19840,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/shared": { @@ -19785,6 +19851,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } }, "packages/ticketing": { @@ -19809,6 +19878,9 @@ "fastify": "^5.12.1", "typescript": "^5.8.3" }, + "engines": { + "node": ">=26.8.2" + }, "peerDependencies": { "@parse/node-apn": "^8.1.0", "express": "5.x", @@ -19838,6 +19910,9 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } } diff --git a/package.json b/package.json index c8bacecfb3..814e9cda8f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "engineStrict": true, "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "workspaces": [ @@ -65,7 +65,9 @@ "scripts": { "typedoc": "typedoc --entryPointStrategy packages ./packages/* --out ./typedocs && rm -Rf ./docs/static/types && mv ./typedocs ./docs/static/types", "lint": "eslint --fix .", - "test": "npm run test:run:unit && npm run test:run:integration", + "lint:check": "eslint .", + "test": "npm run test:run:unit && npm run test:run:integration && npm run test:run:docker", + "test:run:docker": "node --test docker/*.test.mjs", "test:run:unit": "cd packages && node --test", "test:run:integration": "node --no-warnings --env-file .env.tests --env-file-if-exists=.env --test-isolation=none --test-force-exit --test-global-setup=tests/helpers.js --test --test-concurrency=1 tests/*.test.*", "dev": "run-p dev:*", diff --git a/packages/api/package.json b/packages/api/package.json index 9341c4ad89..81892e9304 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -172,5 +172,8 @@ "multer": "^2.0.1", "passport": "^0.7.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/api/src/mcp/tools/order/schemas.ts b/packages/api/src/mcp/tools/order/schemas.ts index 7aa961d038..9eed5dc54e 100644 --- a/packages/api/src/mcp/tools/order/schemas.ts +++ b/packages/api/src/mcp/tools/order/schemas.ts @@ -7,10 +7,10 @@ import { OrderFilterSchema, createManagementSchemaFromValidators, } from '../../utils/sharedSchemas.ts'; +import { PaymentProviderTypeEnum, DeliveryProviderTypeEnum } from '../../utils/providerSchemas.ts'; -export const OrderStatusEnum = z.enum(['PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED']); -export const PaymentProviderTypeEnum = z.enum(['CARD', 'INVOICE', 'GENERIC']); -export const DeliveryProviderTypeEnum = z.enum(['PICKUP', 'SHIPPING', 'LOCAL']); +export { OrderStatusEnum } from '../../utils/sharedSchemas.ts'; +export { PaymentProviderTypeEnum, DeliveryProviderTypeEnum } from '../../utils/providerSchemas.ts'; export const SortDirectionEnum = z.enum(['ASC', 'DESC']); export const SortOptionInput = z.strictObject({ diff --git a/packages/api/src/mcp/tools/provider/schemas.ts b/packages/api/src/mcp/tools/provider/schemas.ts index 3d4b628fa0..6b03901d65 100644 --- a/packages/api/src/mcp/tools/provider/schemas.ts +++ b/packages/api/src/mcp/tools/provider/schemas.ts @@ -1,17 +1,33 @@ import { z } from 'zod/v4-mini'; import { SearchSchema, createManagementSchemaFromValidators } from '../../utils/sharedSchemas.ts'; +import { + ProviderTypeEnum, + ProviderSubtypeSchema, + ProviderSubtypeSchemas, + providerSubtypeDescription, + type ProviderType, +} from '../../utils/providerSchemas.ts'; -export const ProviderTypeEnum = z - .enum(['PAYMENT', 'DELIVERY', 'WAREHOUSING']) +export { + ProviderTypeEnum, + PaymentProviderTypeEnum, + DeliveryProviderTypeEnum, + WarehousingProviderTypeEnum, +} from '../../utils/providerSchemas.ts'; + +const TypeFilterSchema = z + .optional(ProviderSubtypeSchema) .check( z.describe( - 'Type of provider - PAYMENT for payment processing (cards, invoices), DELIVERY for shipping/pickup methods, WAREHOUSING for inventory management', + `Optional filter by specific subtype: ${providerSubtypeDescription}; must match providerType`, ), ); -export const PaymentProviderTypeEnum = z.enum(['CARD', 'INVOICE', 'GENERIC']); -export const DeliveryProviderTypeEnum = z.enum(['PICKUP', 'SHIPPING', 'LOCAL']); -export const WarehousingProviderTypeEnum = z.enum(['PHYSICAL', 'VIRTUAL']); +const matchingTypeFilter = z.refine<{ providerType: ProviderType; typeFilter?: string }>( + ({ providerType, typeFilter }) => + typeFilter === undefined || ProviderSubtypeSchemas[providerType].safeParse(typeFilter).success, + { message: 'Subtype must match the providerType category', path: ['typeFilter'] }, +); export const ConfigurationEntry = z.strictObject({ key: z @@ -30,30 +46,36 @@ export const ConfigurationEntry = z.strictObject({ }); export const ProviderConfigSchema = z.object({ - type: z - .union([PaymentProviderTypeEnum, DeliveryProviderTypeEnum, WarehousingProviderTypeEnum]) - .check( - z.describe( - 'Specific provider subtype: PAYMENT types (CARD, INVOICE, GENERIC), DELIVERY types (PICKUP, SHIPPING, LOCAL), WAREHOUSING types (PHYSICAL, VIRTUAL) - must match providerType category', - ), + type: ProviderSubtypeSchema.check( + z.describe( + `Specific provider subtype: ${providerSubtypeDescription}; must match providerType category`, ), + ), adapterKey: z .string() .check( z.minLength(1), z.describe( - 'Unique adapter key that identifies the specific provider implementation - get available keys from provider_interfaces tool', + 'Unique adapter key that identifies the specific provider implementation - get available keys with the provider_management INTERFACES action', ), ), }); export const actionValidators = { - CREATE: z.object({ - providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), - provider: ProviderConfigSchema.check( - z.describe('Provider configuration including type and adapter'), + CREATE: z + .object({ + providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), + provider: ProviderConfigSchema.check( + z.describe('Provider configuration including type and adapter'), + ), + }) + .check( + z.refine( + ({ providerType, provider }) => + ProviderSubtypeSchemas[providerType].safeParse(provider.type).success, + { message: 'Subtype must match the providerType category', path: ['provider', 'type'] }, + ), ), - }), UPDATE: z.object({ providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), @@ -91,32 +113,20 @@ export const actionValidators = { .check(z.minLength(1), z.describe('Unique identifier of the specific provider instance')), }), - LIST: z.object({ - providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), - typeFilter: z - .optional( - z.union([PaymentProviderTypeEnum, DeliveryProviderTypeEnum, WarehousingProviderTypeEnum]), - ) - .check( - z.describe( - 'Optional filter by specific subtype: PAYMENT (CARD, INVOICE, GENERIC), DELIVERY (PICKUP, SHIPPING, LOCAL), WAREHOUSING (PHYSICAL, VIRTUAL)', - ), - ), - ...SearchSchema, - }), + LIST: z + .object({ + providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), + typeFilter: TypeFilterSchema, + ...SearchSchema, + }) + .check(matchingTypeFilter), - INTERFACES: z.object({ - providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), - typeFilter: z - .optional( - z.union([PaymentProviderTypeEnum, DeliveryProviderTypeEnum, WarehousingProviderTypeEnum]), - ) - .check( - z.describe( - 'Optional filter by specific subtype: PAYMENT (CARD, INVOICE, GENERIC), DELIVERY (PICKUP, SHIPPING, LOCAL), WAREHOUSING (PHYSICAL, VIRTUAL)', - ), - ), - }), + INTERFACES: z + .object({ + providerType: ProviderTypeEnum.check(z.describe('Type of provider system to operate on')), + typeFilter: TypeFilterSchema, + }) + .check(matchingTypeFilter), } as const; export const ProviderManagementSchema = createManagementSchemaFromValidators(actionValidators); diff --git a/packages/api/src/mcp/tools/provider/utils/getProviderConfig.ts b/packages/api/src/mcp/tools/provider/utils/getProviderConfig.ts index 217d23688a..57708d6d70 100644 --- a/packages/api/src/mcp/tools/provider/utils/getProviderConfig.ts +++ b/packages/api/src/mcp/tools/provider/utils/getProviderConfig.ts @@ -1,4 +1,5 @@ import type { Context } from '../../../../context.ts'; +import type { ProviderType } from '../../../utils/providerSchemas.ts'; import { PaymentDirector, DeliveryDirector, WarehousingDirector } from '@unchainedshop/core'; import { PaymentProviderNotFoundError, @@ -6,7 +7,7 @@ import { WarehousingProviderNotFoundError, } from '../../../../errors.ts'; -export type ProviderType = 'PAYMENT' | 'DELIVERY' | 'WAREHOUSING'; +export type { ProviderType } from '../../../utils/providerSchemas.ts'; export interface ProviderConfig { module: any; diff --git a/packages/api/src/mcp/utils/providerSchemas.test.ts b/packages/api/src/mcp/utils/providerSchemas.test.ts new file mode 100644 index 0000000000..d493d0e94d --- /dev/null +++ b/packages/api/src/mcp/utils/providerSchemas.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { PaymentProviderType } from '@unchainedshop/core-payment'; +import { DeliveryProviderType } from '@unchainedshop/core-delivery'; +import { WarehousingProviderType } from '@unchainedshop/core-warehousing'; +import { OrderStatus } from '@unchainedshop/core-orders'; +import { + actionValidators as providerActions, + ProviderManagementSchema, +} from '../tools/provider/schemas.ts'; +import { actionValidators as orderActions, OrderManagementSchema } from '../tools/order/schemas.ts'; + +const domainTypes = { + PAYMENT: PaymentProviderType, + DELIVERY: DeliveryProviderType, + WAREHOUSING: WarehousingProviderType, +} as const; + +const createInput = (providerType: string, type: string) => ({ + providerType, + provider: { type, adapterKey: 'test-adapter' }, +}); + +describe('MCP provider subtype validation', () => { + it('accepts every domain subtype for its provider category', () => { + for (const [providerType, types] of Object.entries(domainTypes)) { + for (const type of Object.values(types)) { + assert.ok(providerActions.CREATE.safeParse(createInput(providerType, type)).success); + for (const action of ['LIST', 'INTERFACES'] as const) { + assert.ok(providerActions[action].safeParse({ providerType, typeFilter: type }).success); + } + } + } + }); + + it('rejects removed CARD and LOCAL subtypes for creation and filtering', () => { + for (const providerType of Object.keys(domainTypes)) { + for (const type of ['CARD', 'LOCAL']) { + assert.equal(providerActions.CREATE.safeParse(createInput(providerType, type)).success, false); + for (const action of ['LIST', 'INTERFACES'] as const) { + assert.equal( + providerActions[action].safeParse({ providerType, typeFilter: type }).success, + false, + ); + } + } + } + }); + + it('rejects valid subtypes when used in another provider category', () => { + for (const providerType of Object.keys(domainTypes)) { + for (const [otherCategory, types] of Object.entries(domainTypes)) { + if (otherCategory === providerType) continue; + for (const type of Object.values(types)) { + const created = providerActions.CREATE.safeParse(createInput(providerType, type)); + assert.equal(created.success, false, `${providerType} CREATE must reject ${type}`); + if (!created.success) { + assert.deepEqual(created.error.issues[0].path, ['provider', 'type']); + } + for (const action of ['LIST', 'INTERFACES'] as const) { + const filtered = providerActions[action].safeParse({ providerType, typeFilter: type }); + assert.equal(filtered.success, false, `${providerType} ${action} must reject ${type}`); + if (!filtered.success) { + assert.deepEqual(filtered.error.issues[0].path, ['typeFilter']); + } + } + } + } + } + }); + + it('allows an omitted subtype filter', () => { + for (const providerType of Object.keys(domainTypes)) { + for (const action of ['LIST', 'INTERFACES'] as const) { + assert.ok(providerActions[action].safeParse({ providerType }).success); + } + } + }); + + it('advertises only supported provider subtypes to MCP clients', () => { + const schema = (ProviderManagementSchema as any)['~standard'].jsonSchema.input(); + const expected = Object.values(domainTypes).flatMap((types) => Object.values(types)); + const advertised = schema.properties.provider.properties.type.anyOf.flatMap( + (entry: { enum: string[] }) => entry.enum, + ); + assert.deepEqual(advertised.toSorted(), expected.toSorted()); + const filterTypes = schema.properties.typeFilter.anyOf.flatMap( + (entry: { enum: string[] }) => entry.enum, + ); + assert.deepEqual(filterTypes.toSorted(), expected.toSorted()); + assert.doesNotMatch(JSON.stringify(schema), /\b(CARD|LOCAL)\b/); + }); +}); + +describe('MCP order filters', () => { + it('accepts the domain payment and delivery subtypes', () => { + const input = { + paymentProviderTypes: Object.values(PaymentProviderType), + deliveryProviderTypes: Object.values(DeliveryProviderType), + }; + assert.ok(orderActions.LIST.safeParse(input).success); + }); + + it('rejects removed and cross-category provider subtypes', () => { + for (const paymentType of ['CARD', 'LOCAL', ...Object.values(DeliveryProviderType)]) { + assert.equal(orderActions.LIST.safeParse({ paymentProviderTypes: [paymentType] }).success, false); + } + for (const deliveryType of ['LOCAL', 'CARD', ...Object.values(PaymentProviderType)]) { + assert.equal( + orderActions.LIST.safeParse({ deliveryProviderTypes: [deliveryType] }).success, + false, + ); + } + }); + + it('uses domain order statuses in every shared order filter', () => { + for (const action of ['LIST', 'SALES_SUMMARY', 'MONTHLY_BREAKDOWN'] as const) { + assert.ok(orderActions[action].safeParse({ status: Object.values(OrderStatus) }).success); + for (const status of ['SHIPPED', 'DELIVERED', 'CANCELLED']) { + assert.equal(orderActions[action].safeParse({ status: [status] }).success, false); + } + } + }); + + it('advertises domain enums in the order management wire schema', () => { + const schema = (OrderManagementSchema as any)['~standard'].jsonSchema.input(); + assert.deepEqual( + schema.properties.paymentProviderTypes.items.enum, + Object.values(PaymentProviderType), + ); + assert.deepEqual( + schema.properties.deliveryProviderTypes.items.enum, + Object.values(DeliveryProviderType), + ); + assert.deepEqual(schema.properties.status.items.enum, Object.values(OrderStatus)); + }); +}); diff --git a/packages/api/src/mcp/utils/providerSchemas.ts b/packages/api/src/mcp/utils/providerSchemas.ts new file mode 100644 index 0000000000..ec99d384d5 --- /dev/null +++ b/packages/api/src/mcp/utils/providerSchemas.ts @@ -0,0 +1,30 @@ +import { z } from 'zod/v4-mini'; +import { PaymentProviderType } from '@unchainedshop/core-payment'; +import { DeliveryProviderType } from '@unchainedshop/core-delivery'; +import { WarehousingProviderType } from '@unchainedshop/core-warehousing'; + +export const PaymentProviderTypeEnum = z.enum(PaymentProviderType); +export const DeliveryProviderTypeEnum = z.enum(DeliveryProviderType); +export const WarehousingProviderTypeEnum = z.enum(WarehousingProviderType); + +export const ProviderSubtypeSchemas = { + PAYMENT: PaymentProviderTypeEnum, + DELIVERY: DeliveryProviderTypeEnum, + WAREHOUSING: WarehousingProviderTypeEnum, +} as const; + +export type ProviderType = keyof typeof ProviderSubtypeSchemas; + +export const ProviderTypeEnum = z + .enum(Object.keys(ProviderSubtypeSchemas) as [ProviderType, ...ProviderType[]]) + .check( + z.describe( + 'Type of provider - PAYMENT for payment processing, DELIVERY for shipping/pickup methods, WAREHOUSING for inventory management', + ), + ); + +export const ProviderSubtypeSchema = z.union(Object.values(ProviderSubtypeSchemas)); + +export const providerSubtypeDescription = Object.entries(ProviderSubtypeSchemas) + .map(([category, schema]) => `${category} (${schema.options.join(', ')})`) + .join(', '); diff --git a/packages/api/src/mcp/utils/sharedSchemas.ts b/packages/api/src/mcp/utils/sharedSchemas.ts index dae538659e..8cdd8e80e0 100644 --- a/packages/api/src/mcp/utils/sharedSchemas.ts +++ b/packages/api/src/mcp/utils/sharedSchemas.ts @@ -1,6 +1,7 @@ import { z } from 'zod/v4-mini'; import type { StandardSchemaWithJSON } from '@modelcontextprotocol/server'; import { SortDirection } from '@unchainedshop/utils'; +import { OrderStatus } from '@unchainedshop/core-orders'; const sortDirectionKeys = Object.keys(SortDirection) as [string, ...string[]]; @@ -51,6 +52,8 @@ export const DateRangeSchema = { to: z.optional(z.iso.datetime()).check(z.describe('End date in ISO format')), }; +export const OrderStatusEnum = z.enum(OrderStatus); + export const OrderFilterSchema = { paymentProviderIds: z .optional(z.array(z.string())) @@ -58,9 +61,7 @@ export const OrderFilterSchema = { deliveryProviderIds: z .optional(z.array(z.string())) .check(z.describe('Filter by delivery provider IDs')), - status: z - .optional(z.array(z.enum(['PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED']))) - .check(z.describe('Filter by order statuses')), + status: z.optional(z.array(OrderStatusEnum)).check(z.describe('Filter by order statuses')), }; export const EntityIdSchema = { diff --git a/packages/core-assortments/package.json b/packages/core-assortments/package.json index c4d7e913b1..df9dd720b5 100644 --- a/packages/core-assortments/package.json +++ b/packages/core-assortments/package.json @@ -43,5 +43,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-bookmarks/package.json b/packages/core-bookmarks/package.json index bcd4099990..cdb59f5715 100644 --- a/packages/core-bookmarks/package.json +++ b/packages/core-bookmarks/package.json @@ -41,5 +41,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-countries/package.json b/packages/core-countries/package.json index 35d1c47f01..9dc40ca69f 100644 --- a/packages/core-countries/package.json +++ b/packages/core-countries/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-currencies/package.json b/packages/core-currencies/package.json index a4841012c7..4fbe6e0fae 100644 --- a/packages/core-currencies/package.json +++ b/packages/core-currencies/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-delivery/package.json b/packages/core-delivery/package.json index 97523995de..caf081b74f 100644 --- a/packages/core-delivery/package.json +++ b/packages/core-delivery/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-enrollments/package.json b/packages/core-enrollments/package.json index ea42e32633..df48d25930 100644 --- a/packages/core-enrollments/package.json +++ b/packages/core-enrollments/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-events/package.json b/packages/core-events/package.json index b5f6f21378..bc37514eec 100644 --- a/packages/core-events/package.json +++ b/packages/core-events/package.json @@ -43,5 +43,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-files/package.json b/packages/core-files/package.json index 5445f0879c..ba98869325 100644 --- a/packages/core-files/package.json +++ b/packages/core-files/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-filters/package.json b/packages/core-filters/package.json index ec356b9132..e4bdf6866d 100644 --- a/packages/core-filters/package.json +++ b/packages/core-filters/package.json @@ -43,5 +43,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-languages/package.json b/packages/core-languages/package.json index 5781d44ee0..e1783e80c8 100644 --- a/packages/core-languages/package.json +++ b/packages/core-languages/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-orders/package.json b/packages/core-orders/package.json index 2d0c70d3f8..9e69828a17 100644 --- a/packages/core-orders/package.json +++ b/packages/core-orders/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-payment/package.json b/packages/core-payment/package.json index 772e4a7d22..6e0f417151 100644 --- a/packages/core-payment/package.json +++ b/packages/core-payment/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-products/package.json b/packages/core-products/package.json index 014fef49a7..729b88fb70 100644 --- a/packages/core-products/package.json +++ b/packages/core-products/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-quotations/package.json b/packages/core-quotations/package.json index da8f2fd169..23220ab7cc 100644 --- a/packages/core-quotations/package.json +++ b/packages/core-quotations/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-users/package.json b/packages/core-users/package.json index e217b1d13c..eeeca1708e 100644 --- a/packages/core-users/package.json +++ b/packages/core-users/package.json @@ -59,5 +59,8 @@ "@noble/hashes": "^2.0.0", "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts b/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts index e753a30fee..92e6a7f9f9 100644 --- a/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts +++ b/packages/core-users/src/module/configureUsersWebAuthnModule.test.ts @@ -83,7 +83,7 @@ describe('WebAuthn Module', () => { let db: Db; before(async () => { - db = await initDb({ forceInMemory: true }); + db = await initDb({ forceInMemory: true, port: 0 }); }); after(async () => { diff --git a/packages/core-warehousing/package.json b/packages/core-warehousing/package.json index e912bcb3ef..e4a599e92c 100644 --- a/packages/core-warehousing/package.json +++ b/packages/core-warehousing/package.json @@ -42,5 +42,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core-worker/package.json b/packages/core-worker/package.json index 5f0691450d..86f3f2a9a5 100644 --- a/packages/core-worker/package.json +++ b/packages/core-worker/package.json @@ -43,5 +43,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core/package.json b/packages/core/package.json index e2c548dbc0..4a7a94c8a2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,5 +63,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/core/src/utils/schedule.test.ts b/packages/core/src/utils/schedule.test.ts index 87127f3f89..7263752fef 100644 --- a/packages/core/src/utils/schedule.test.ts +++ b/packages/core/src/utils/schedule.test.ts @@ -147,8 +147,7 @@ describe('schedule.schedule().next()', () => { it('should return next occurrence for specific hour cron', () => { const scheduleData = schedule.parse.cron('0 15 * * *'); // Use a reference date where local hour is 10 - const referenceDate = new Date(); - referenceDate.setHours(10, 0, 0, 0); + const referenceDate = new Date(2024, 0, 15, 10, 0, 0); const nextDate = schedule.schedule(scheduleData).next(1, referenceDate) as Date; // Should be 15:00 local time on the same day @@ -160,8 +159,7 @@ describe('schedule.schedule().next()', () => { it('should return next day if time has passed', () => { const scheduleData = schedule.parse.cron('0 15 * * *'); // Use a reference date where local hour is 16 (after 15:00) - const referenceDate = new Date(); - referenceDate.setHours(16, 0, 0, 0); + const referenceDate = new Date(2024, 0, 15, 16, 0, 0); const nextDate = schedule.schedule(scheduleData).next(1, referenceDate) as Date; // Should be 15:00 local time on the next day @@ -232,6 +230,21 @@ describe('BaseWorker.autorescheduleTypes pattern', () => { // fixedSchedule.schedules[0].s = [0]; // const nextDate = schedule.schedule(fixedSchedule).next(1, referenceDate) as Date; + it('should schedule the next day for every reference second in the scheduled minute', () => { + const sched = schedule.parse.cron('0 3 * * *'); + sched.schedules[0].s = [0]; + const expected = new Date(2024, 0, 16, 3, 0, 0); + + for (let second = 0; second < 60; second++) { + const ref = new Date(2024, 0, 15, 3, 0, second); + const referenceTime = ref.getTime(); + const next = schedule.schedule(sched).next(1, ref) as Date; + + assert.strictEqual(next.getTime(), expected.getTime(), `reference second ${second}`); + assert.strictEqual(ref.getTime(), referenceTime, 'should preserve the reference date'); + } + }); + it('should find next for "0 3 * * *" (error-notifications) when reference is after 03:00', () => { const sched = schedule.parse.cron('0 3 * * *'); sched.schedules[0].s = [0]; // BaseWorker override @@ -732,7 +745,7 @@ describe('multiple occurrences', () => { } }); - it('should produce 365 daily occurrences correctly', () => { + it('should produce 366 daily occurrences correctly in a leap year', () => { const sched = schedule.parse.cron('0 3 * * *'); // daily at 03:00 const ref = new Date(2024, 0, 1, 0, 0, 0); // Jan 1, 2024 (leap year) const dates = schedule.schedule(sched).next(366, ref) as Date[]; @@ -866,11 +879,15 @@ describe('raw ScheduleData', () => { }); // ============================================================================ -// Performance: the optimized algorithm should be fast for all schedule types +// Hardware-dependent benchmarks are opt-in; correctness is covered above. +// Run on an idle machine from the repository root: +// UNCHAINED_SCHEDULE_BENCHMARK=1 node --test --test-name-pattern='schedule performance benchmarks' packages/core/src/utils/schedule.test.ts // ============================================================================ -describe('performance', () => { - it('should find daily schedule next occurrence in under 5ms', () => { +const runBenchmarks = process.env.UNCHAINED_SCHEDULE_BENCHMARK === '1'; + +describe('schedule performance benchmarks', { skip: !runBenchmarks }, () => { + it('should calculate 1000 daily next occurrences in under 50ms', () => { const sched = schedule.parse.cron('0 3 * * *'); // Worst case: just after 03:00, need to jump ~24 hours const ref = new Date(2024, 0, 15, 3, 0, 1); @@ -885,7 +902,7 @@ describe('performance', () => { assert.ok(elapsed < 50, `1000 daily next() calls took ${elapsed.toFixed(1)}ms, expected < 50ms`); }); - it('should find yearly schedule next occurrence quickly', () => { + it('should calculate 1000 yearly next occurrences in under 50ms', () => { // Once per year: Jan 1 at midnight const sched = schedule.parse.cron('0 0 1 1 *'); const ref = new Date(2024, 0, 1, 0, 0, 0); // exactly on match diff --git a/packages/events/package.json b/packages/events/package.json index dc328d1d2a..1e05c5eda1 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -39,5 +39,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/file-upload/package.json b/packages/file-upload/package.json index a8b38b0f52..858311d2dc 100644 --- a/packages/file-upload/package.json +++ b/packages/file-upload/package.json @@ -40,5 +40,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/logger/package.json b/packages/logger/package.json index fe7bea6d6b..80c4de6c55 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -39,5 +39,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/mongodb/package.json b/packages/mongodb/package.json index a966e5bf28..b68bc0be00 100644 --- a/packages/mongodb/package.json +++ b/packages/mongodb/package.json @@ -55,5 +55,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/platform/package.json b/packages/platform/package.json index 1930462707..7c15dc0ba9 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -37,7 +37,7 @@ }, "homepage": "https://github.com/unchainedshop/unchained#readme", "engines": { - "node": ">=22.0.0", + "node": ">=26.8.2", "npm": ">=10.0.0" }, "dependencies": { diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 932f345ad6..7fa7c505b5 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -138,5 +138,8 @@ "typescript": "^5.8.3", "web-push": "^3.6.7", "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/roles/package.json b/packages/roles/package.json index f2089b8540..4046644adb 100644 --- a/packages/roles/package.json +++ b/packages/roles/package.json @@ -36,5 +36,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 9f9d287283..ca7f71c5d8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,5 +11,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/ticketing/package.json b/packages/ticketing/package.json index 0ce553ba5f..05904fdc83 100644 --- a/packages/ticketing/package.json +++ b/packages/ticketing/package.json @@ -66,5 +66,8 @@ "express": "^5.1.0", "fastify": "^5.12.1", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/packages/utils/package.json b/packages/utils/package.json index 87eeb72c8d..9501d03ca9 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -36,5 +36,8 @@ "devDependencies": { "@types/node": "^26.2.0", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=26.8.2" } } diff --git a/tools/demo-data-cli/package-lock.json b/tools/demo-data-cli/package-lock.json index 7d3820b233..315771a2bd 100644 --- a/tools/demo-data-cli/package-lock.json +++ b/tools/demo-data-cli/package-lock.json @@ -19,7 +19,7 @@ "typescript": "^5.4.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=26.8.2" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/tools/demo-data-cli/package.json b/tools/demo-data-cli/package.json index 4102f70a8d..d58f6352fd 100644 --- a/tools/demo-data-cli/package.json +++ b/tools/demo-data-cli/package.json @@ -13,7 +13,7 @@ "dev": "node --import tsx src/index.ts" }, "engines": { - "node": ">=22.0.0" + "node": ">=26.8.2" }, "dependencies": { "commander": "^12.0.0"