@unchainedshop/api 4.6.1 → 4.7.2

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.
Files changed (75) hide show
  1. package/lib/acl.js +29 -1
  2. package/lib/api-index.d.ts +1 -0
  3. package/lib/auth.d.ts +44 -0
  4. package/lib/auth.js +187 -0
  5. package/lib/context.d.ts +1 -0
  6. package/lib/context.js +8 -3
  7. package/lib/events.d.ts +2 -0
  8. package/lib/events.js +2 -0
  9. package/lib/express/index.d.ts +6 -9
  10. package/lib/express/index.js +52 -125
  11. package/lib/express/mountPluginRoutes.d.ts +3 -0
  12. package/lib/express/mountPluginRoutes.js +59 -0
  13. package/lib/express/mountRoutes.d.ts +3 -0
  14. package/lib/express/mountRoutes.js +57 -0
  15. package/lib/fastify/index.d.ts +6 -9
  16. package/lib/fastify/index.js +51 -113
  17. package/lib/fastify/mountPluginRoutes.d.ts +3 -0
  18. package/lib/fastify/mountPluginRoutes.js +62 -0
  19. package/lib/fastify/mountRoutes.d.ts +3 -0
  20. package/lib/fastify/mountRoutes.js +60 -0
  21. package/lib/handlers/createBackchannelLogoutHandler.d.ts +4 -0
  22. package/lib/handlers/createBackchannelLogoutHandler.js +170 -0
  23. package/lib/mcp/tools/localization/getLocalizationsConfig.d.ts +1 -1
  24. package/lib/mcp/tools/localization/schemas.d.ts +34 -0
  25. package/lib/mcp/tools/order/handlers/getTopCustomers.d.ts +2 -0
  26. package/lib/mcp/tools/quotation/handlers/getQuotation.d.ts +2 -0
  27. package/lib/mcp/tools/quotation/handlers/listQuotations.d.ts +2 -0
  28. package/lib/mcp/tools/quotation/handlers/makeQuotationProposal.d.ts +2 -0
  29. package/lib/mcp/tools/quotation/handlers/rejectQuotation.d.ts +2 -0
  30. package/lib/mcp/tools/quotation/handlers/verifyQuotation.d.ts +2 -0
  31. package/lib/mcp/tools/system/handlers/countEvents.d.ts +8 -1
  32. package/lib/mcp/tools/system/handlers/countWork.d.ts +10 -1
  33. package/lib/mcp/tools/system/handlers/countWork.js +1 -0
  34. package/lib/mcp/tools/system/handlers/index.d.ts +4 -4
  35. package/lib/mcp/tools/system/handlers/listEvents.d.ts +12 -1
  36. package/lib/mcp/tools/system/handlers/listWork.d.ts +14 -1
  37. package/lib/mcp/tools/system/handlers/listWork.js +1 -0
  38. package/lib/mcp/tools/users/handlers/addUserEmail.d.ts +2 -0
  39. package/lib/mcp/tools/users/handlers/createUser.d.ts +2 -0
  40. package/lib/mcp/tools/users/handlers/enrollUser.d.ts +2 -0
  41. package/lib/mcp/tools/users/handlers/getCurrentUser.d.ts +2 -0
  42. package/lib/mcp/tools/users/handlers/getUser.d.ts +2 -0
  43. package/lib/mcp/tools/users/handlers/listUsers.d.ts +2 -0
  44. package/lib/mcp/tools/users/handlers/setUserTags.d.ts +2 -0
  45. package/lib/mcp/tools/users/handlers/setUserUsername.d.ts +2 -0
  46. package/lib/mcp/tools/users/handlers/updateUser.d.ts +2 -0
  47. package/lib/mcp/utils/getNormalizedQuotationDetails.d.ts +2 -0
  48. package/lib/mcp/utils/getNormalizedUserDetails.d.ts +2 -0
  49. package/lib/mcp/utils/normalizeMediaUrl.js +1 -1
  50. package/lib/mcp/utils/sanitizeLocalizationEntityData.d.ts +1 -1
  51. package/lib/mcp/utils/validateIsoCode.d.ts +1 -1
  52. package/lib/middleware/createAuthMiddleware.d.ts +29 -0
  53. package/lib/middleware/createAuthMiddleware.js +128 -0
  54. package/lib/resolvers/mutations/accounts/logoutAllSessions.d.ts +4 -0
  55. package/lib/resolvers/mutations/accounts/logoutAllSessions.js +11 -0
  56. package/lib/resolvers/mutations/index.d.ts +1 -6
  57. package/lib/resolvers/mutations/index.js +2 -12
  58. package/lib/resolvers/mutations/orders/updateCart.js +7 -1
  59. package/lib/resolvers/type/index.d.ts +0 -1
  60. package/lib/resolvers/type/order/order-delivery-pickup-types.d.ts +0 -1
  61. package/lib/resolvers/type/order/order-delivery-pickup-types.js +0 -7
  62. package/lib/resolvers/type/order/order-payment-base.d.ts +7 -0
  63. package/lib/resolvers/type/order/order-payment-base.js +14 -0
  64. package/lib/roles/index.js +1 -0
  65. package/lib/roles/loggedIn.js +1 -0
  66. package/lib/schema/mutation.js +6 -44
  67. package/lib/schema/types/common.d.ts +2 -0
  68. package/lib/schema/types/common.js +12 -0
  69. package/lib/schema/types/index.js +2 -2
  70. package/lib/schema/types/order/delivery.js +0 -2
  71. package/lib/utils/mapServiceError.d.ts +1 -0
  72. package/lib/utils/mapServiceError.js +59 -0
  73. package/lib/utils/maskError.d.ts +1 -0
  74. package/lib/utils/maskError.js +22 -0
  75. package/package.json +8 -17
