diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore index ec7ee8cf4..1cd713e26 100644 --- a/admin-ui/.gitignore +++ b/admin-ui/.gitignore @@ -47,6 +47,10 @@ manifest.json .qodo dist + +# Generated shim files (produced by generate-shims.mjs) +src/sdk/shims/*.ts +!src/sdk/shims/host.ts # TypeScript build info tsconfig.tsbuildinfo client/tsconfig.tsbuildinfo diff --git a/admin-ui/next.config.js b/admin-ui/next.config.js index a8d936baa..c22623bc4 100644 --- a/admin-ui/next.config.js +++ b/admin-ui/next.config.js @@ -10,6 +10,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default { output: 'export', + // Keep the dev server's state out of .next: `next build` writes production + // artifacts there (with output: 'export' a custom distDir would become the + // export destination, so build must stay on the default), and dev runs on a + // .next polluted by a build break dynamic-route matching (/ext/[[...slug]] + // 404s) under Turbopack. + distDir: process.env.NODE_ENV === 'production' ? '.next' : '.next-dev', basePath: '', trailingSlash: true, assetPrefix: '', diff --git a/admin-ui/package.json b/admin-ui/package.json index e68dea540..c51f4b69b 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -29,6 +29,10 @@ }, "./theme": "./src/sdk/theme.ts", "./plugins": "./src/sdk/plugins.ts", + "./plugin-runtime": { + "types": "./src/sdk/plugin-runtime.d.mts", + "import": "./src/sdk/plugin-runtime.mjs" + }, "./plugin-build": { "import": "./src/sdk/plugin-build.mjs" }, @@ -100,7 +104,7 @@ "scripts": { "prepublishOnly": "npm run build", "dev": "next dev", - "build": "NODE_ENV=production next build && tsup && tsc -p tsconfig.sdk.json", + "build": "NODE_ENV=production next build && npm run build:sdk", "build:sdk": "tsup && tsc -p tsconfig.sdk.json", "start": "next start", "lint": "eslint .", diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index 960d81d4d..8c90a5578 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 790cb28b8..04f355835 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 '@/components/ui/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/admin-ui/src/modules/plugins/PluginContext.tsx b/admin-ui/src/modules/plugins/PluginContext.tsx index b9532190b..205eb3a2a 100644 --- a/admin-ui/src/modules/plugins/PluginContext.tsx +++ b/admin-ui/src/modules/plugins/PluginContext.tsx @@ -6,22 +6,24 @@ import React, { type ReactNode, } from 'react'; import * as jsxRuntime from 'react/jsx-runtime'; -import { gql } from '@apollo/client'; -import { - useQuery, - useMutation, - useLazyQuery, - useApolloClient, -} from '@apollo/client/react'; -import { useRouter } from 'next/router'; -import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; -import { toast } from 'react-toastify'; +import * as ReactDOM from 'react-dom'; +import * as ReactDOMClient from 'react-dom/client'; +import * as ApolloClient from '@apollo/client'; +import * as ApolloClientReact from '@apollo/client/react'; +import * as NextRouter from 'next/router'; +import NextLink from 'next/link'; +import NextImage from 'next/image'; +import NextHead from 'next/head'; +import * as ReactIntl from 'react-intl'; +import * as ReactToastify from 'react-toastify'; +import * as ReactHookForm from 'react-hook-form'; +import { definePlugin } from '../../sdk/plugins'; +import { SHARED_DEP_SHIMS } from '../../sdk/plugin-runtime.mjs'; import { usePluginRuntime } from './PluginRuntimeContext'; declare global { interface Window { __UNCHAINED_PLUGIN_DEPS__: Record; - __UNCHAINED_PLUGINS__: Record>; } } @@ -73,42 +75,102 @@ const getPluginBaseUrl = () => { } }; +/** + * Expose the host app's module instances to plugin bundles. Plugin bundles are + * standard ESM with shared dependencies left external; the browser import map + * resolves those bare specifiers to shim modules that re-export the instances + * registered here, so plugins run on the exact same React/Apollo as the host. + */ const setupPluginRuntime = () => { if (typeof window === 'undefined') return; if (window.__UNCHAINED_PLUGIN_DEPS__) return; - window.__UNCHAINED_PLUGINS__ = {}; window.__UNCHAINED_PLUGIN_DEPS__ = { react: React, 'react/jsx-runtime': jsxRuntime, - '@apollo/client': { - gql, - useQuery, - useMutation, - useLazyQuery, - useApolloClient, - }, - '@apollo/client/react': { - useQuery, - useMutation, - useLazyQuery, - useApolloClient, - }, - 'next/router': { useRouter }, - 'react-intl': { useIntl, FormattedMessage, defineMessages }, - 'react-toastify': { toast }, - '@unchainedshop/admin-ui/plugins': { usePluginRuntime }, + 'react-dom': ReactDOM, + 'react-dom/client': ReactDOMClient, + '@apollo/client': ApolloClient, + '@apollo/client/react': ApolloClientReact, + 'next/router': NextRouter, + 'next/link': { default: NextLink }, + 'next/image': { default: NextImage }, + 'next/head': { default: NextHead }, + 'react-intl': ReactIntl, + 'react-toastify': ReactToastify, + 'react-hook-form': ReactHookForm, + '@unchainedshop/admin-ui/plugins': { definePlugin, usePluginRuntime }, }; + + if (process.env.NODE_ENV !== 'production') { + const missing = Object.keys(SHARED_DEP_SHIMS).filter( + (k) => !(k in window.__UNCHAINED_PLUGIN_DEPS__), + ); + if (missing.length > 0) { + console.warn( + `admin-ui plugin runtime: SHARED_DEP_SHIMS has entries not registered in __UNCHAINED_PLUGIN_DEPS__: ${missing.join(', ')}`, + ); + } + } }; -const loadPluginScript = (url: string): Promise => { - return new Promise((resolve, reject) => { +const ensureImportMap = async (baseUrl: string): Promise => { + if ( + document.querySelector('script[type="importmap"][data-unchained-admin-ui]') + ) + return true; + + // Import map not pre-injected (e.g. Next.js dev server on port 3000). + // Fetch it from the backend and inject it dynamically. Import maps must be + // added before any module scripts run, but since plugin ESM hasn't loaded + // yet at this point the timing is safe. + try { + const res = await fetch(`${baseUrl}/admin-ui-importmap.json`, { + cache: 'no-cache', + }); + if (!res.ok) { + console.warn( + 'admin-ui plugin runtime: could not fetch import map from server. ' + + 'Plugins depending on shared host dependencies will fail to load.', + ); + return false; + } + const importMap = await res.json(); + // The import map uses relative URLs (e.g. /admin-ui-sdk/...). When + // injected on a different origin (Next.js dev on port 3000 vs backend + // on port 4010), rewrite them to absolute URLs pointing at the backend. + if (baseUrl && importMap.imports) { + for (const [key, value] of Object.entries(importMap.imports)) { + if (typeof value === 'string' && value.startsWith('/')) { + importMap.imports[key] = `${baseUrl}${value}`; + } + } + } const script = document.createElement('script'); - script.src = url; - script.onload = () => resolve(script); - script.onerror = () => reject(new Error(`Failed to load script: ${url}`)); + script.type = 'importmap'; + script.setAttribute('data-unchained-admin-ui', ''); + script.textContent = JSON.stringify(importMap); document.head.appendChild(script); - }); + return true; + } catch (err) { + console.warn('admin-ui plugin runtime: failed to inject import map:', err); + return false; + } +}; + +const loadPluginModule = async ( + manifest: PluginManifest, + baseUrl: string, +): Promise => { + const url = new URL(manifest.bundleUrl, baseUrl || window.location.origin) + .href; + const mod = await import(/* webpackIgnore: true */ url); + if (mod && Object.keys(mod).length > 0) return mod; + console.error( + `Plugin "${manifest.name}" loaded but exports no components. ` + + 'Rebuild it with the current @unchainedshop/admin-ui/plugin-build.', + ); + return null; }; export const PluginProvider = ({ children }: { children: ReactNode }) => { @@ -118,7 +180,6 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { setupPluginRuntime(); - const scriptElements: HTMLScriptElement[] = []; let cancelled = false; (async () => { @@ -132,25 +193,24 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { return; } const data: PluginManifest[] = await res.json(); - if (cancelled) return; + if (cancelled || !Array.isArray(data) || data.length === 0) { + setLoading(false); + return; + } setManifests(data); + if (!(await ensureImportMap(baseUrl))) { + setLoading(false); + return; + } + if (cancelled) return; + const loaded = new Map(); await Promise.all( data.map(async (manifest) => { try { - const script = await loadPluginScript( - `${baseUrl}${manifest.bundleUrl}`, - ); - scriptElements.push(script); - const mod = window.__UNCHAINED_PLUGINS__?.[manifest.name]; - if (mod) { - loaded.set(manifest.name, mod); - } else { - console.error( - `Plugin "${manifest.name}" loaded but did not register on window.__UNCHAINED_PLUGINS__`, - ); - } + const mod = await loadPluginModule(manifest, baseUrl); + if (mod) loaded.set(manifest.name, mod); } catch (err) { console.error(`Failed to load plugin "${manifest.name}":`, err); } @@ -166,8 +226,6 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { return () => { cancelled = true; - scriptElements.forEach((script) => script.remove()); - window.__UNCHAINED_PLUGINS__ = {}; }; }, []); diff --git a/admin-ui/src/sdk/README.md b/admin-ui/src/sdk/README.md new file mode 100644 index 000000000..7ddb72af4 --- /dev/null +++ b/admin-ui/src/sdk/README.md @@ -0,0 +1,170 @@ +# Admin UI Plugins + +Extend the Unchained admin-ui with your own pages, entity managers, tabs, and +dashboard widgets — without forking it. A plugin is a single ESM bundle plus a +manifest you register on the engine; the admin-ui discovers and loads it at +runtime. + +## How it works + +- Plugins are built with `definePluginConfig()` from + `@unchainedshop/admin-ui/plugin-build` into one standard ESM bundle. +- Dependencies the host app already ships (React, Apollo, react-intl, the + `@unchainedshop/admin-ui/*` SDK modules, ...) are left as bare import + specifiers. In the browser, an import map resolves them to the host's own + module instances, so your plugin runs on the exact same React/Apollo as the + admin-ui. Everything else you import gets bundled in. +- The engine serves your bundle under `/admin-plugins/.js` and a + manifest under `/admin-ui-plugins.json`; the admin-ui loads bundles with a + native dynamic `import()`. + +The module graph (which specifiers are shared vs. bundled) is defined in +[`plugin-runtime.mjs`](./plugin-runtime.mjs) — see `SHARED_DEP_SHIMS` and +`SDK_ENTRY_KEYS`. + +## Writing a plugin + +Minimal project layout (see `examples/kitchensink/plugins/bookmark-manager` +for a complete one): + +``` +my-plugin/ +├── package.json +├── tsup.config.ts +└── src/ + └── index.tsx +``` + +`package.json`: + +```json +{ + "name": "my-plugin", + "type": "module", + "scripts": { "build": "tsup", "build:watch": "tsup --watch" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@unchainedshop/admin-ui": "^5.0.0", + "esbuild": "^0.25.0", + "react": "^19.0.0", + "tsup": "^8.0.0" + } +} +``` + +`tsup.config.ts`: + +```ts +import { definePluginConfig } from '@unchainedshop/admin-ui/plugin-build'; + +export default definePluginConfig('my-plugin'); +``` + +`src/index.tsx` — export every component your manifest references, as named +exports: + +```tsx +export { default as ThingList } from './components/ThingList'; +export { default as ThingDetail } from './components/ThingDetail'; +export { default as ThingWidget } from './components/ThingWidget'; +``` + +Inside components you can import from the host SDK: + +```tsx +import { Button } from '@unchainedshop/admin-ui/ui'; +import { useForm } from '@unchainedshop/admin-ui/form'; +import { useProducts } from '@unchainedshop/admin-ui/modules/product'; +import { usePluginRuntime } from '@unchainedshop/admin-ui/plugins'; +import { useQuery } from '@apollo/client/react'; +import { FormattedMessage } from 'react-intl'; +``` + +## Registering a plugin on the engine + +Pass the manifest in the `adminUI.plugins` option of the Express or Fastify +`connect()`: + +```ts +adminUI: { + plugins: [ + { + name: 'my-plugin', // [a-z0-9._-], used in URLs + version: '1.0.0', // optional, informational + bundlePath: resolve(__dirname, '../plugins/my-plugin/dist/index.js'), + navigation: { // optional: groups nav items in a submenu + label: 'My Plugin', + icon: 'bookmark', // heroicon name + requiredRole: 'viewProducts', + sortOrder: 75, // position among sidebar items, see below + }, + slots: { + entities: [ + { + path: '/things', // page lives at /ext/things + label: 'Things', + icon: 'bookmark', + requiredRole: 'viewProducts', + sortOrder: 75, + components: { + list: 'ThingList', // must match a named export of the bundle + detail: 'ThingDetail', + create: 'ThingCreate' // optional + }, + }, + ], + pages: [ + { path: '/reports', label: 'Reports', component: 'ReportsPage' }, + ], + 'dashboard:widgets': [ + { component: 'ThingWidget', width: 'half' }, // full | half | third + ], + 'product:tabs': [ + { label: 'Things', component: 'ProductThingsTab' }, + ], + }, + }, + ], +} +``` + +Slot types: `entities` (list/detail/create pages under `/ext/`), +`pages` (single custom page under `/ext/`), `dashboard:widgets`, and +`:tabs` for `product`, `assortment`, `filter`, `user`, `order`. + +- `requiredRole` — role name checked against the logged-in user; the item is + hidden without it. +- `sortOrder` — position in the sidebar. Built-in items use 0–130 in steps of + 10 (Orders 30, Products 40, Users 70, System settings 110, ...). Items + without a `sortOrder` keep their relative order after ordered ones. + +At startup the engine validates the manifest against the bundle and logs a +warning if a referenced component is not exported, if two plugins share a +name, or if the bundle was built against a different admin-ui SDK version +than the one running. + +## Development workflow + +Run the plugin build in watch mode next to the engine: + +```bash +cd my-plugin && npm run build:watch +``` + +In dev mode (`NODE_ENV !== 'production'`) the engine picks up bundle changes +on the next manifest request — reload the admin-ui to get the new bundle. In +production, bundles are read once at startup and served with immutable +caching, keyed by content hash. + +## Content-Security-Policy + +The engine injects the import map as an inline ``; + } else { + log.warn( + 'admin-ui plugin runtime: SDK dist files not found; plugins depending on shared modules will fail to load', + ); + } + } + + return { routes, validPlugins, importMapTag, importMapJSON: importMapContent }; +} + +export function buildImportMapTag(importMapJSON: string, nonce?: string): string { + const nonceAttr = nonce ? ` nonce="${nonce}"` : ''; + return ``; } diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts index 330273536..458478330 100644 --- a/packages/api/src/express/index.ts +++ b/packages/api/src/express/index.ts @@ -4,7 +4,7 @@ import type { YogaServerInstance } from 'graphql-yoga'; import type { UnchainedCore } from '@unchainedshop/core'; import { pluginRegistry } from '@unchainedshop/core'; import { createHash } from 'node:crypto'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { getCurrentContextResolver } from '../context.ts'; @@ -16,7 +16,12 @@ import { connectChat } from './chatHandler.ts'; import { mountRoutes } from './mountRoutes.ts'; import { createBackchannelLogoutRoute } from '../handlers/createBackchannelLogoutHandler.ts'; import { generateThemeCSS, type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme'; -import { preparePluginAssets, resolveAdminUIPath, type AdminUIPluginConfig } from '../adminUiPlugins.ts'; +import { + preparePluginAssets, + buildImportMapTag, + resolveAdminUIPath, + type AdminUIPluginConfig, +} from '../adminUiPlugins.ts'; export type { AdminUIPluginConfig, @@ -63,7 +68,11 @@ export const adminUIRouter = ( const log = { info: console.info, warn: console.warn }; const devMode = process.env.NODE_ENV !== 'production'; - const { routes: pluginRoutes } = preparePluginAssets(plugins, log, { devMode }); + const { + routes: pluginRoutes, + importMapTag, + importMapJSON, + } = preparePluginAssets(plugins, log, { devMode }); for (const [path, asset] of pluginRoutes) { router.get(path, (req, res) => { @@ -83,22 +92,57 @@ export const adminUIRouter = ( }); } - router.use(e.static(adminUIPath)); + // With an import map to inject, index.html must not be served directly by + // the static handler so that / falls through to the injecting catch-all. + router.use(e.static(adminUIPath, importMapTag ? { index: false } : undefined)); // SPA fallback: the admin-ui is a Next.js static export where plugin // entity/page routes live under /ext/*, pre-rendered to a dedicated HTML // file. Hard loads of /ext/* must get that file, everything else gets the // root index.html. - const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); - const hasExtHtml = existsSync(extHtmlPath); + if (importMapJSON) { + const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const extHtmlRaw = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; + + // Pre-build non-nonce versions for when no CSP nonce is present + const defaultTag = importMapTag!; + const injectedHtml = indexHtml.replace('', `${defaultTag}`); + const extHtml = extHtmlRaw ? extHtmlRaw.replace('', `${defaultTag}`) : null; + + router.get(/(.*)/, (req, res) => { + if (devMode) res.set('Cache-Control', 'no-cache'); + const urlPath = req.path.replace(/\/+$/, ''); + + // CSP nonce convention (Express/helmet): a middleware upstream sets + // res.locals.cspNonce (see helmet's CSP nonce docs) and references it + // as `'nonce-...'` in script-src. When present, the injected import + // map tag carries it; without it, strict CSPs will block the tag. + const nonce = (res as any).locals?.cspNonce as string | undefined; + if (nonce) { + const tag = buildImportMapTag(importMapJSON, nonce); + const baseHtml = + extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/')) ? extHtmlRaw! : indexHtml; + return res.type('text/html').send(baseHtml.replace('', `${tag}`)); + } - router.get(/(.*)/, (req, res) => { - const urlPath = req.path.replace(/\/+$/, ''); - if (hasExtHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { - return res.sendFile(extHtmlPath); - } - return res.sendFile(join(adminUIPath, 'index.html')); - }); + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return res.type('text/html').send(extHtml); + } + return res.type('text/html').send(injectedHtml); + }); + } else { + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const hasExtHtml = existsSync(extHtmlPath); + + router.get(/(.*)/, (req, res) => { + const urlPath = req.path.replace(/\/+$/, ''); + if (hasExtHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return res.sendFile(extHtmlPath); + } + return res.sendFile(join(adminUIPath, 'index.html')); + }); + } } return router; diff --git a/packages/api/src/fastify/index.ts b/packages/api/src/fastify/index.ts index 62f4a5744..9177a5ddf 100644 --- a/packages/api/src/fastify/index.ts +++ b/packages/api/src/fastify/index.ts @@ -16,7 +16,12 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { createBackchannelLogoutRoute } from '../handlers/createBackchannelLogoutHandler.ts'; import { generateThemeCSS, type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme'; -import { preparePluginAssets, resolveAdminUIPath, type AdminUIPluginConfig } from '../adminUiPlugins.ts'; +import { + preparePluginAssets, + buildImportMapTag, + resolveAdminUIPath, + type AdminUIPluginConfig, +} from '../adminUiPlugins.ts'; export type { AdminUIPluginConfig, @@ -34,6 +39,8 @@ export interface AdminUIRouterOptions { enabled?: boolean; theme?: AdminUIThemeConfig; plugins?: AdminUIPluginConfig[]; + importMapTag?: string | null; + importMapJSON?: string | null; } /** @@ -245,7 +252,11 @@ export const connect = async ( }); const devMode = process.env.NODE_ENV !== 'production'; - const { routes: pluginRoutes } = preparePluginAssets(adminUIPlugins, fastify.log, { + const { + routes: pluginRoutes, + importMapTag, + importMapJSON, + } = preparePluginAssets(adminUIPlugins, fastify.log, { devMode, }); @@ -272,6 +283,8 @@ export const connect = async ( enabled: true, prefix: adminUIOptions?.prefix || '/', plugins: adminUIPlugins, + importMapTag, + importMapJSON, }); } }; @@ -313,26 +326,70 @@ export const adminUIRouter: FastifyPluginAsync = async ( // entity/page routes live under /ext/*, pre-rendered to a dedicated // HTML file. Hard loads of /ext/* must get that file, other non-file // paths fall back to the root index.html. - const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); - const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); - const extHtml = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; - - await fastify.register(fastifyStatic, { - root: adminUIPath, - prefix: opts.prefix || '/', - wildcard: false, - }); - - fastify.setNotFoundHandler(async (request, reply) => { - if (request.method === 'GET' && !request.url.includes('.')) { - const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); - if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { - return reply.type('text/html').send(extHtml); + if (opts.importMapJSON) { + const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const extHtmlRaw = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; + + const defaultTag = opts.importMapTag!; + const injectedHtml = indexHtml.replace('', `${defaultTag}`); + const extHtml = extHtmlRaw ? extHtmlRaw.replace('', `${defaultTag}`) : null; + + await fastify.register(fastifyStatic, { + root: adminUIPath, + prefix: opts.prefix || '/', + wildcard: false, + index: false, + }); + + fastify.setNotFoundHandler(async (request, reply) => { + if (request.method === 'GET' && !request.url.includes('.')) { + if (process.env.NODE_ENV !== 'production') reply.header('Cache-Control', 'no-cache'); + const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); + + // CSP nonce convention (@fastify/helmet with enableCSPNonces: + // true): the plugin decorates reply.cspNonce = { script, style }. + // When present, the injected import map tag carries the script + // nonce; without it, strict CSPs will block the tag. + const nonce = (reply as any).cspNonce?.script as string | undefined; + if (nonce) { + const tag = buildImportMapTag(opts.importMapJSON!, nonce); + const baseHtml = + extHtmlRaw && (urlPath === '/ext' || urlPath.startsWith('/ext/')) + ? extHtmlRaw + : indexHtml; + return reply.type('text/html').send(baseHtml.replace('', `${tag}`)); + } + + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return reply.type('text/html').send(extHtml); + } + return reply.type('text/html').send(injectedHtml); } - return reply.type('text/html').send(indexHtml); - } - return reply.code(404).send({ error: 'Not Found' }); - }); + return reply.code(404).send({ error: 'Not Found' }); + }); + } else { + const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const extHtml = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; + + await fastify.register(fastifyStatic, { + root: adminUIPath, + prefix: opts.prefix || '/', + wildcard: false, + }); + + fastify.setNotFoundHandler(async (request, reply) => { + if (request.method === 'GET' && !request.url.includes('.')) { + const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return reply.type('text/html').send(extHtml); + } + return reply.type('text/html').send(indexHtml); + } + return reply.code(404).send({ error: 'Not Found' }); + }); + } return; } } diff --git a/packages/plugins/src/payment/datatrans-v2/api/types.ts b/packages/plugins/src/payment/datatrans-v2/api/types.ts index f8dc41462..043a512aa 100644 --- a/packages/plugins/src/payment/datatrans-v2/api/types.ts +++ b/packages/plugins/src/payment/datatrans-v2/api/types.ts @@ -60,18 +60,7 @@ export interface DT2015Configuration { } export type SupportedLanguage = - | 'de' - | 'en' - | 'fr' - | 'it' - | 'es' - | 'el' - | 'no' - | 'da' - | 'pl' - | 'pt' - | 'ru' - | 'ja'; + 'de' | 'en' | 'fr' | 'it' | 'es' | 'el' | 'no' | 'da' | 'pl' | 'pt' | 'ru' | 'ja'; export type PaymentMethod = | 'ACC' diff --git a/packages/plugins/src/payment/stripe/stripe.ts b/packages/plugins/src/payment/stripe/stripe.ts index 26ba6e2a8..05c447545 100644 --- a/packages/plugins/src/payment/stripe/stripe.ts +++ b/packages/plugins/src/payment/stripe/stripe.ts @@ -14,7 +14,7 @@ if (STRIPE_SECRET) { try { const { default: Stripe } = await import('stripe'); stripe = new Stripe(STRIPE_SECRET, { - apiVersion: '2026-04-22.dahlia', + apiVersion: '2026-06-24.dahlia', }); } catch { logger.warn(`optional peer npm package 'stripe' not installed, stripe adapter will not work`); diff --git a/tools/demo-data-cli/src/types/bulk-import.ts b/tools/demo-data-cli/src/types/bulk-import.ts index 29501569a..0be950f19 100644 --- a/tools/demo-data-cli/src/types/bulk-import.ts +++ b/tools/demo-data-cli/src/types/bulk-import.ts @@ -29,11 +29,7 @@ export interface ProductLocalizedContent extends LocalizedContent { // Product Types export type ProductType = - | 'SIMPLE_PRODUCT' - | 'CONFIGURABLE_PRODUCT' - | 'BUNDLE_PRODUCT' - | 'PLAN_PRODUCT' - | 'TOKENIZED_PRODUCT'; + 'SIMPLE_PRODUCT' | 'CONFIGURABLE_PRODUCT' | 'BUNDLE_PRODUCT' | 'PLAN_PRODUCT' | 'TOKENIZED_PRODUCT'; export interface ProductPricing { amount: number;