@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 +2 -2
- package/lib/adminUiPlugins.d.ts +24 -0
- package/lib/auth.d.ts +0 -8
- package/lib/auth.js +0 -20
- package/lib/express/chatHandler.js +12 -1
- package/lib/express/index.d.ts +2 -4
- package/lib/express/index.js +21 -9
- package/lib/fastify/chatHandler.js +10 -1
- package/lib/fastify/index.d.ts +2 -4
- package/lib/fastify/index.js +20 -8
- package/lib/loaders/assortmentLinksLoader.js +24 -22
- package/lib/loaders/assortmentMediaTextLoader.js +1 -0
- package/lib/loaders/assortmentProductLoader.js +7 -4
- package/lib/loaders/assortmentTextLoader.js +1 -0
- package/lib/loaders/filterTextLoader.js +1 -0
- package/lib/loaders/productMediaTextLoader.js +1 -0
- package/lib/loaders/productTextLoader.js +1 -0
- package/lib/loaders/productVariationTextLoader.js +1 -0
- package/lib/mcp/tools/assortment/handlers/getAssortmentFilters.js +1 -1
- package/lib/mcp/tools/assortment/handlers/getAssortmentProducts.js +1 -1
- package/lib/mcp/tools/users/handlers/getUserPaymentCredentials.js +1 -0
- package/lib/mcp/utils/getNormalizedAssortmentDetails.js +2 -2
- package/lib/mcp/utils/getNormalizedUserDetails.js +1 -0
- package/lib/middleware/createAuthMiddleware.js +2 -39
- package/lib/resolvers/mutations/orders/checkoutCart.js +1 -1
- package/lib/resolvers/type/assortment/assortment-types.js +2 -2
- package/lib/resolvers/type/user-types.js +1 -0
- package/package.json +8 -3
- package/lib/adminUiTheme.d.ts +0 -25
- package/lib/adminUiTheme.js +0 -34
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 | `
|
|
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
|
|
package/lib/adminUiPlugins.d.ts
CHANGED
|
@@ -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(
|
|
72
|
+
const resourceContext = await buildChatResourceContext(unchainedContext);
|
|
62
73
|
const tools = {
|
|
63
74
|
...defaultUnchainedTools,
|
|
64
75
|
...additionalTools,
|
package/lib/express/index.d.ts
CHANGED
|
@@ -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 '
|
|
8
|
-
|
|
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;
|
package/lib/express/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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',
|
|
42
|
+
.set('ETag', etag)
|
|
31
43
|
.type('text/css')
|
|
32
|
-
.send(
|
|
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(
|
|
66
|
+
const resourceContext = await buildChatResourceContext(unchainedContext);
|
|
58
67
|
const tools = {
|
|
59
68
|
...defaultUnchainedTools,
|
|
60
69
|
...additionalTools,
|
package/lib/fastify/index.d.ts
CHANGED
|
@@ -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 '
|
|
8
|
-
|
|
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;
|
package/lib/fastify/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
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
|
-
|
|
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',
|
|
150
|
+
.header('ETag', etag)
|
|
139
151
|
.type('text/css')
|
|
140
|
-
.send(
|
|
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 =
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
29
|
+
return parentAssortmentLinkMap.get(q.parentAssortmentId) || [];
|
|
28
30
|
}
|
|
29
31
|
else if (q.childAssortmentId) {
|
|
30
|
-
return childAssortmentLinkMap
|
|
32
|
+
return childAssortmentLinkMap.get(q.childAssortmentId) || [];
|
|
31
33
|
}
|
|
32
34
|
if (q.assortmentId) {
|
|
33
35
|
return [
|
|
34
|
-
...(parentAssortmentLinkMap
|
|
35
|
-
...(childAssortmentLinkMap
|
|
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
|
|
10
|
-
|
|
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
|
|
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.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);
|
|
@@ -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,
|
|
@@ -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
|
|
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,
|
|
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) {
|
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
|
-
"
|
|
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.
|
|
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": "^
|
|
166
|
+
"typescript": "^6.0.3"
|
|
162
167
|
}
|
|
163
168
|
}
|
package/lib/adminUiTheme.d.ts
DELETED
|
@@ -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;
|
package/lib/adminUiTheme.js
DELETED
|
@@ -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
|
-
};
|