@unchainedshop/api 5.0.0-alpha.3 → 5.0.0-alpha.4

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.
package/README.md CHANGED
@@ -168,8 +168,8 @@ The API layer implements comprehensive security controls.
168
168
  |----------|---------|---------|
169
169
  | `UNCHAINED_TOKEN_SECRET` | Session encryption (min 32 chars) | Required |
170
170
  | `UNCHAINED_COOKIE_NAME` | Cookie name | `unchained_token` |
171
- | `UNCHAINED_COOKIE_SAMESITE` | SameSite attribute | `none` |
172
- | `UNCHAINED_COOKIE_INSECURE` | Disable secure flag | `false` |
171
+ | `UNCHAINED_COOKIE_SAMESITE` | SameSite attribute | `lax` |
172
+ | `UNCHAINED_COOKIE_INSECURE` | Disable secure flag (development only) | `false` |
173
173
 
174
174
  Cookies are `httpOnly` and `secure` by default.
175
175
 
@@ -1,3 +1,27 @@
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
+ }
1
25
  export interface AdminUIPluginEntityConfig {
2
26
  path: string;
3
27
  label: string;
package/lib/auth.d.ts CHANGED
@@ -2,7 +2,6 @@ export interface AccessTokenPayload {
2
2
  iss: string;
3
3
  sub: string;
4
4
  ver: number;
5
- fgp?: string;
6
5
  imp?: string;
7
6
  jti?: string;
8
7
  iat?: number;
@@ -16,14 +15,8 @@ export interface OIDCProviderConfig {
16
15
  export interface AuthConfig {
17
16
  oidcProviders?: OIDCProviderConfig[];
18
17
  }
19
- export declare function generateFingerprint(): {
20
- raw: string;
21
- hash: string;
22
- };
23
- export declare function verifyFingerprint(raw: string, hash: string): boolean;
24
18
  export declare function signAccessToken(userId: string, tokenVersion: number, options?: {
25
19
  impersonatorId?: string;
26
- fingerprintHash?: string;
27
20
  }): Promise<{
28
21
  token: string;
29
22
  expires: Date;
@@ -37,7 +30,6 @@ export interface AuthHandlerResult {
37
30
  userId?: string;
38
31
  tokenVersion?: number;
39
32
  impersonatorId?: string;
40
- fingerprintHash?: string;
41
33
  accessToken?: string;
42
34
  isApiKey?: boolean;
43
35
  }
package/lib/auth.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createLogger } from '@unchainedshop/logger';
2
2
  import * as jose from 'jose';
3
- import { createHash } from 'crypto';
4
3
  const logger = createLogger('unchained:api:auth');
5
4
  const { UNCHAINED_TOKEN_SECRET, UNCHAINED_TOKEN_EXPIRY_SECONDS = '3600', UNCHAINED_TOKEN_ISSUER = 'unchained-engine', } = process.env;
6
5
  const MIN_SECRET_LENGTH = 32;
@@ -11,21 +10,6 @@ function validateSecretStrength(secret) {
11
10
  `Current length: ${secret.length}. Generate a secure secret with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`);
12
11
  }
13
12
  }
14
- export function generateFingerprint() {
15
- const raw = crypto.randomUUID() + crypto.randomUUID();
16
- const hash = createHash('sha256').update(raw).digest('hex');
17
- return { raw, hash };
18
- }
19
- export function verifyFingerprint(raw, hash) {
20
- const computedHash = createHash('sha256').update(raw).digest('hex');
21
- if (computedHash.length !== hash.length)
22
- return false;
23
- let result = 0;
24
- for (let i = 0; i < computedHash.length; i++) {
25
- result |= computedHash.charCodeAt(i) ^ hash.charCodeAt(i);
26
- }
27
- return result === 0;
28
- }
29
13
  export async function signAccessToken(userId, tokenVersion, options) {
30
14
  if (!UNCHAINED_TOKEN_SECRET) {
31
15
  throw new Error('UNCHAINED_TOKEN_SECRET environment variable is required');
@@ -38,9 +22,6 @@ export async function signAccessToken(userId, tokenVersion, options) {
38
22
  ver: tokenVersion,
39
23
  jti: crypto.randomUUID(),
40
24
  };
41
- if (options?.fingerprintHash) {
42
- payload.fgp = options.fingerprintHash;
43
- }
44
25
  if (options?.impersonatorId) {
45
26
  payload.imp = options.impersonatorId;
46
27
  }
@@ -168,7 +149,6 @@ export function createAuthHandler(config) {
168
149
  userId: localPayload.sub,
169
150
  tokenVersion: localPayload.ver,
170
151
  impersonatorId: localPayload.imp,
171
- fingerprintHash: localPayload.fgp,
172
152
  };
173
153
  }
174
154
  if (config?.oidcProviders?.length) {
@@ -43,6 +43,16 @@ const setupMCPChatHandler = (chatConfiguration) => {
43
43
  res.status(405).json({ error: 'Method Not Allowed. Use POST.' });
44
44
  return;
45
45
  }
46
+ const unchainedContext = req.unchainedContext;
47
+ const user = unchainedContext?.user;
48
+ if (!user) {
49
+ res.status(401).json({ error: 'unauthorized' });
50
+ return;
51
+ }
52
+ if (!(user.roles || []).includes('admin')) {
53
+ res.status(403).json({ error: 'forbidden', message: 'Chat requires admin privileges' });
54
+ return;
55
+ }
46
56
  let client;
47
57
  const lifecycle = createChatRequestLifecycle(res, configuredAbortSignal);
48
58
  try {
@@ -52,13 +62,14 @@ const setupMCPChatHandler = (chatConfiguration) => {
52
62
  url: unchainedMCPUrl,
53
63
  headers: {
54
64
  Cookie: req.headers.cookie || '',
65
+ ...(req.headers.authorization ? { Authorization: req.headers.authorization } : {}),
55
66
  },
56
67
  },
57
68
  initializationOptions: { signal: lifecycle.signal },
58
69
  });
59
70
  lifecycle.setClientClose(() => client.close());
60
71
  const defaultUnchainedTools = await client.tools();
61
- const resourceContext = await buildChatResourceContext(req.unchainedContext);
72
+ const resourceContext = await buildChatResourceContext(unchainedContext);
62
73
  const tools = {
63
74
  ...defaultUnchainedTools,
64
75
  ...additionalTools,
@@ -4,10 +4,8 @@ import type { UnchainedCore } from '@unchainedshop/core';
4
4
  import type { AuthConfig } from '../auth.ts';
5
5
  import type { ChatConfiguration } from '../chat/utils.ts';
6
6
  import { connectChat } from './chatHandler.ts';
7
- import { type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme';
8
- import { type AdminUIPluginConfig } from '../adminUiPlugins.ts';
9
- export type { AdminUIPluginConfig, AdminUIPluginEntityConfig, AdminUIPluginPageConfig, AdminUIPluginTabConfig, AdminUIPluginWidgetConfig, AdminUIPluginSlotConfig, } from '../adminUiPlugins.ts';
10
- export type { AdminUIThemeTokens, AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme';
7
+ import { type AdminUIPluginConfig, type AdminUIThemeConfig } from '../adminUiPlugins.ts';
8
+ export type { AdminUIPluginConfig, AdminUIPluginEntityConfig, AdminUIPluginPageConfig, AdminUIPluginTabConfig, AdminUIPluginWidgetConfig, AdminUIPluginSlotConfig, AdminUIThemeTokens, AdminUIThemeConfig, } from '../adminUiPlugins.ts';
11
9
  export interface AdminUIRouterOptions {
12
10
  prefix?: string;
13
11
  enabled?: boolean;
@@ -10,26 +10,38 @@ import createMCPMiddleware from "./createMCPMiddleware.js";
10
10
  import { connectChat } from "./chatHandler.js";
11
11
  import { mountRoutes } from "./mountRoutes.js";
12
12
  import { createBackchannelLogoutRoute } from "../handlers/createBackchannelLogoutHandler.js";
13
- import { generateThemeCSS } from '@unchainedshop/admin-ui/theme';
14
- import { preparePluginAssets, resolveAdminUIPath } from "../adminUiPlugins.js";
13
+ import { preparePluginAssets, resolveAdminUIPath, } from "../adminUiPlugins.js";
15
14
  export const adminUIRouter = (enabled = true, theme, plugins = []) => {
16
15
  const router = e.Router();
17
16
  const adminUIPath = resolveAdminUIPath();
18
17
  if (!adminUIPath)
19
18
  return router;
20
19
  if (enabled) {
21
- const themeCSS = generateThemeCSS(theme);
22
- const themeHash = createHash('sha256').update(themeCSS).digest('hex').slice(0, 8);
23
- const themeEtag = `"${themeHash}"`;
24
- router.get('/admin-ui-theme.css', (req, res) => {
25
- if (req.headers['if-none-match'] === themeEtag) {
20
+ let themePromise;
21
+ const resolveThemeCSS = async () => {
22
+ let css = '/* default theme */';
23
+ if (theme) {
24
+ try {
25
+ const { generateThemeCSS } = await import('@unchainedshop/admin-ui/theme');
26
+ css = generateThemeCSS(theme);
27
+ }
28
+ catch {
29
+ console.warn("npm dependency @unchainedshop/admin-ui is not installed, can't apply admin-ui theme");
30
+ }
31
+ }
32
+ const etag = `"${createHash('sha256').update(css).digest('hex').slice(0, 8)}"`;
33
+ return { css, etag };
34
+ };
35
+ router.get('/admin-ui-theme.css', async (req, res) => {
36
+ const { css, etag } = await (themePromise ||= resolveThemeCSS());
37
+ if (req.headers['if-none-match'] === etag) {
26
38
  return res.status(304).end();
27
39
  }
28
40
  res
29
41
  .set('Cache-Control', 'public, max-age=0, must-revalidate')
30
- .set('ETag', themeEtag)
42
+ .set('ETag', etag)
31
43
  .type('text/css')
32
- .send(themeCSS);
44
+ .send(css);
33
45
  });
34
46
  const log = { info: console.info, warn: console.warn };
35
47
  const devMode = process.env.NODE_ENV !== 'production';
@@ -39,6 +39,14 @@ const setupMCPChatHandler = (chatConfiguration) => {
39
39
  });
40
40
  return res.status(200).send();
41
41
  }
42
+ const unchainedContext = req.unchainedContext;
43
+ const user = unchainedContext?.user;
44
+ if (!user) {
45
+ return res.status(401).send({ error: 'unauthorized' });
46
+ }
47
+ if (!(user.roles || []).includes('admin')) {
48
+ return res.status(403).send({ error: 'forbidden', message: 'Chat requires admin privileges' });
49
+ }
42
50
  let client;
43
51
  const lifecycle = createChatRequestLifecycle(res.raw, configuredAbortSignal);
44
52
  try {
@@ -48,13 +56,14 @@ const setupMCPChatHandler = (chatConfiguration) => {
48
56
  url: unchainedMCPUrl,
49
57
  headers: {
50
58
  Cookie: req.headers.cookie || '',
59
+ ...(req.headers.authorization ? { Authorization: req.headers.authorization } : {}),
51
60
  },
52
61
  },
53
62
  initializationOptions: { signal: lifecycle.signal },
54
63
  });
55
64
  lifecycle.setClientClose(() => client.close());
56
65
  const defaultUnchainedTools = await client.tools();
57
- const resourceContext = await buildChatResourceContext(req.unchainedContext);
66
+ const resourceContext = await buildChatResourceContext(unchainedContext);
58
67
  const tools = {
59
68
  ...defaultUnchainedTools,
60
69
  ...additionalTools,
@@ -4,10 +4,8 @@ import type { UnchainedCore } from '@unchainedshop/core';
4
4
  import type { FastifyBaseLogger, FastifyInstance, FastifyPluginAsync } from 'fastify';
5
5
  import { connectChat } from './chatHandler.ts';
6
6
  import type { ChatConfiguration } from '../chat/utils.ts';
7
- import { type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme';
8
- import { type AdminUIPluginConfig } from '../adminUiPlugins.ts';
9
- export type { AdminUIPluginConfig, AdminUIPluginEntityConfig, AdminUIPluginPageConfig, AdminUIPluginTabConfig, AdminUIPluginWidgetConfig, AdminUIPluginSlotConfig, } from '../adminUiPlugins.ts';
10
- export type { AdminUIThemeTokens, AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme';
7
+ import { type AdminUIPluginConfig, type AdminUIThemeConfig } from '../adminUiPlugins.ts';
8
+ export type { AdminUIPluginConfig, AdminUIPluginEntityConfig, AdminUIPluginPageConfig, AdminUIPluginTabConfig, AdminUIPluginWidgetConfig, AdminUIPluginSlotConfig, AdminUIThemeTokens, AdminUIThemeConfig, } from '../adminUiPlugins.ts';
11
9
  export interface AdminUIRouterOptions {
12
10
  prefix?: string;
13
11
  enabled?: boolean;
@@ -10,8 +10,7 @@ import { createHash } from 'node:crypto';
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { createBackchannelLogoutRoute } from "../handlers/createBackchannelLogoutHandler.js";
13
- import { generateThemeCSS } from '@unchainedshop/admin-ui/theme';
14
- import { preparePluginAssets, resolveAdminUIPath } from "../adminUiPlugins.js";
13
+ import { preparePluginAssets, resolveAdminUIPath, } from "../adminUiPlugins.js";
15
14
  const resolveUserRemoteAddress = (req, trustProxy = false) => {
16
15
  let remoteAddress;
17
16
  if (trustProxy) {
@@ -126,18 +125,31 @@ export const connect = async (fastify, { graphqlHandler, unchainedAPI, }, { allo
126
125
  if (adminUI) {
127
126
  const adminUIOptions = typeof adminUI === 'object' ? adminUI : undefined;
128
127
  const adminUIPlugins = adminUIOptions?.plugins || [];
129
- const themeCSS = generateThemeCSS(adminUIOptions?.theme);
130
- const themeHash = createHash('sha256').update(themeCSS).digest('hex').slice(0, 8);
131
- const themeEtag = `"${themeHash}"`;
128
+ let themePromise;
129
+ const resolveThemeCSS = async () => {
130
+ let css = '/* default theme */';
131
+ if (adminUIOptions?.theme) {
132
+ try {
133
+ const { generateThemeCSS } = await import('@unchainedshop/admin-ui/theme');
134
+ css = generateThemeCSS(adminUIOptions.theme);
135
+ }
136
+ catch {
137
+ fastify.log.warn("npm dependency @unchainedshop/admin-ui is not installed, can't apply admin-ui theme");
138
+ }
139
+ }
140
+ const etag = `"${createHash('sha256').update(css).digest('hex').slice(0, 8)}"`;
141
+ return { css, etag };
142
+ };
132
143
  fastify.get('/admin-ui-theme.css', async (request, reply) => {
133
- if (request.headers['if-none-match'] === themeEtag) {
144
+ const { css, etag } = await (themePromise ||= resolveThemeCSS());
145
+ if (request.headers['if-none-match'] === etag) {
134
146
  return reply.code(304).send();
135
147
  }
136
148
  return reply
137
149
  .header('Cache-Control', 'public, max-age=0, must-revalidate')
138
- .header('ETag', themeEtag)
150
+ .header('ETag', etag)
139
151
  .type('text/css')
140
- .send(themeCSS);
152
+ .send(css);
141
153
  });
142
154
  const devMode = process.env.NODE_ENV !== 'production';
143
155
  const { routes: pluginRoutes } = preparePluginAssets(adminUIPlugins, fastify.log, {
@@ -1,38 +1,40 @@
1
1
  import DataLoader from 'dataloader';
2
2
  export default (unchainedAPI) => new DataLoader(async (queries) => {
3
- const parentAssortmentIds = queries.flatMap((q) => q.parentAssortmentId).filter(Boolean);
4
- const childAssortmentIds = queries.flatMap((q) => q.childAssortmentId).filter(Boolean);
5
- const assortmentIds = queries.flatMap((q) => q.assortmentId).filter(Boolean);
3
+ const parentAssortmentIds = [
4
+ ...new Set(queries.flatMap((q) => q.parentAssortmentId).filter(Boolean)),
5
+ ];
6
+ const childAssortmentIds = [
7
+ ...new Set(queries.flatMap((q) => q.childAssortmentId).filter(Boolean)),
8
+ ];
9
+ const assortmentIds = [
10
+ ...new Set(queries.flatMap((q) => q.assortmentId).filter(Boolean)),
11
+ ];
6
12
  const allLinks = await unchainedAPI.modules.assortments.links.findLinks({
7
- assortmentIds: [...new Set([...parentAssortmentIds, ...childAssortmentIds, ...assortmentIds])],
13
+ ...(parentAssortmentIds.length ? { parentAssortmentIds } : {}),
14
+ ...(childAssortmentIds.length ? { childAssortmentIds } : {}),
15
+ ...(assortmentIds.length ? { assortmentIds } : {}),
8
16
  });
9
- const parentAssortmentLinkMap = {};
10
- const childAssortmentLinkMap = {};
17
+ const parentAssortmentLinkMap = new Map();
18
+ const childAssortmentLinkMap = new Map();
11
19
  for (const link of allLinks) {
12
- if (!parentAssortmentLinkMap[link.parentAssortmentId]) {
13
- parentAssortmentLinkMap[link.parentAssortmentId] = [link];
14
- }
15
- else {
16
- parentAssortmentLinkMap[link.parentAssortmentId].push(link);
17
- }
18
- if (!childAssortmentLinkMap[link.childAssortmentId]) {
19
- childAssortmentLinkMap[link.childAssortmentId] = [link];
20
- }
21
- else {
22
- childAssortmentLinkMap[link.childAssortmentId].push(link);
23
- }
20
+ const parentLinks = parentAssortmentLinkMap.get(link.parentAssortmentId) || [];
21
+ parentLinks.push(link);
22
+ parentAssortmentLinkMap.set(link.parentAssortmentId, parentLinks);
23
+ const childLinks = childAssortmentLinkMap.get(link.childAssortmentId) || [];
24
+ childLinks.push(link);
25
+ childAssortmentLinkMap.set(link.childAssortmentId, childLinks);
24
26
  }
25
27
  return queries.map((q) => {
26
28
  if (q.parentAssortmentId) {
27
- return parentAssortmentLinkMap[q.parentAssortmentId] || [];
29
+ return parentAssortmentLinkMap.get(q.parentAssortmentId) || [];
28
30
  }
29
31
  else if (q.childAssortmentId) {
30
- return childAssortmentLinkMap[q.childAssortmentId] || [];
32
+ return childAssortmentLinkMap.get(q.childAssortmentId) || [];
31
33
  }
32
34
  if (q.assortmentId) {
33
35
  return [
34
- ...(parentAssortmentLinkMap[q.assortmentId] || []),
35
- ...(childAssortmentLinkMap[q.assortmentId] || []),
36
+ ...(parentAssortmentLinkMap.get(q.assortmentId) || []),
37
+ ...(childAssortmentLinkMap.get(q.assortmentId) || []),
36
38
  ];
37
39
  }
38
40
  return [];
@@ -6,6 +6,7 @@ export default (unchainedAPI) => new DataLoader(async (queries) => {
6
6
  const texts = await unchainedAPI.modules.assortments.media.texts.findMediaTexts({ assortmentMediaIds }, {
7
7
  sort: {
8
8
  assortmentMediaId: 1,
9
+ _id: 1,
9
10
  },
10
11
  });
11
12
  const localeMap = buildLocaleMap(queries, texts);
@@ -1,13 +1,16 @@
1
1
  import DataLoader from 'dataloader';
2
2
  export default (unchainedAPI) => new DataLoader(async (queries) => {
3
3
  const assortmentIds = [...new Set(queries.map((q) => q.assortmentId).filter(Boolean))];
4
+ const productIds = [...new Set(queries.map((q) => q.productId).filter(Boolean))];
4
5
  const assortmentProducts = await unchainedAPI.modules.assortments.products.findAssortmentProducts({
5
6
  assortmentIds,
7
+ productIds,
6
8
  });
7
- const assortmentProductMap = {};
9
+ const assortmentProductMap = new Map();
8
10
  for (const assortmentProduct of assortmentProducts) {
9
- assortmentProductMap[assortmentProduct.assortmentId + assortmentProduct.productId] =
10
- assortmentProduct;
11
+ const productsById = assortmentProductMap.get(assortmentProduct.assortmentId) || new Map();
12
+ productsById.set(assortmentProduct.productId, assortmentProduct);
13
+ assortmentProductMap.set(assortmentProduct.assortmentId, productsById);
11
14
  }
12
- return queries.map((q) => assortmentProductMap[q.assortmentId + q.productId]);
15
+ return queries.map((q) => assortmentProductMap.get(q.assortmentId)?.get(q.productId));
13
16
  });
@@ -6,6 +6,7 @@ export default (unchainedAPI) => new DataLoader(async (queries) => {
6
6
  const texts = await unchainedAPI.modules.assortments.texts.findTexts({ assortmentIds }, {
7
7
  sort: {
8
8
  assortmentId: 1,
9
+ _id: 1,
9
10
  },
10
11
  });
11
12
  const localeMap = buildLocaleMap(queries, texts);
@@ -6,6 +6,7 @@ export default (unchainedAPI) => new DataLoader(async (queries) => {
6
6
  const texts = await unchainedAPI.modules.filters.texts.findTexts({ filterIds }, {
7
7
  sort: {
8
8
  filterId: 1,
9
+ _id: 1,
9
10
  },
10
11
  });
11
12
  const localeMap = buildLocaleMap(queries, texts);
@@ -6,6 +6,7 @@ export default (unchainedAPI) => new DataLoader(async (queries) => {
6
6
  const texts = await unchainedAPI.modules.products.media.texts.findMediaTexts({ productMediaIds }, {
7
7
  sort: {
8
8
  productMediaId: 1,
9
+ _id: 1,
9
10
  },
10
11
  });
11
12
  const localeMap = buildLocaleMap(queries, texts);
@@ -6,6 +6,7 @@ export default (unchainedAPI) => new DataLoader(async (queries) => {
6
6
  const texts = await unchainedAPI.modules.products.texts.findTexts({ productIds }, {
7
7
  sort: {
8
8
  productId: 1,
9
+ _id: 1,
9
10
  },
10
11
  });
11
12
  const localeMap = buildLocaleMap(queries, texts);
@@ -8,6 +8,7 @@ export default (unchainedAPI) => new DataLoader(async (queries) => {
8
8
  }, {
9
9
  sort: {
10
10
  productVariationId: 1,
11
+ _id: 1,
11
12
  },
12
13
  });
13
14
  const localeMap = buildLocaleMap(queries, texts);
@@ -7,7 +7,7 @@ export default async function getAssortmentFilters(context, params) {
7
7
  const assortment = await getNormalizedAssortmentDetails({ assortmentId }, context);
8
8
  if (!assortment)
9
9
  throw new AssortmentNotFoundError({ assortmentId });
10
- const filters = await modules.assortments.filters.findFilters({ assortmentId }, { sort: { sortKey: 1 } });
10
+ const filters = await modules.assortments.filters.findFilters({ assortmentId }, { sort: { sortKey: 1, _id: 1 } });
11
11
  const filters_normalized = await Promise.all(filters?.map(async ({ filterId, ...rest }) => ({
12
12
  ...(await getNormalizedFilterDetails(filterId, context)),
13
13
  ...rest,
@@ -7,7 +7,7 @@ export default async function getAssortmentProducts(context, params) {
7
7
  const assortment = await getNormalizedAssortmentDetails({ assortmentId }, context);
8
8
  if (!assortment)
9
9
  throw new AssortmentNotFoundError({ assortmentId });
10
- const assortmentProducts = await modules.assortments.products.findAssortmentProducts({ assortmentId }, { sort: { sortKey: 1 } });
10
+ const assortmentProducts = await modules.assortments.products.findAssortmentProducts({ assortmentId }, { sort: { sortKey: 1, _id: 1 } });
11
11
  const products = await Promise.all(assortmentProducts?.map(async ({ productId, ...rest }) => ({
12
12
  ...(await getNormalizedProductDetails(productId, context)),
13
13
  ...rest,
@@ -4,6 +4,7 @@ export default async function getUserPaymentCredentials(context, params) {
4
4
  const credentials = await modules.payment.paymentCredentials.findPaymentCredentials({ userId }, {
5
5
  sort: {
6
6
  created: -1,
7
+ _id: -1,
7
8
  },
8
9
  });
9
10
  return { credentials };
@@ -9,7 +9,7 @@ export async function getNormalizedAssortmentDetails({ assortmentId, slug }, con
9
9
  assortmentId: normalizedAssortmentId,
10
10
  locale,
11
11
  });
12
- const filters = await modules.assortments.filters.findFilters({ assortmentId: assortment._id }, { sort: { sortKey: 1 } });
12
+ const filters = await modules.assortments.filters.findFilters({ assortmentId: assortment._id }, { sort: { sortKey: 1, _id: 1 } });
13
13
  const assortmentMedias = await modules.assortments.media.findAssortmentMedias({
14
14
  assortmentId: normalizedAssortmentId,
15
15
  });
@@ -19,7 +19,7 @@ export async function getNormalizedAssortmentDetails({ assortmentId, slug }, con
19
19
  const links = (await loaders.assortmentLinksLoader.load({
20
20
  assortmentId: assortment._id,
21
21
  })) || [];
22
- const products = await modules.assortments.products.findAssortmentProducts({ assortmentId: assortment._id }, { sort: { sortKey: 1 } });
22
+ const products = await modules.assortments.products.findAssortmentProducts({ assortmentId: assortment._id }, { sort: { sortKey: 1, _id: 1 } });
23
23
  const assortmentIds = assortmentChildLinks.map(({ childAssortmentId }) => childAssortmentId);
24
24
  const childrenCount = await modules.assortments.count({
25
25
  assortmentIds,
@@ -38,6 +38,7 @@ export async function getNormalizedUserDetails(userId, context) {
38
38
  const paymentCredentials = await modules.payment.paymentCredentials.findPaymentCredentials({ userId: user._id }, {
39
39
  sort: {
40
40
  created: -1,
41
+ _id: -1,
41
42
  },
42
43
  });
43
44
  const quotations = await modules.quotations.findQuotations({
@@ -1,9 +1,9 @@
1
1
  import { emit } from '@unchainedshop/events';
2
- import { signAccessToken, createAuthHandler, generateFingerprint, verifyFingerprint, } from "../auth.js";
2
+ import { signAccessToken, createAuthHandler } from "../auth.js";
3
3
  import { API_EVENTS } from "../events.js";
4
4
  import { createLogger } from '@unchainedshop/logger';
5
5
  const logger = createLogger('unchained:api:auth-middleware');
6
- const { UNCHAINED_COOKIE_NAME = 'unchained_token', UNCHAINED_COOKIE_PATH = '/', UNCHAINED_COOKIE_DOMAIN, UNCHAINED_COOKIE_SAMESITE = 'lax', UNCHAINED_COOKIE_INSECURE, UNCHAINED_FINGERPRINT_COOKIE_NAME = '__Secure-fgp', UNCHAINED_TOKEN_EXPIRY_SECONDS = '3600', } = process.env;
6
+ const { UNCHAINED_COOKIE_NAME = 'unchained_token', UNCHAINED_COOKIE_PATH = '/', UNCHAINED_COOKIE_DOMAIN, UNCHAINED_COOKIE_SAMESITE = 'lax', UNCHAINED_COOKIE_INSECURE, UNCHAINED_TOKEN_EXPIRY_SECONDS = '3600', } = process.env;
7
7
  function getTokenCookieOptions(expires) {
8
8
  const secure = !UNCHAINED_COOKIE_INSECURE;
9
9
  const sameSite = {
@@ -30,19 +30,6 @@ function getTokenCookieOptions(expires) {
30
30
  expires,
31
31
  };
32
32
  }
33
- function getFingerprintCookieOptions(expires) {
34
- const secure = !UNCHAINED_COOKIE_INSECURE;
35
- const expirySeconds = parseInt(UNCHAINED_TOKEN_EXPIRY_SECONDS, 10);
36
- return {
37
- domain: UNCHAINED_COOKIE_DOMAIN,
38
- path: UNCHAINED_COOKIE_PATH,
39
- secure,
40
- httpOnly: true,
41
- sameSite: 'strict',
42
- maxAge: expires ? undefined : expirySeconds * 1000,
43
- expires,
44
- };
45
- }
46
33
  function extractBearerToken(authHeader) {
47
34
  if (!authHeader)
48
35
  return undefined;
@@ -62,38 +49,15 @@ export async function createAuthContext(params, authConfig) {
62
49
  const headerToken = extractBearerToken(authHeader);
63
50
  const cookieToken = getCookie(UNCHAINED_COOKIE_NAME);
64
51
  const token = headerToken || cookieToken;
65
- const fingerprintCookie = getCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME);
66
52
  const verifyToken = createAuthHandler(authConfig);
67
53
  const authResult = token ? await verifyToken(token) : {};
68
- if (authResult.fingerprintHash && fingerprintCookie) {
69
- const fingerprintValid = verifyFingerprint(fingerprintCookie, authResult.fingerprintHash);
70
- if (!fingerprintValid) {
71
- logger.warn('Token sidejacking detected: fingerprint mismatch', {
72
- userId: authResult.userId,
73
- });
74
- authResult.userId = undefined;
75
- authResult.tokenVersion = undefined;
76
- authResult.impersonatorId = undefined;
77
- }
78
- }
79
- else if (authResult.fingerprintHash && !fingerprintCookie) {
80
- logger.warn('Token sidejacking detected: fingerprint cookie missing', {
81
- userId: authResult.userId,
82
- });
83
- authResult.userId = undefined;
84
- authResult.tokenVersion = undefined;
85
- authResult.impersonatorId = undefined;
86
- }
87
54
  const login = async (user, options = {}) => {
88
55
  const { impersonator } = options;
89
56
  const tokenVersion = user.tokenVersion ?? 1;
90
- const { raw: fingerprintRaw, hash: fingerprintHash } = generateFingerprint();
91
57
  const { token: newToken, expires } = await signAccessToken(user._id, tokenVersion, {
92
58
  impersonatorId: impersonator?._id,
93
- fingerprintHash,
94
59
  });
95
60
  setCookie(UNCHAINED_COOKIE_NAME, newToken, getTokenCookieOptions(expires));
96
- setCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME, fingerprintRaw, getFingerprintCookieOptions(expires));
97
61
  const tokenObject = {
98
62
  _id: crypto.randomUUID(),
99
63
  userId: user._id,
@@ -105,7 +69,6 @@ export async function createAuthContext(params, authConfig) {
105
69
  };
106
70
  const logout = async () => {
107
71
  clearCookie(UNCHAINED_COOKIE_NAME, getTokenCookieOptions());
108
- clearCookie(UNCHAINED_FINGERPRINT_COOKIE_NAME, getFingerprintCookieOptions());
109
72
  if (authResult.userId) {
110
73
  const tokenObject = {
111
74
  _id: crypto.randomUUID(),
@@ -22,7 +22,7 @@ export default async function checkoutCart(root, params, context) {
22
22
  userId,
23
23
  orderId: order._id,
24
24
  ...transactionContext,
25
- detailCode: error.name || error.code,
25
+ detailCode: error.extensions?.code || error.name || error.code,
26
26
  detailMessage: error.message,
27
27
  });
28
28
  }
@@ -32,7 +32,7 @@ export const AssortmentTypes = {
32
32
  return modules.assortments.filters.findFilters({
33
33
  assortmentId: obj._id,
34
34
  }, {
35
- sort: { sortKey: 1 },
35
+ sort: { sortKey: 1, _id: 1 },
36
36
  });
37
37
  },
38
38
  async linkedAssortments(assortment, _, { loaders }) {
@@ -53,7 +53,7 @@ export const AssortmentTypes = {
53
53
  return modules.assortments.products.findAssortmentProducts({
54
54
  assortmentId: obj._id,
55
55
  }, {
56
- sort: { sortKey: 1 },
56
+ sort: { sortKey: 1, _id: 1 },
57
57
  });
58
58
  },
59
59
  async texts(obj, { forceLocale }, requestContext) {
@@ -105,6 +105,7 @@ export const User = {
105
105
  return context.modules.payment.paymentCredentials.findPaymentCredentials({ ...params.selector, userId: user._id }, {
106
106
  sort: {
107
107
  created: -1,
108
+ _id: -1,
108
109
  },
109
110
  });
110
111
  },
package/package.json CHANGED
@@ -1,7 +1,8 @@
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": "5.0.0-alpha.3",
4
+ "mcpName": "io.github.unchainedshop/unchained",
5
+ "version": "5.0.0-alpha.4",
5
6
  "main": "lib/api-index.js",
6
7
  "types": "lib/api-index.d.ts",
7
8
  "type": "module",
@@ -65,6 +66,7 @@
65
66
  "@fastify/multipart": ">= 10 < 11",
66
67
  "@fastify/static": ">= 10.1.3 < 11",
67
68
  "@modelcontextprotocol/server": ">= 2 < 3",
69
+ "@unchainedshop/admin-ui": "^5.0.0-alpha.1",
68
70
  "ai": ">= 7 < 8",
69
71
  "cookie-parser": ">= 1.4 < 2",
70
72
  "express": ">= 5 < 6",
@@ -86,6 +88,9 @@
86
88
  "@modelcontextprotocol/server": {
87
89
  "optional": true
88
90
  },
91
+ "@unchainedshop/admin-ui": {
92
+ "optional": true
93
+ },
89
94
  "ai": {
90
95
  "optional": true
91
96
  },
@@ -136,7 +141,7 @@
136
141
  "@unchainedshop/mongodb": "^5.0.0-alpha.1",
137
142
  "@unchainedshop/roles": "^5.0.0-alpha.1",
138
143
  "@unchainedshop/utils": "^5.0.0-alpha.1",
139
- "@whatwg-node/server": "^0.10.18",
144
+ "@whatwg-node/server": "^0.11.0",
140
145
  "dataloader": "^2.2.3",
141
146
  "graphql-scalars": "^2.0.0",
142
147
  "jose": "^6.0.10",
@@ -158,6 +163,6 @@
158
163
  "fastify": "^5.4.0",
159
164
  "graphql": "^17.0.2",
160
165
  "multer": "^2.0.1",
161
- "typescript": "^5.8.3"
166
+ "typescript": "^6.0.3"
162
167
  }
163
168
  }
@@ -1,25 +0,0 @@
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;
@@ -1,34 +0,0 @@
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
- };