@@ -0,0 +1,57 @@
1
+ import { createServerAdapter } from '@whatwg-node/server';
2
+ import { createLogger } from '@unchainedshop/logger';
3
+ const logger = createLogger('express');
4
+ export function mountRoutes(app, unchainedAPI, routes) {
5
+ if (routes.length === 0)
6
+ return;
7
+ const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
8
+ logger.info(`Mounting ${routes.length} route(s): ${endpoints}`);
9
+ for (const route of routes) {
10
+ const adapter = createServerAdapter(async (request, serverContext) => {
11
+ const context = {
12
+ ...unchainedAPI,
13
+ ...serverContext.unchainedContext,
14
+ params: serverContext.params || {},
15
+ rawRequest: serverContext.rawRequest,
16
+ };
17
+ try {
18
+ return await route.handler(request, context);
19
+ }
20
+ catch (error) {
21
+ logger.error(`Error in route handler ${route.method} ${route.path}`, {
22
+ error: error instanceof Error ? error.message : String(error),
23
+ });
24
+ return new Response(JSON.stringify({
25
+ error: error instanceof Error ? error.message : 'Internal Server Error',
26
+ }), {
27
+ status: 500,
28
+ headers: { 'Content-Type': 'application/json' },
29
+ });
30
+ }
31
+ });
32
+ const method = route.method.toLowerCase();
33
+ const expressHandler = async (req, res) => {
34
+ try {
35
+ await adapter.handleNodeRequestAndResponse(req, res, {
36
+ unchainedContext: req.unchainedContext,
37
+ params: req.params,
38
+ rawRequest: req,
39
+ });
40
+ }
41
+ catch (error) {
42
+ logger.error(`Error handling request for ${route.method} ${route.path}`, {
43
+ error: error instanceof Error ? error.message : String(error),
44
+ });
45
+ if (!res.headersSent) {
46
+ res.status(500).json({ error: 'Internal Server Error' });
47
+ }
48
+ }
49
+ };
50
+ if (method === 'all') {
51
+ app.use(route.path, expressHandler);
52
+ }
53
+ else {
54
+ app[method](route.path, expressHandler);
55
+ }
56
+ }
57
+ }
@@ -1,5 +1,5 @@
1
+ import type { AuthConfig } from '../auth.ts';
1
2
  import type { YogaServerInstance } from 'graphql-yoga';
2
- import type { mongodb } from '@unchainedshop/mongodb';
3
3
  import type { UnchainedCore } from '@unchainedshop/core';
4
4
  import type { FastifyBaseLogger, FastifyInstance, FastifyPluginAsync } from 'fastify';
5
5
  import { connectChat } from './chatHandler.ts';
@@ -9,18 +9,15 @@ export interface AdminUIRouterOptions {
9
9
  enabled?: boolean;
10
10
  }
11
11
  export declare const unchainedLogger: (prefix: string) => FastifyBaseLogger;
