@unchainedshop/api 4.8.12 → 4.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ export interface AdminUIThemeTokens {
2
+ surface?: string;
3
+ 'surface-subtle'?: string;
4
+ 'surface-raised'?: string;
5
+ 'surface-input'?: string;
6
+ border?: string;
7
+ 'border-subtle'?: string;
8
+ 'text-primary'?: string;
9
+ 'text-secondary'?: string;
10
+ 'text-muted'?: string;
11
+ 'text-on-dark'?: string;
12
+ accent?: string;
13
+ 'accent-hover'?: string;
14
+ danger?: string;
15
+ 'danger-surface'?: string;
16
+ success?: string;
17
+ warning?: string;
18
+ 'focus-ring'?: string;
19
+ 'text-on-accent'?: string;
20
+ }
21
+ export interface AdminUIThemeConfig {
22
+ light?: AdminUIThemeTokens;
23
+ dark?: AdminUIThemeTokens;
24
+ }
25
+ export declare const generateThemeCSS: (theme?: AdminUIThemeConfig) => string;
@@ -0,0 +1,34 @@
1
+ const CSS_VALUE_RE = /^[a-zA-Z0-9#().,/%\s\-_]+$/;
2
+ const sanitizeCSSValue = (value) => {
3
+ const trimmed = value.trim();
4
+ if (!trimmed || !CSS_VALUE_RE.test(trimmed))
5
+ return null;
6
+ return trimmed;
7
+ };
8
+ const buildTokenBlock = (selector, tokens) => {
9
+ const vars = Object.entries(tokens)
10
+ .map(([key, value]) => {
11
+ const safe = sanitizeCSSValue(value);
12
+ return safe ? ` --token-${key}: ${safe};` : null;
13
+ })
14
+ .filter(Boolean);
15
+ if (vars.length === 0)
16
+ return null;
17
+ return `${selector} {\n${vars.join('\n')}\n}`;
18
+ };
19
+ export const generateThemeCSS = (theme) => {
20
+ if (!theme)
21
+ return '/* default theme */';
22
+ const blocks = [];
23
+ if (theme.light) {
24
+ const block = buildTokenBlock(':root:root', theme.light);
25
+ if (block)
26
+ blocks.push(block);
27
+ }
28
+ if (theme.dark) {
29
+ const block = buildTokenBlock('.dark.dark', theme.dark);
30
+ if (block)
31
+ blocks.push(block);
32
+ }
33
+ return blocks.length > 0 ? blocks.join('\n\n') : '/* default theme */';
34
+ };
@@ -17,15 +17,27 @@ catch {
17
17
  const transports = {};
18
18
  const handlePostRequest = async (req, res) => {
19
19
  const sessionId = req.headers['mcp-session-id'];
20
+ const currentUserId = req.unchainedContext.user._id;
20
21
  let transport;
21
22
  if (sessionId && transports[sessionId]) {
22
- transport = transports[sessionId];
23
+ if (transports[sessionId].userId !== currentUserId) {
24
+ res.status(404).json({
25
+ jsonrpc: '2.0',
26
+ error: {
27
+ code: -32000,
28
+ message: 'Bad Request: No valid session ID provided',
29
+ },
30
+ id: null,
31
+ });
32
+ return;
33
+ }
34
+ transport = transports[sessionId].transport;
23
35
  }
24
36
  else if (!sessionId && isInitializeRequest(req.body)) {
25
37
  transport = new StreamableHTTPServerTransport({
26
38
  sessionIdGenerator: () => crypto.randomUUID(),
27
39
  onsessioninitialized: (sessionId) => {
28
- transports[sessionId] = transport;
40
+ transports[sessionId] = { transport, userId: currentUserId };
29
41
  },
30
42
  });
31
43
  transport.onclose = () => {
@@ -56,15 +68,18 @@ const handlePostRequest = async (req, res) => {
56
68
  };
57
69
  const handleSessionRequest = async (req, res) => {
58
70
  const sessionId = req.headers['mcp-session-id'];
59
- if (!sessionId || !transports[sessionId]) {
71
+ if (!sessionId ||
72
+ !transports[sessionId] ||
73
+ transports[sessionId].userId !== req.unchainedContext.user._id) {
60
74
  res.status(400).send('Invalid or missing session ID');
61
75
  return;
62
76
  }
63
- const transport = transports[sessionId];
77
+ const transport = transports[sessionId].transport;
64
78
  await transport.handleRequest(req, res);
65
79
  };
66
80
  const createMCPMiddleware = (req, res, next) => {
67
- if (!req.unchainedContext.user) {
81
+ const user = req.unchainedContext.user;
82
+ if (!user) {
68
83
  res.status(401);
69
84
  res.header('WWW-Authenticate', `Bearer realm="Unchained MCP", error="invalid_token", resource="${process.env.ROOT_URL || 'http://localhost:4010'}",`);
70
85
  res.json({
@@ -73,6 +88,10 @@ const createMCPMiddleware = (req, res, next) => {
73
88
  });
74
89
  return;
75
90
  }
91
+ if (!(user.roles || []).includes('admin')) {
92
+ res.status(403).json({ error: 'forbidden', message: 'MCP requires admin privileges' });
93
+ return;
94
+ }
76
95
  if (req.method === 'POST') {
77
96
  return handlePostRequest(req, res, next);
78
97
  }
@@ -16,7 +16,8 @@ catch {
16
16
  }
17
17
  const transports = {};
18
18
  const mcpHandler = async (req, res) => {
19
- if (!req.unchainedContext.user) {
19
+ const user = req.unchainedContext.user;
20
+ if (!user) {
20
21
  res.status(401);
21
22
  res.header('WWW-Authenticate', `Bearer realm="Unchained MCP", error="invalid_token", resource="${process.env.ROOT_URL || 'http://localhost:4010'}",`);
22
23
  return res.send(JSON.stringify({
@@ -24,18 +25,33 @@ const mcpHandler = async (req, res) => {
24
25
  resource_metadata: `${process.env.ROOT_URL || 'http://localhost:4010'}/.well-known/oauth-protected-resource`,
25
26
  }));
26
27
  }
28
+ if (!(user.roles || []).includes('admin')) {
29
+ res.status(403);
30
+ return res.send(JSON.stringify({ error: 'forbidden', message: 'MCP requires admin privileges' }));
31
+ }
32
+ const currentUserId = user._id;
27
33
  try {
28
34
  if (req.method === 'POST') {
29
35
  const sessionId = req.headers['mcp-session-id'];
30
36
  let transport;
31
37
  if (sessionId && transports[sessionId]) {
32
- transport = transports[sessionId];
38
+ if (transports[sessionId].userId !== currentUserId) {
39
+ return res.status(404).send(JSON.stringify({
40
+ jsonrpc: '2.0',
41
+ error: {
42
+ code: -32000,
43
+ message: 'Bad Request: No valid session ID provided',
44
+ },
45
+ id: null,
46
+ }));
47
+ }
48
+ transport = transports[sessionId].transport;
33
49
  }
34
50
  else if (!sessionId && isInitializeRequest(req.body)) {
35
51
  transport = new StreamableHTTPServerTransport({
36
52
  sessionIdGenerator: () => crypto.randomUUID(),
37
53
  onsessioninitialized: (sessionId) => {
38
- transports[sessionId] = transport;
54
+ transports[sessionId] = { transport, userId: currentUserId };
39
55
  },
40
56
  });
41
57
  transport.onclose = () => {
@@ -43,7 +59,7 @@ const mcpHandler = async (req, res) => {
43
59
  delete transports[transport.sessionId];
44
60
  }
45
61
  };
46
- const roles = req.unchainedContext.user.roles || [];
62
+ const roles = user.roles || [];
47
63
  const { default: initMCPServer } = await import("../mcp/index.js");
48
64
  const server = initMCPServer(new McpServer({
49
65
  name: 'Unchained MCP Server',
@@ -66,10 +82,10 @@ const mcpHandler = async (req, res) => {
66
82
  }
67
83
  else if (req.method === 'GET' || req.method === 'DELETE') {
68
84
  const sessionId = req.headers['mcp-session-id'];
69
- if (!sessionId || !transports[sessionId]) {
85
+ if (!sessionId || !transports[sessionId] || transports[sessionId].userId !== currentUserId) {
70
86
  return res.status(400).send('Invalid or missing session ID');
71
87
  }
72
- const transport = transports[sessionId];
88
+ const transport = transports[sessionId].transport;
73
89
  await transport.handleRequest(req.raw, res.raw);
74
90
  return res;
75
91
  }
@@ -0,0 +1,13 @@
1
+ import type { UnchainedCore } from '@unchainedshop/core';
2
+ import type { Order } from '@unchainedshop/core-orders';
3
+ import DataLoader from 'dataloader';
4
+ declare const _default: (unchainedAPI: UnchainedCore) => DataLoader<{
5
+ userId: string;
6
+ countryCode?: string;
7
+ orderNumber?: string;
8
+ }, Order | null, {
9
+ userId: string;
10
+ countryCode?: string;
11
+ orderNumber?: string;
12
+ }>;
13
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import DataLoader from 'dataloader';
2
+ export default (unchainedAPI) => new DataLoader(async (queries) => {
3
+ const userIds = [...new Set(queries.map((q) => q.userId).filter(Boolean))];
4
+ const carts = await unchainedAPI.modules.orders.findCarts({ userIds });
5
+ return queries.map((q) => carts.find((cart) => cart.userId === q.userId &&
6
+ (!q.countryCode || cart.countryCode === q.countryCode) &&
7
+ (!q.orderNumber || cart.orderNumber === q.orderNumber)) || null);
8
+ });
@@ -166,6 +166,15 @@ declare const loaders: (unchainedAPI: UnchainedCore) => {
166
166
  }, import("@unchainedshop/core-orders").Order, {
167
167
  orderId: string;
168
168
  }>;
169
+ cartLoader: import("dataloader")<{
170
+ userId: string;
171
+ countryCode?: string;
172
+ orderNumber?: string;
173
+ }, import("@unchainedshop/core-orders").Order | null, {
174
+ userId: string;
175
+ countryCode?: string;
176
+ orderNumber?: string;
177
+ }>;
169
178
  orderPaymentLoader: import("dataloader")<{
170
179
  orderPaymentId: string;
171
180
  }, import("@unchainedshop/core-orders").OrderPayment | null, {
@@ -26,6 +26,7 @@ import deliveryProviderLoader from "./deliveryProviderLoader.js";
26
26
  import paymentProviderLoader from "./paymentProviderLoader.js";
27
27
  import warehousingProviderLoader from "./warehousingProviderLoader.js";
28
28
  import orderLoader from "./orderLoader.js";
29
+ import cartLoader from "./cartLoader.js";
29
30
  import orderPaymentLoader from "./orderPaymentLoader.js";
30
31
  import orderDeliveryLoader from "./orderDeliveryLoader.js";
31
32
  import orderPositionsLoader from "./orderPositionsLoader.js";
@@ -63,6 +64,7 @@ const loaders = (unchainedAPI) => {
63
64
  paymentProviderLoader: paymentProviderLoader(unchainedAPI),
64
65
  warehousingProviderLoader: warehousingProviderLoader(unchainedAPI),
65
66
  orderLoader: orderLoader(unchainedAPI),
67
+ cartLoader: cartLoader(unchainedAPI),
66
68
  orderPaymentLoader: orderPaymentLoader(unchainedAPI),
67
69
  orderDeliveryLoader: orderDeliveryLoader(unchainedAPI),
68
70
  orderPositionsLoader: orderPositionsLoader(unchainedAPI),
@@ -65,9 +65,8 @@ export async function createAuthContext(params, authConfig) {
65
65
  const fingerprintCookie = getCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME);
66
66
  const verifyToken = createAuthHandler(authConfig);
67
67
  const authResult = token ? await verifyToken(token) : {};
68
- let fingerprintValid = true;
69
68
  if (authResult.fingerprintHash && fingerprintCookie) {
70
- fingerprintValid = verifyFingerprint(fingerprintCookie, authResult.fingerprintHash);
69
+ const fingerprintValid = verifyFingerprint(fingerprintCookie, authResult.fingerprintHash);
71
70
  if (!fingerprintValid) {
72
71
  logger.warn('Token sidejacking detected: fingerprint mismatch', {
73
72
  userId: authResult.userId,
@@ -81,7 +80,6 @@ export async function createAuthContext(params, authConfig) {
81
80
  logger.warn('Token sidejacking detected: fingerprint cookie missing', {
82
81
  userId: authResult.userId,
83
82
  });
84
- fingerprintValid = false;
85
83
  authResult.userId = undefined;
86
84
  authResult.tokenVersion = undefined;
87
85
  authResult.impersonatorId = undefined;
@@ -231,7 +231,7 @@ export default {
231
231
  addCartProduct: acl(actions.updateCart)(addCartProduct),
232
232
  addMultipleCartProducts: acl(actions.updateCart)(addMultipleCartProducts),
233
233
  addCartDiscount: acl(actions.updateCart)(addCartDiscount),
234
- addCartQuotation: acl(actions.updateCart)(addCartQuotation),
234
+ addCartQuotation: acl(actions.addCartQuotation)(addCartQuotation),
235
235
  updateCart: acl(actions.updateCart)(updateCart),
236
236
  emptyCart: acl(actions.updateCart)(emptyCart),
237
237
  checkoutCart: acl(actions.checkoutCart)(checkoutCart),
@@ -301,7 +301,7 @@ export default {
301
301
  verifyQuotation: acl(actions.manageQuotations)(verifyQuotation),
302
302
  makeQuotationProposal: acl(actions.manageQuotations)(makeQuotationProposal),
303
303
  bookmark: acl(actions.bookmarkProduct)(bookmark),
304
- createBookmark: acl(actions.manageBookmarks)(createBookmark),
304
+ createBookmark: acl(actions.createBookmark)(createBookmark),
305
305
  removeBookmark: acl(actions.manageBookmarks)(removeBookmark),
306
306
  addWork: acl(actions.manageWorker)(addWork),
307
307
  allocateWork: acl(actions.manageWorker)(allocateWork),
@@ -309,6 +309,6 @@ export default {
309
309
  removeWork: acl(actions.manageWorker)(removeWork),
310
310
  processNextWork: acl(actions.manageWorker)(processNextWork),
311
311
  signPaymentProviderForCredentialRegistration: acl(actions.registerPaymentCredentials)(signPaymentProviderForCredentialRegistration),
312
- signPaymentProviderForCheckout: acl(actions.registerPaymentCredentials)(signPaymentProviderForCheckout),
312
+ signPaymentProviderForCheckout: acl(actions.updateOrderPayment)(signPaymentProviderForCheckout),
313
313
  removeUserProductReviews: acl(actions.updateUser)(removeUserProductReviews),
314
314
  };
@@ -49,9 +49,9 @@ export const User = {
49
49
  return context.modules.bookmarks.findBookmarksByUserId(user._id);
50
50
  },
51
51
  async cart(user, params, context) {
52
- const { modules, countryCode } = context;
52
+ const { loaders, countryCode } = context;
53
53
  await checkAction(context, viewUserOrders, [user, params]);
54
- return modules.orders.cart({
54
+ return loaders.cartLoader.load({
55
55
  countryCode,
56
56
  orderNumber: params.orderNumber,
57
57
  userId: user._id,
@@ -66,6 +66,7 @@ const actions = [
66
66
  'createEnrollment',
67
67
  'updateEnrollment',
68
68
  'updateCart',
69
+ 'addCartQuotation',
69
70
  'checkoutCart',
70
71
  'updateOrder',
71
72
  'updateOrderDelivery',
@@ -90,6 +91,7 @@ const actions = [
90
91
  'manageQuotations',
91
92
  'answerQuotation',
92
93
  'bookmarkProduct',
94
+ 'createBookmark',
93
95
  'manageBookmarks',
94
96
  'search',
95
97
  'manageWorker',
@@ -116,6 +116,14 @@ export const loggedIn = (role, actions) => {
116
116
  return true;
117
117
  return bookmark.userId === userId;
118
118
  };
119
+ const isOwnBookmarkUser = (obj, { userId: bookmarkUserId }, { userId }) => {
120
+ return !bookmarkUserId || bookmarkUserId === userId;
121
+ };
122
+ const isOwnedCartAndQuotation = async (obj, params, context) => {
123
+ if (!(await isOwnedOrderOrCart(obj, params, context)))
124
+ return false;
125
+ return isOwnedQuotation(obj, params, context);
126
+ };
119
127
  const isOwnedPaymentCredential = async (obj, { paymentCredentialsId }, { modules, userId }) => {
120
128
  const credentials = await modules.payment.paymentCredentials.findPaymentCredential({
121
129
  paymentCredentialsId,
@@ -155,6 +163,7 @@ export const loggedIn = (role, actions) => {
155
163
  role.allow(actions.updateOrderPayment, isOwnedOrderPayment);
156
164
  role.allow(actions.checkoutCart, isOwnedOrderOrCart);
157
165
  role.allow(actions.updateCart, isOwnedOrderOrCart);
166
+ role.allow(actions.addCartQuotation, isOwnedCartAndQuotation);
158
167
  role.allow(actions.createCart, () => true);
159
168
  role.allow(actions.viewEnrollment, isOwnedEnrollment);
160
169
  role.allow(actions.updateEnrollment, isOwnedEnrollment);
@@ -164,6 +173,7 @@ export const loggedIn = (role, actions) => {
164
173
  role.allow(actions.requestQuotation, () => true);
165
174
  role.allow(actions.answerQuotation, isOwnedQuotation);
166
175
  role.allow(actions.manageBookmarks, isOwnedBookmark);
176
+ role.allow(actions.createBookmark, isOwnBookmarkUser);
167
177
  role.allow(actions.bookmarkProduct, () => true);
168
178
  role.allow(actions.voteProductReview, () => true);
169
179
  role.allow(actions.changePassword, () => true);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unchainedshop/api",
3
3
  "description": "GraphQL API layer for the Unchained Engine with Express/Fastify adapters and MCP server",
4
- "version": "4.8.12",
4
+ "version": "4.8.14",
5
5
  "main": "lib/api-index.js",
6
6
  "types": "lib/api-index.d.ts",
7
7
  "type": "module",
@@ -116,11 +116,11 @@
116
116
  }
117
117
  },
118
118
  "dependencies": {
119
- "@unchainedshop/core": "^4.6.0",
120
- "@unchainedshop/events": "^4.6.0",
121
- "@unchainedshop/logger": "^4.6.0",
122
- "@unchainedshop/roles": "^4.6.0",
123
- "@unchainedshop/utils": "^4.6.0",
119
+ "@unchainedshop/core": "^4.8.12",
120
+ "@unchainedshop/events": "^4.8.12",
121
+ "@unchainedshop/logger": "^4.8.12",
122
+ "@unchainedshop/roles": "^4.8.12",
123
+ "@unchainedshop/utils": "^4.8.12",
124
124
  "dataloader": "^2.2.3",
125
125
  "expiry-map": "^2.0.0",
126
126
  "graphql-scalars": "^1.24.2",