@crowdin/app-project-module 3.1.0 → 3.2.0

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.
@@ -1,13 +1,15 @@
1
- import Crowdin from '@crowdin/crowdin-api-client';
1
+ import type Crowdin from '@crowdin/crowdin-api-client';
2
2
  import { Response } from 'express';
3
3
  import { Config, CrowdinClientRequest, CrowdinContextInfo, SubscriptionInfo } from '../types';
4
4
  import { LogErrorFunction, LogFunction } from '../util/logger';
5
- export declare function prepareCrowdinRequest({ jwtToken, config, optional, checkSubscriptionExpiration, moduleKey, }: {
5
+ export declare function prepareCrowdinRequest({ jwtToken, config, optional, checkSubscriptionExpiration, moduleKey, crowdinToken, projectId, }: {
6
6
  jwtToken: string;
7
7
  config: Config;
8
8
  optional: boolean;
9
9
  checkSubscriptionExpiration: boolean;
10
10
  moduleKey: string[] | string | undefined;
11
+ crowdinToken?: string;
12
+ projectId?: number;
11
13
  }): Promise<{
12
14
  context: CrowdinContextInfo;
13
15
  logInfo: LogFunction;
@@ -20,8 +20,17 @@ const logger_1 = require("../util/logger");
20
20
  const subscription_1 = require("../util/subscription");
21
21
  const integration_access_denied_1 = require("./integration-access-denied");
22
22
  const token_1 = require("../util/app-functions/token");
23
+ function assertModuleKey(moduleKey, jwtModule) {
24
+ if (!moduleKey || !jwtModule) {
25
+ return;
26
+ }
27
+ const matches = typeof moduleKey === 'string' ? moduleKey === jwtModule : moduleKey.includes(jwtModule);
28
+ if (!matches) {
29
+ throw new Error("Module key doesn't match");
30
+ }
31
+ }
23
32
  function prepareCrowdinRequest(_a) {
24
- return __awaiter(this, arguments, void 0, function* ({ jwtToken, config, optional = false, checkSubscriptionExpiration = true, moduleKey, }) {
33
+ return __awaiter(this, arguments, void 0, function* ({ jwtToken, config, optional = false, checkSubscriptionExpiration = true, moduleKey, crowdinToken, projectId, }) {
25
34
  (0, logger_1.log)('Validating jwt token from incoming request');
26
35
  const jwtPayload = yield (0, token_1.validateJwtToken)(jwtToken, config.clientSecret, config.jwtValidationOptions);
27
36
  const context = {
@@ -32,6 +41,29 @@ function prepareCrowdinRequest(_a) {
32
41
  };
33
42
  const logInfo = (0, logger_1.withContext)(context);
34
43
  const logError = (0, logger_1.withContextError)(context);
44
+ if (crowdinToken) {
45
+ logInfo('Using caller-supplied Crowdin token');
46
+ assertModuleKey(moduleKey, jwtPayload.module);
47
+ const client = yield (0, connection_1.buildCrowdinClientFromPat)({
48
+ config,
49
+ token: crowdinToken,
50
+ // For enterprise the org-specific URL implicitly restricts the token to this org.
51
+ // For Crowdin.com (no domain) we fall back to project-level access validation below.
52
+ organization: jwtPayload.domain || undefined,
53
+ });
54
+ try {
55
+ yield client.usersApi.getAuthenticatedUser();
56
+ // For non-enterprise (no domain) there is no org-scoped URL, so validate by
57
+ // checking that the PAT has access to the project named in the JWT / request.
58
+ if (!jwtPayload.domain && projectId) {
59
+ yield client.projectsGroupsApi.getProject(projectId);
60
+ }
61
+ }
62
+ catch (_b) {
63
+ throw new util_1.CodeError('Supplied crowdinToken is invalid or does not belong to this organization', 403);
64
+ }
65
+ return { context, client, logInfo, logError };
66
+ }
35
67
  logInfo('Loading crowdin credentials');
36
68
  const credentials = yield (0, storage_1.getStorage)().getCrowdinCredentials(context.crowdinId);
37
69
  if (!credentials) {
@@ -40,14 +72,7 @@ function prepareCrowdinRequest(_a) {
40
72
  }
41
73
  throw new Error("Can't find organization by id");
42
74
  }
43
- if (!!moduleKey && !!jwtPayload.module) {
44
- if (typeof moduleKey === 'string' && moduleKey !== jwtPayload.module) {
45
- throw new Error("Module key doesn't match");
46
- }
47
- if (Array.isArray(moduleKey) && !moduleKey.includes(jwtPayload.module)) {
48
- throw new Error("Module key doesn't match");
49
- }
50
- }
75
+ assertModuleKey(moduleKey, jwtPayload.module);
51
76
  logInfo('Building crowdin client instance');
52
77
  const { client, token } = yield (0, connection_1.prepareCrowdinClient)({ config, credentials, autoRenew: true, context });
53
78
  let subscriptionInfo;
@@ -67,22 +92,25 @@ function prepareCrowdinRequest(_a) {
67
92
  }
68
93
  function handle({ config, optional = false, checkSubscriptionExpiration = true, moduleKey, isIntegration = false, isLogout = false, }) {
69
94
  return (0, util_1.runAsyncWrapper)((req, res, next) => __awaiter(this, void 0, void 0, function* () {
70
- var _a, _b;
95
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
71
96
  const jwtToken = getToken(req);
72
97
  if (!jwtToken) {
73
98
  (0, logger_1.temporaryErrorDebug)('Access denied: crowdin-client', req);
74
99
  return res.status(403).send({ error: 'Access denied' });
75
100
  }
76
101
  try {
102
+ const isAgentCall = req.isApiCall && !!((_a = req.body) === null || _a === void 0 ? void 0 : _a.crowdinToken);
77
103
  const data = yield prepareCrowdinRequest({
78
104
  jwtToken,
79
105
  config,
80
106
  optional,
81
107
  checkSubscriptionExpiration,
82
108
  moduleKey,
109
+ crowdinToken: isAgentCall ? req.body.crowdinToken : undefined,
110
+ projectId: isAgentCall ? (_b = req.body) === null || _b === void 0 ? void 0 : _b.projectId : undefined,
83
111
  });
84
- if ((_a = config.api) === null || _a === void 0 ? void 0 : _a.default) {
85
- if (req.isApiCall && !((_b = req.body) === null || _b === void 0 ? void 0 : _b.projectId)) {
112
+ if ((_c = config.api) === null || _c === void 0 ? void 0 : _c.default) {
113
+ if (req.isApiCall && !((_d = req.body) === null || _d === void 0 ? void 0 : _d.projectId)) {
86
114
  return res.status(400).json({
87
115
  error: {
88
116
  message: 'Missing required parameter: projectId',
@@ -91,7 +119,10 @@ function handle({ config, optional = false, checkSubscriptionExpiration = true,
91
119
  }
92
120
  data.context = (0, api_1.updateCrowdinContext)(req, data.context);
93
121
  }
94
- if (isIntegration && data.client) {
122
+ // Skip integration access check when the caller supplies their own credentials —
123
+ // integration-credentials middleware handles those via the injected path.
124
+ const hasInjectedIntegrationCreds = req.isApiCall && !!((_e = req.body) === null || _e === void 0 ? void 0 : _e.integrationCredentials);
125
+ if (isIntegration && data.client && !hasInjectedIntegrationCreds) {
95
126
  const result = yield (0, connection_1.resolveIntegrationAccess)(data.context.clientId);
96
127
  if (!result.integrationCredentials && !isLogout) {
97
128
  const owners = yield (0, integration_access_denied_1.getIntegrationManagedBy)({
@@ -121,12 +152,18 @@ function handle({ config, optional = false, checkSubscriptionExpiration = true,
121
152
  req.subscriptionInfo = data.subscriptionInfo;
122
153
  req.logInfo = data.logInfo;
123
154
  req.logError = data.logError;
155
+ // Strip secrets from req.body so no downstream handler ever sees raw tokens.
156
+ // crowdinToken has been consumed; move integrationCredentials to a typed slot
157
+ // so the integration-credentials middleware can still pick it up.
158
+ req.injectedIntegrationCredentials = (_f = req.body) === null || _f === void 0 ? void 0 : _f.integrationCredentials;
159
+ (_g = req.body) === null || _g === void 0 ? true : delete _g.crowdinToken;
160
+ (_h = req.body) === null || _h === void 0 ? true : delete _h.integrationCredentials;
124
161
  next();
125
162
  }
126
163
  catch (e) {
127
164
  const errorMessage = (0, logger_1.getErrorMessage)(e);
128
165
  if (e instanceof util_1.CodeError) {
129
- return res.status(402).send({ message: errorMessage || 'Error', code: e.code });
166
+ return res.status((_j = e.code) !== null && _j !== void 0 ? _j : 402).send({ message: errorMessage || 'Error', code: e.code });
130
167
  }
131
168
  (0, logger_1.logError)(e);
132
169
  return res.status(403).send({ error: errorMessage || 'Error' });
@@ -15,9 +15,42 @@ const storage_1 = require("../storage");
15
15
  const util_1 = require("../util");
16
16
  const connection_1 = require("../util/connection");
17
17
  const integration_access_denied_1 = require("./integration-access-denied");
18
+ function loadIntegrationSettings(clientId, integration, req) {
19
+ return __awaiter(this, void 0, void 0, function* () {
20
+ const integrationConfig = yield (0, storage_1.getStorage)().getIntegrationConfig(clientId);
21
+ let settings = (integrationConfig === null || integrationConfig === void 0 ? void 0 : integrationConfig.config) ? JSON.parse(integrationConfig.config) || {} : {};
22
+ if (integration.normalizeSettings) {
23
+ settings = yield integration.normalizeSettings({
24
+ settings,
25
+ credentials: req.integrationCredentials,
26
+ client: req.crowdinApiClient,
27
+ });
28
+ }
29
+ return settings;
30
+ });
31
+ }
18
32
  function handle({ config, integration, optional = false, isLogout = false, }) {
19
33
  return (0, util_1.runAsyncWrapper)((req, res, next) => __awaiter(this, void 0, void 0, function* () {
20
34
  const isApiCall = req === null || req === void 0 ? void 0 : req.isApiCall;
35
+ // crowdin-client middleware moves integrationCredentials out of req.body before next(),
36
+ // so secrets are never visible to handlers. Read from the typed slot it left behind.
37
+ const rawInjected = req.injectedIntegrationCredentials;
38
+ if (isApiCall && rawInjected) {
39
+ delete req.injectedIntegrationCredentials;
40
+ req.logInfo('Using caller-supplied integration credentials');
41
+ req.integrationCredentials = typeof rawInjected === 'string' ? { accessToken: rawInjected } : rawInjected;
42
+ if (integration.checkConnection) {
43
+ try {
44
+ yield integration.checkConnection(req.integrationCredentials);
45
+ }
46
+ catch (e) {
47
+ return res.status(401).json({ error: { message: 'Supplied integrationCredentials are invalid' } });
48
+ }
49
+ }
50
+ const clientId = req.crowdinContext.clientId;
51
+ req.integrationSettings = yield loadIntegrationSettings(clientId, integration, req);
52
+ return next();
53
+ }
21
54
  req.logInfo(`Loading integration credentials for client ${req.crowdinContext.clientId}`);
22
55
  const { integrationCredentials: resolvedIntegrationCredentials, projectIntegrationCredentials, projectId, clientId: resolvedClientId, ownerIds, } = yield (0, connection_1.resolveIntegrationAccess)(req.crowdinContext.clientId);
23
56
  let integrationCredentials = resolvedIntegrationCredentials;
@@ -79,18 +112,7 @@ function handle({ config, integration, optional = false, isLogout = false, }) {
79
112
  throw new util_1.CodeError(message, 401);
80
113
  }
81
114
  }
82
- const integrationConfig = yield (0, storage_1.getStorage)().getIntegrationConfig(clientId);
83
- if (integrationConfig === null || integrationConfig === void 0 ? void 0 : integrationConfig.config) {
84
- let integrationSettings = JSON.parse(integrationConfig.config) || {};
85
- if (integration.normalizeSettings) {
86
- integrationSettings = yield integration.normalizeSettings({
87
- settings: integrationSettings,
88
- credentials: req.integrationCredentials,
89
- client: req.crowdinApiClient,
90
- });
91
- }
92
- req.integrationSettings = integrationSettings;
93
- }
115
+ req.integrationSettings = yield loadIntegrationSettings(clientId, integration, req);
94
116
  next();
95
117
  }));
96
118
  }
@@ -13,11 +13,24 @@ exports.default = handle;
13
13
  const util_1 = require("../../../util");
14
14
  const defaults_1 = require("../util/defaults");
15
15
  const logger_1 = require("../../../util/logger");
16
+ const types_1 = require("../types");
16
17
  const files_1 = require("../util/files");
18
+ const storage_1 = require("../../../storage");
17
19
  function handle(config, integration) {
18
20
  return (0, util_1.runAsyncWrapper)((req, res) => __awaiter(this, void 0, void 0, function* () {
19
- var _a;
21
+ var _a, _b;
20
22
  const mode = (_a = req.query) === null || _a === void 0 ? void 0 : _a.mode;
23
+ const externalId = (_b = req.query) === null || _b === void 0 ? void 0 : _b.externalId;
24
+ // Reverse lookup: given an integration file ID, return the mapped Crowdin file entry.
25
+ if (externalId) {
26
+ const syncedData = yield (0, storage_1.getStorage)().getSyncedData(req.crowdinContext.clientId, req.crowdinContext.crowdinId, types_1.Provider.INTEGRATION);
27
+ const syncedFiles = (syncedData === null || syncedData === void 0 ? void 0 : syncedData.files) ? JSON.parse(syncedData.files) : [];
28
+ const entry = syncedFiles.find((f) => f.id === externalId);
29
+ if (!(entry === null || entry === void 0 ? void 0 : entry.crowdinFileId)) {
30
+ return res.status(404).json({ error: { message: 'No Crowdin file mapped to this external ID' } });
31
+ }
32
+ return res.json({ id: String(entry.crowdinFileId), externalFileId: externalId });
33
+ }
21
34
  req.logInfo(`Loading crowdin files${mode ? ` (mode: ${mode})` : ''}`);
22
35
  if (integration.getCrowdinFiles) {
23
36
  const rootFolder = yield (0, defaults_1.getRootFolder)(config, integration, req.crowdinApiClient, req.crowdinContext.jwtPayload.context.project_id);
@@ -41,6 +54,26 @@ function handle(config, integration) {
41
54
  files,
42
55
  });
43
56
  }
57
+ // Path B: populate externalFileId from stored integrationFileId→crowdinFileId mapping
58
+ // for files where the app developer hasn't supplied it directly (Path A).
59
+ // A leaf is either a typed file or explicitly flagged with nodeType '1';
60
+ // folders (nodeType '0') and branches (nodeType '2') without a type are skipped.
61
+ const isFileLeaf = (f) => f.type !== undefined || f.nodeType === '1';
62
+ const needsMapping = files.some((f) => isFileLeaf(f) && !f.externalFileId);
63
+ if (needsMapping) {
64
+ const syncedData = yield (0, storage_1.getStorage)().getSyncedData(req.crowdinContext.clientId, req.crowdinContext.crowdinId, types_1.Provider.INTEGRATION);
65
+ if (syncedData === null || syncedData === void 0 ? void 0 : syncedData.files) {
66
+ const syncedFiles = JSON.parse(syncedData.files);
67
+ const byCrowdinFileId = new Map(syncedFiles
68
+ .filter((e) => e.crowdinFileId !== null && e.crowdinFileId !== undefined)
69
+ .map((e) => [String(e.crowdinFileId), e.id]));
70
+ if (byCrowdinFileId.size > 0) {
71
+ files = files.map((f) => !isFileLeaf(f) || f.externalFileId || !byCrowdinFileId.has(f.id)
72
+ ? f
73
+ : Object.assign(Object.assign({}, f), { externalFileId: byCrowdinFileId.get(f.id) }));
74
+ }
75
+ }
76
+ }
44
77
  req.logInfo(`Returning ${files.length} items`);
45
78
  res.send(files);
46
79
  }
@@ -451,6 +451,7 @@ export interface BaseTreeItem {
451
451
  path?: string;
452
452
  type?: string;
453
453
  syncedAt?: string;
454
+ externalFileId?: string;
454
455
  }
455
456
  export interface File extends BaseTreeItem {
456
457
  type: SourceFilesModel.FileType;
package/out/types.d.ts CHANGED
@@ -408,6 +408,7 @@ export interface CrowdinClientRequest extends Request {
408
408
  logInfo: LogFunction;
409
409
  logError: LogErrorFunction;
410
410
  isApiCall?: boolean;
411
+ injectedIntegrationCredentials?: any;
411
412
  }
412
413
  export interface CrowdinCredentials {
413
414
  id: string;
@@ -17,5 +17,10 @@ export interface IntegrationAccessResult {
17
17
  clientId: string;
18
18
  ownerIds: number[];
19
19
  }
20
+ export declare function buildCrowdinClientFromPat({ config, token, organization, }: {
21
+ config: Config;
22
+ token: string;
23
+ organization?: string;
24
+ }): Promise<Crowdin>;
20
25
  export declare function resolveIntegrationAccess(clientId: string): Promise<IntegrationAccessResult>;
21
26
  export declare function prepareIntegrationCredentials(config: Config, integration: IntegrationLogic, integrationCredentials: IntegrationCredentials): Promise<any>;
@@ -46,6 +46,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.prepareCrowdinClient = prepareCrowdinClient;
49
+ exports.buildCrowdinClientFromPat = buildCrowdinClientFromPat;
49
50
  exports.resolveIntegrationAccess = resolveIntegrationAccess;
50
51
  exports.prepareIntegrationCredentials = prepareIntegrationCredentials;
51
52
  const crowdin_api_client_1 = __importDefault(require("@crowdin/crowdin-api-client"));
@@ -256,6 +257,12 @@ function prepareCrowdinClient(_a) {
256
257
  };
257
258
  });
258
259
  }
260
+ function buildCrowdinClientFromPat(_a) {
261
+ return __awaiter(this, arguments, void 0, function* ({ config, token, organization, }) {
262
+ var _b;
263
+ return new crowdin_api_client_1.default({ token, organization, apiDomain: (_b = config.crowdinUrls) === null || _b === void 0 ? void 0 : _b.apiDomain });
264
+ });
265
+ }
259
266
  function resolveIntegrationAccess(clientId) {
260
267
  return __awaiter(this, void 0, void 0, function* () {
261
268
  const { organization, projectId, userId } = (0, token_1.parseCrowdinId)(clientId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdin/app-project-module",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Module that generates for you all common endpoints for serving standalone Crowdin App",
5
5
  "main": "out/index.js",
6
6
  "types": "out/index.d.ts",