12
- export declare const connect: (fastify: FastifyInstance, { graphqlHandler, db, unchainedAPI, }: {
12
+ export declare const connect: (fastify: FastifyInstance, { graphqlHandler, unchainedAPI, }: {
13
13
  graphqlHandler: YogaServerInstance<any, any>;
14
- db: mongodb.Db;
15
14
  unchainedAPI: UnchainedCore;
16
- }, { allowRemoteToLocalhostSecureCookies, adminUI, chat, initPluginMiddlewares, }?: {
15
+ }, { allowRemoteToLocalhostSecureCookies, adminUI, chat, authConfig, trustProxy, }?: {
17
16
  allowRemoteToLocalhostSecureCookies?: boolean;
18
17
  adminUI?: boolean | Omit<AdminUIRouterOptions, "enabled">;
19
18
  chat?: ChatConfiguration;
20
- initPluginMiddlewares?: (app: FastifyInstance, { unchainedAPI }: {
21
- unchainedAPI: UnchainedCore;
22
- }) => void;
23
- }) => void;
19
+ authConfig?: AuthConfig;
20
+ trustProxy?: boolean;
21
+ }) => Promise<void>;
24
22
  export declare const adminUIRouter: FastifyPluginAsync<AdminUIRouterOptions>;
25
- export declare const fastifyRouter: FastifyPluginAsync<AdminUIRouterOptions>;
26
23
  export { connectChat };
@@ -1,64 +1,59 @@
1
1
  import { getCurrentContextResolver } from "../context.js";
2
- import bulkImportHandler from "./bulkImportHandler.js";
3
- import ercMetadataHandler from "./ercMetadataHandler.js";
4
- import MongoStore from "../mongo-store.js";
5
- import { emit } from '@unchainedshop/events';
6
- import { API_EVENTS } from "../events.js";
7
- import fastifySession from '@fastify/session';
2
+ import { createAuthContext } from "../middleware/createAuthMiddleware.js";
3
+ import { pluginRegistry } from '@unchainedshop/core';
8
4
  import fastifyCookie from '@fastify/cookie';
9
- import fastifyMultipart from '@fastify/multipart';
10
5
  import { createLogger } from '@unchainedshop/logger';
11
6
  import mcpHandler from "./mcpHandler.js";
12
- import tempUploadHandler from "./tempUploadHandler.js";
13
7
  import { connectChat } from "./chatHandler.js";
8
+ import { mountRoutes } from "./mountRoutes.js";
14
9
  import { readFileSync } from 'node:fs';
15
- const resolveUserRemoteAddress = (req) => {
16
- const remoteAddress = req.headers['x-real-ip'] ||
17
- req.headers['x-forwarded-for'] ||
18
- req.socket?.remoteAddress;
10
+ import { createBackchannelLogoutRoute } from "../handlers/createBackchannelLogoutHandler.js";
11
+ const resolveUserRemoteAddress = (req, trustProxy = false) => {
12
+ let remoteAddress;
13
+ if (trustProxy) {
14
+ const forwardedFor = req.headers['x-forwarded-for'];
15
+ const forwardedIps = forwardedFor?.split(',').map((ip) => ip.trim());
16
+ remoteAddress =
17
+ req.headers['x-real-ip'] ||
18
+ forwardedIps?.[forwardedIps.length - 1] ||
19
+ req.socket?.remoteAddress;
20
+ }
21
+ else {
22
+ remoteAddress = req.socket?.remoteAddress;
23
+ }
19
24
  const remotePort = req.socket?.remotePort;
20
25
  return { remoteAddress, remotePort };
21
26
  };
22
- const { MCP_API_PATH = '/mcp', GRAPHQL_API_PATH = '/graphql', BULK_IMPORT_API_PATH = '/bulk-import', TEMP_UPLOAD_API_PATH = '/temp-upload', ERC_METADATA_API_PATH = '/erc-metadata/:productId/:localeOrTokenFilename/:tokenFileName?', UNCHAINED_COOKIE_NAME = 'unchained_token', UNCHAINED_COOKIE_PATH = '/', UNCHAINED_COOKIE_DOMAIN, UNCHAINED_COOKIE_SAMESITE = 'none', UNCHAINED_COOKIE_INSECURE, } = process.env;
23
- const middlewareHook = async function middlewareHook(req, reply) {
27
+ const { MCP_API_PATH = '/mcp' } = process.env;
28
+ const createMiddlewareHook = (authConfig, trustProxy = false) => async function middlewareHook(req, reply) {
24
29
  const setHeader = (key, value) => reply.header(key, value);
25
30
  const getHeader = (key) => req.headers[key];
26
- const { remoteAddress, remotePort } = resolveUserRemoteAddress(req);
27
- const context = getCurrentContextResolver();
28
- const login = async function (user, options = {}) {
29
- const { impersonator } = options;
30
- req.session.userId = user._id;
31
- req.session.impersonatorId = impersonator?._id;
32
- const tokenObject = {
33
- _id: req.session.sessionId,
34
- userId: user._id,
35
- tokenExpires: new Date(req.session.cookie._expires),
36
- };
37
- await emit(API_EVENTS.API_LOGIN_TOKEN_CREATED, tokenObject);
38
- user._inLoginMethodResponse = true;
39
- return { user, ...tokenObject };
40
- };
41
- const logout = async function logout() {
42
- const tokenObject = {
43
- _id: req.session.sessionId,
44
- userId: req.session?.userId,
45
- };
46
- req.session.userId = null;
47
- req.session.impersonatorId = null;
48
- await emit(API_EVENTS.API_LOGOUT, tokenObject);
49
- return true;
31
+ const getCookie = (name) => req.cookies?.[name];
32
+ const setCookie = (name, value, options) => reply.setCookie(name, value, options);
33
+ const clearCookie = (name, options) => reply.clearCookie(name, { ...options, maxAge: 0 });
34
+ const { remoteAddress, remotePort } = resolveUserRemoteAddress(req, trustProxy);
35
+ const authContextParams = {
36
+ setHeader,
37
+ getHeader,
38
+ getCookie,
39
+ setCookie,
40
+ clearCookie,
41
+ remoteAddress,
42
+ remotePort,
50
43
  };
51
- const [, accessToken] = req.headers.authorization?.split(' ') || [];
44
+ const authContext = await createAuthContext(authContextParams, authConfig);
45
+ const context = getCurrentContextResolver();
52
46
  req.unchainedContext = await context({
53
47
  setHeader,
54
48
  getHeader,
55
49
  remoteAddress,
56
50
  remotePort,
57
- login,
58
- logout,
59
- accessToken,
60
- userId: req.session.userId,
61
- impersonatorId: req.session.impersonatorId,
51
+ login: authContext.login,
52
+ logout: authContext.logout,
53
+ accessToken: authContext.accessToken,
54
+ userId: authContext.userId,
55
+ impersonatorId: authContext.impersonatorId,
56
+ tokenVersion: authContext.tokenVersion,
62
57
  }, req, reply);
63
58
  };
64
59
  export const unchainedLogger = (prefix) => {
@@ -77,8 +72,12 @@ export const unchainedLogger = (prefix) => {
77
72
  };
78
73
  return new Logger();
79
74
  };
80
- export const connect = (fastify, { graphqlHandler, db, unchainedAPI, }, { allowRemoteToLocalhostSecureCookies = false, adminUI = false, chat, initPluginMiddlewares, } = {}) => {
75
+ export const connect = async (fastify, { graphqlHandler, unchainedAPI, }, { allowRemoteToLocalhostSecureCookies = false, adminUI = false, chat, authConfig, trustProxy = false, } = {}) => {
81
76
  if (allowRemoteToLocalhostSecureCookies) {
77
+ if (process.env.NODE_ENV === 'production') {
78
+ throw new Error('allowRemoteToLocalhostSecureCookies is not allowed in production. ' +
79
+ 'Configure a proper CORS policy with specific allowed origins instead.');
80
+ }
82
81
  fastify.addHook('preHandler', async function (request) {
83
82
  request.headers['x-forwarded-proto'] = 'https';
84
83
  });
@@ -92,94 +91,34 @@ export const connect = (fastify, { graphqlHandler, db, unchainedAPI, }, { allowR
92
91
  });
93
92
  });
94
93
  }
95
- const cookieName = UNCHAINED_COOKIE_NAME;
96
- const domain = UNCHAINED_COOKIE_DOMAIN;
97
- const path = UNCHAINED_COOKIE_PATH;
98
- const secure = UNCHAINED_COOKIE_INSECURE ? false : true;
99
- const sameSite = ({
100
- none: 'none',
101
- lax: 'lax',
102
- strict: 'strict',
103
- '1': true,
104
- '0': false,
105
- }[UNCHAINED_COOKIE_SAMESITE?.trim()?.toLowerCase()] || false);
106
94
  if (!fastify.hasPlugin('@fastify/cookie')) {
107
95
  fastify.register(fastifyCookie);
108
96
  }
109
- fastify.register(fastifySession, {
110
- secret: process.env.UNCHAINED_TOKEN_SECRET,
111
- cookieName,
112
- store: MongoStore.create({
113
- client: db.client,
114
- dbName: db.databaseName,
115
- collectionName: 'sessions',
116
- }),
117
- cookie: {
118
- domain,
119
- httpOnly: true,
120
- path,
121
- secure,
122
- sameSite,
123
- maxAge: 1000 * 60 * 60 * 24 * 7,
124
- },
125
- });
126
97
  fastify.decorateRequest('unchainedContext');
127
- fastify.addHook('onRequest', middlewareHook);
98
+ fastify.addHook('onRequest', createMiddlewareHook(authConfig, trustProxy || allowRemoteToLocalhostSecureCookies));
128
99
  fastify.route({
129
- url: GRAPHQL_API_PATH,
100
+ url: graphqlHandler.graphqlEndpoint,
130
101
  method: ['GET', 'POST', 'OPTIONS'],
131
102
  handler: async (req, reply) => {
132
- const response = await graphqlHandler.handleNodeRequestAndResponse(req, reply, {
103
+ return graphqlHandler.handleNodeRequestAndResponse(req, reply, {
133
104
  req,
134
105
  reply,
135
106
  });
136
- response.headers.forEach((value, key) => {
137
- reply.header(key, value);
138
- });
139
- reply.status(response.status);
140
- reply.send(response.body);
141
- return reply;
142
107
  },
143
108
  });
144
- fastify.route({
145
- url: ERC_METADATA_API_PATH,
146
- method: ['GET'],
147
- handler: ercMetadataHandler,
148
- });
149
109
  fastify.route({
150
110
  url: MCP_API_PATH,
151
111
  method: ['GET', 'POST', 'DELETE'],
152
112
  handler: mcpHandler,
153
113
  });
154
- fastify.register((s, opts, registered) => {
155
- s.register(fastifyMultipart, { throwFileSizeLimit: true, limits: { fileSize: 1024 * 1024 * 35 } });
156
- s.route({
157
- url: TEMP_UPLOAD_API_PATH,
158
- method: ['POST'],
159
- bodyLimit: 1024 * 1024 * 35,
160
- handler: tempUploadHandler,
161
- });
162
- registered();
163
- });
164
- fastify.register((s, opts, registered) => {
165
- s.removeAllContentTypeParsers();
166
- s.addContentTypeParser('*', function (req, payload, done) {
167
- done(null);
168
- });
169
- s.route({
170
- url: BULK_IMPORT_API_PATH,
171
- method: ['POST'],
172
- bodyLimit: 1024 * 1024 * 1024 * 5,
173
- handler: bulkImportHandler,
174
- });
175
- registered();
176
- });
177
114
  if (chat) {
178
115
  connectChat(fastify, chat);
179
116
  }
180
- if (initPluginMiddlewares) {
181
- initPluginMiddlewares(fastify, { unchainedAPI });
117
+ const routes = pluginRegistry.getRoutes();
118
+ if (authConfig?.oidcProviders?.length) {
119
+ routes.push(createBackchannelLogoutRoute(authConfig.oidcProviders));
182
120
  }
121
+ mountRoutes(fastify, unchainedAPI, routes);
183
122
  if (adminUI) {
184
123
  fastify.register(adminUIRouter, {
185
124
  enabled: true,
@@ -243,5 +182,4 @@ export const adminUIRouter = async (fastify, opts) => {
243
182
  }
244
183
  }
245
184
  };
246
- export const fastifyRouter = adminUIRouter;
247
185
  export { connectChat };
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { UnchainedCore } from '@unchainedshop/core';
3
+ export declare function mountPluginRoutes(fastify: FastifyInstance, unchainedAPI: UnchainedCore): void;
@@ -0,0 +1,62 @@
1
+ import { pluginRegistry } from '@unchainedshop/core';
2
+ import { createServerAdapter } from '@whatwg-node/server';
3
+ export function mountPluginRoutes(fastify, unchainedAPI) {
4
+ const routes = pluginRegistry.getRoutes();
5
+ if (routes.length === 0)
6
+ return;
7
+ const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
8
+ fastify.log.info(`Mounting ${routes.length} plugin route(s): ${endpoints}`);
9
+ fastify.register((scope, opts, registered) => {
10
+ scope.removeAllContentTypeParsers();
11
+ scope.addContentTypeParser('*', function (request, payload, done) {
12
+ done(null);
13
+ });
14
+ for (const route of routes) {
15
+ const adapter = createServerAdapter(async (request, serverContext) => {
16
+ const context = {
17
+ ...unchainedAPI,
18
+ ...serverContext.unchainedContext,
19
+ params: serverContext.params || {},
20
+ rawRequest: serverContext.rawRequest,
21
+ };
22
+ try {
23
+ return await route.handler(request, context);
24
+ }
25
+ catch (error) {
26
+ fastify.log.error(`Error in plugin route handler ${route.method} ${route.path}: ${error instanceof Error ? error.message : String(error)}`);
27
+ return new Response(JSON.stringify({
28
+ error: error instanceof Error ? error.message : 'Internal Server Error',
29
+ }), {
30
+ status: 500,
31
+ headers: { 'Content-Type': 'application/json' },
32
+ });
33
+ }
34
+ });
35
+ let methods;
36
+ if (route.method === 'ALL') {
37
+ methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
38
+ }
39
+ else {
40
+ methods = [route.method];
41
+ }
42
+ scope.route({
43
+ url: route.path,
44
+ method: methods,
45
+ handler: async (req, reply) => {
46
+ const response = await adapter.handleNodeRequestAndResponse(req.raw, reply.raw, {
47
+ unchainedContext: req.unchainedContext,
48
+ params: req.params,
49
+ rawRequest: req.raw,
50
+ });
51
+ response.headers.forEach((value, key) => {
52
+ reply.header(key, value);
53
+ });
54
+ reply.status(response.status);
55
+ reply.send(response.body || undefined);
56
+ return reply;
57
+ },
58
+ });
59
+ }
60
+ registered();
61
+ });
62
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { UnchainedCore, PluginHttpRoute } from '@unchainedshop/core';
3
+ export declare function mountRoutes(fastify: FastifyInstance, unchainedAPI: UnchainedCore, routes: PluginHttpRoute[]): void;
@@ -0,0 +1,60 @@
1
+ import { createServerAdapter } from '@whatwg-node/server';
2
+ export function mountRoutes(fastify, unchainedAPI, routes) {
3
+ if (routes.length === 0)
4
+ return;
5
+ const endpoints = routes.map((r) => `${r.method} ${r.path}`).join(', ');
6
+ fastify.log.info(`Mounting ${routes.length} route(s): ${endpoints}`);
7
+ fastify.register((scope, opts, registered) => {
8
+ scope.removeAllContentTypeParsers();
9
+ scope.addContentTypeParser('*', function (request, payload, done) {
10
+ done(null);
11
+ });
12
+ for (const route of routes) {
13
+ const adapter = createServerAdapter(async (request, serverContext) => {
14
+ const context = {
15
+ ...unchainedAPI,
16
+ ...serverContext.unchainedContext,
17
+ params: serverContext.params || {},
18
+ rawRequest: serverContext.rawRequest,
19
+ };
20
+ try {
21
+ return await route.handler(request, context);
22
+ }
23
+ catch (error) {
24
+ fastify.log.error(`Error in route handler ${route.method} ${route.path}: ${error instanceof Error ? error.message : String(error)}`);
25
+ return new Response(JSON.stringify({
26
+ error: error instanceof Error ? error.message : 'Internal Server Error',
27
+ }), {
28
+ status: 500,
29
+ headers: { 'Content-Type': 'application/json' },
30
+ });
31
+ }
32
+ });
33
+ let methods;
34
+ if (route.method === 'ALL') {
35
+ methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
36
+ }
37
+ else {
38
+ methods = [route.method];
39
+ }
40
+ scope.route({
41
+ url: route.path,
42
+ method: methods,
43
+ handler: async (req, reply) => {
44
+ const response = await adapter.handleNodeRequestAndResponse(req.raw, reply.raw, {
45
+ unchainedContext: req.unchainedContext,
46
+ params: req.params,
47
+ rawRequest: req.raw,
48
+ });
49
+ response.headers.forEach((value, key) => {
50
+ reply.header(key, value);
51
+ });
52
+ reply.status(response.status);
53
+ reply.send(response.body || undefined);
54
+ return reply;
55
+ },
56
+ });
57
+ }
58
+ registered();
59
+ });
60
+ }
@@ -0,0 +1,4 @@
1
+ import type { PluginHttpRoute } from '@unchainedshop/core';
2
+ import type { OIDCProviderConfig } from '../auth.ts';
3
+ export declare function createBackchannelLogoutRoute(providers: OIDCProviderConfig[]): PluginHttpRoute;
4
+ export default createBackchannelLogoutRoute;
@@ -0,0 +1,170 @@
1
+ import * as jose from 'jose';
2
+ import { createLogger } from '@unchainedshop/logger';
3
+ const logger = createLogger('unchained:api:backchannel-logout');
4
+ function normalizeIssuer(url) {
5
+ try {
6
+ const parsed = new URL(url);
7
+ return parsed.origin + parsed.pathname.replace(/\/$/, '');
8
+ }
9
+ catch {
10
+ return url;
11
+ }
12
+ }
13
+ const jwksCache = new Map();
14
+ function getJWKS(jwksUri) {
15
+ let jwks = jwksCache.get(jwksUri);
16
+ if (!jwks) {
17
+ jwks = jose.createRemoteJWKSet(new URL(jwksUri), {
18
+ cooldownDuration: 30000,
19
+ cacheMaxAge: 600000,
20
+ });
21
+ jwksCache.set(jwksUri, jwks);
22
+ }
23
+ return jwks;
24
+ }
25
+ export function createBackchannelLogoutRoute(providers) {
26
+ return {
27
+ path: '/backchannel-logout',
28
+ method: 'ALL',
29
+ handler: async (request, context) => {
30
+ if (request.method !== 'POST') {
31
+ return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
32
+ status: 405,
33
+ headers: { 'Content-Type': 'application/json' },
34
+ });
35
+ }
36
+ try {
37
+ let logoutToken = null;
38
+ const contentType = request.headers.get('content-type') || '';
39
+ if (contentType.includes('application/x-www-form-urlencoded')) {
40
+ const formData = await request.formData();
41
+ logoutToken = formData.get('logout_token');
42
+ }
43
+ else if (contentType.includes('application/json')) {
44
+ const body = (await request.json());
45
+ logoutToken = body.logout_token || null;
46
+ }
47
+ if (!logoutToken) {
48
+ logger.warn('Back-channel logout request missing logout_token');
49
+ return new Response(JSON.stringify({ error: 'missing_logout_token' }), {
50
+ status: 400,
51
+ headers: { 'Content-Type': 'application/json' },
52
+ });
53
+ }
54
+ let decodedPayload;
55
+ try {
56
+ const parts = logoutToken.split('.');
57
+ if (parts.length !== 3) {
58
+ logger.warn('Invalid logout token format: not a valid JWT');
59
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
60
+ status: 400,
61
+ headers: { 'Content-Type': 'application/json' },
62
+ });
63
+ }
64
+ decodedPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
65
+ }
66
+ catch {
67
+ logger.warn('Failed to decode logout token');
68
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
69
+ status: 400,
70
+ headers: { 'Content-Type': 'application/json' },
71
+ });
72
+ }
73
+ const { iss } = decodedPayload;
74
+ if (!iss || typeof iss !== 'string') {
75
+ logger.warn('Logout token missing issuer (iss)');
76
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
77
+ status: 400,
78
+ headers: { 'Content-Type': 'application/json' },
79
+ });
80
+ }
81
+ const normalizedIss = normalizeIssuer(iss);
82
+ const provider = providers.find((p) => normalizeIssuer(p.issuer) === normalizedIss);
83
+ if (!provider) {
84
+ logger.warn('Unknown issuer in logout token:', { iss });
85
+ return new Response(JSON.stringify({ error: 'unknown_issuer' }), {
86
+ status: 400,
87
+ headers: { 'Content-Type': 'application/json' },
88
+ });
89
+ }
90
+ const jwksUri = provider.jwksUri || `${provider.issuer}/.well-known/jwks.json`;
91
+ let verifiedPayload;
92
+ try {
93
+ const JWKS = getJWKS(jwksUri);
94
+ const verifyOptions = {
95
+ issuer: provider.issuer,
96
+ };
97
+ if (provider.audience) {
98
+ verifyOptions.audience = provider.audience;
99
+ }
100
+ const { payload } = await jose.jwtVerify(logoutToken, JWKS, verifyOptions);
101
+ verifiedPayload = payload;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof jose.errors.JWTExpired) {
105
+ logger.warn('Logout token expired');
106
+ return new Response(JSON.stringify({ error: 'token_expired' }), {
107
+ status: 400,
108
+ headers: { 'Content-Type': 'application/json' },
109
+ });
110
+ }
111
+ else if (error instanceof jose.errors.JWTClaimValidationFailed) {
112
+ logger.warn('Logout token claim validation failed:', { message: error.message });
113
+ return new Response(JSON.stringify({ error: 'invalid_claims' }), {
114
+ status: 400,
115
+ headers: { 'Content-Type': 'application/json' },
116
+ });
117
+ }
118
+ else if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
119
+ logger.warn('Logout token signature verification failed - possible forgery attempt');
120
+ return new Response(JSON.stringify({ error: 'invalid_signature' }), {
121
+ status: 400,
122
+ headers: { 'Content-Type': 'application/json' },
123
+ });
124
+ }
125
+ else {
126
+ logger.error('Logout token verification failed:', { error });
127
+ return new Response(JSON.stringify({ error: 'verification_failed' }), {
128
+ status: 400,
129
+ headers: { 'Content-Type': 'application/json' },
130
+ });
131
+ }
132
+ }
133
+ const { sub, events } = verifiedPayload;
134
+ if (!events?.['http://schemas.openid.net/event/backchannel-logout']) {
135
+ logger.warn('Token is not a back-channel logout token (missing events claim)');
136
+ return new Response(JSON.stringify({ error: 'invalid_token_type' }), {
137
+ status: 400,
138
+ headers: { 'Content-Type': 'application/json' },
139
+ });
140
+ }
141
+ if (!sub) {
142
+ logger.warn('Logout token missing subject (sub)');
143
+ return new Response(JSON.stringify({ error: 'missing_subject' }), {
144
+ status: 400,
145
+ headers: { 'Content-Type': 'application/json' },
146
+ });
147
+ }
148
+ const user = await context.modules.users.findUserById(sub);
149
+ if (!user) {
150
+ logger.info('User not found for back-channel logout:', { sub });
151
+ return new Response('', { status: 200 });
152
+ }
153
+ await context.modules.users.updateOidcLogoutAt(user._id, new Date());
154
+ logger.info('Back-channel logout processed successfully:', {
155
+ userId: user._id,
156
+ issuer: iss,
157
+ });
158
+ return new Response('', { status: 200 });
159
+ }
160
+ catch (error) {
161
+ logger.error('Back-channel logout error:', { error });
162
+ return new Response(JSON.stringify({ error: 'internal_error' }), {
163
+ status: 500,
164
+ headers: { 'Content-Type': 'application/json' },
165
+ });
166
+ }
167
+ },
168
+ };
169
+ }
170
+ export default createBackchannelLogoutRoute;
@@ -1,3 +1,3 @@
1
1
  import type { Context } from 'vm';
2
- import type { LocalizationType, LocalizationModuleConfig } from './types.ts';
2
+ import type { LocalizationType, LocalizationModuleConfig } from './schemas.ts';
3
3
  export declare const getLocalizationsConfig: (context: Context, localizationType: LocalizationType) => LocalizationModuleConfig;
@@ -1,5 +1,39 @@
1
1
  import { z } from 'zod';
2
2
  import { SortDirection } from '@unchainedshop/utils';
3
+ export type LocalizationType = 'COUNTRY' | 'CURRENCY' | 'LANGUAGE';
4
+ export interface LocalizationModuleConfig {
5
+ module: any;
6
+ NotFoundError: any;
7
+ entityName: string;
8
+ idField: string;
9
+ existsMethod: any;
10
+ findMethod: any;
11
+ findMultipleMethod: any;
12
+ }
13
+ export interface LocalizationEntity {
14
+ isoCode: string;
15
+ contractAddress?: string;
16
+ decimals?: number;
17
+ }
18
+ export interface LocalizationUpdateEntity {
19
+ isoCode?: string;
20
+ contractAddress?: string;
21
+ decimals?: number;
22
+ }
23
+ export interface LocalizationListOptions {
24
+ limit?: number;
25
+ offset?: number;
26
+ includeInactive?: boolean;
27
+ queryString?: string;
28
+ sort?: {
29
+ key: string;
30
+ value: 'ASC' | 'DESC';
31
+ }[];
32
+ }
33
+ export interface LocalizationCountOptions {
34
+ includeInactive?: boolean;
35
+ queryString?: string;
36
+ }
3
37
  export declare const sortDirectionKeys: [keyof typeof SortDirection, ...(keyof typeof SortDirection)[]];
4
38
  export declare const LocalizationTypeEnum: z.ZodEnum<{
5
39
  COUNTRY: "COUNTRY";