@appsemble/node-utils 0.37.4 → 0.37.6

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
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.37.4/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.37.6/config/assets/logo.svg) Appsemble Node Utilities
2
2
 
3
3
  > NodeJS utilities used by Appsemble internally.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/node-utils)](https://www.npmjs.com/package/@appsemble/node-utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.37.4/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.37.4)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.37.6/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.37.6)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io)
8
8
 
9
9
  ## Table of Contents
@@ -26,5 +26,5 @@ compatibility is not guaranteed.
26
26
 
27
27
  ## License
28
28
 
29
- [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.37.4/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.37.6/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolve `asset()` functions and app asset URLs in CSS to absolute app asset URLs.
3
+ *
4
+ * @param css The CSS to process.
5
+ * @param appId The id of the app the assets belong to.
6
+ * @param host The host on which the app asset endpoints are available.
7
+ * @returns The CSS with all app asset references resolved.
8
+ */
9
+ export declare function replaceAssetFunctions(css: string, appId: number | undefined, host: string): string;
package/assetCssURL.js ADDED
@@ -0,0 +1,134 @@
1
+ const assetUrlMatcher = /url\(\s*asset\(\s*(?:("|')(.*?)\1|([^\s"'()][^)]*))\s*\)\s*\)/g;
2
+ const standaloneAssetMatcher = /asset\(\s*(?:("|')(.*?)\1|([^\s"'()][^)]*))\s*\)/g;
3
+ const urlMatcher = /url\(\s*(?:("|')(.*?)\1|([^\s"'()][^)]*))\s*\)/g;
4
+ const appAssetPathMatcher = /^\/api\/apps\/\d+\/assets\/(.+)$/;
5
+ function hasRejectedTokens(value) {
6
+ let currentValue = value;
7
+ for (let i = 0; i < 3; i += 1) {
8
+ if (currentValue.includes('..') || /%2f|%5c/i.test(currentValue)) {
9
+ return true;
10
+ }
11
+ if (!currentValue.includes('%')) {
12
+ return false;
13
+ }
14
+ try {
15
+ const decodedValue = decodeURIComponent(currentValue);
16
+ if (decodedValue === currentValue) {
17
+ return false;
18
+ }
19
+ if (decodedValue.includes('/') || decodedValue.includes('\\')) {
20
+ return true;
21
+ }
22
+ currentValue = decodedValue;
23
+ }
24
+ catch {
25
+ return true;
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ function rewriteAppAssetURL(url, appId, host) {
31
+ const trimmedUrl = url.trim();
32
+ if (trimmedUrl.startsWith('/')) {
33
+ const appAssetPathMatch = appAssetPathMatcher.exec(trimmedUrl);
34
+ if (!appAssetPathMatch) {
35
+ return trimmedUrl;
36
+ }
37
+ const appAssetPath = appAssetPathMatch[1];
38
+ if (hasRejectedTokens(appAssetPath)) {
39
+ return trimmedUrl;
40
+ }
41
+ return String(new URL(`/api/apps/${appId}/assets/${appAssetPath}`, host));
42
+ }
43
+ try {
44
+ const parsedUrl = new URL(trimmedUrl, host);
45
+ const appAssetPathMatch = appAssetPathMatcher.exec(parsedUrl.pathname);
46
+ if (!appAssetPathMatch) {
47
+ return trimmedUrl;
48
+ }
49
+ const appAssetPath = appAssetPathMatch[1];
50
+ if (hasRejectedTokens(appAssetPath)) {
51
+ return trimmedUrl;
52
+ }
53
+ const rewrittenUrl = new URL(`/api/apps/${appId}/assets/${appAssetPath}`, host);
54
+ rewrittenUrl.search = parsedUrl.search;
55
+ rewrittenUrl.hash = parsedUrl.hash;
56
+ return String(rewrittenUrl);
57
+ }
58
+ catch {
59
+ return trimmedUrl;
60
+ }
61
+ }
62
+ function resolveAssetURL(appId, assetId, host) {
63
+ if (assetId.startsWith('data:')) {
64
+ return assetId;
65
+ }
66
+ if (assetId.startsWith('/')) {
67
+ const appAssetPathMatch = appAssetPathMatcher.exec(assetId.trim());
68
+ if (appAssetPathMatch && hasRejectedTokens(appAssetPathMatch[1])) {
69
+ return null;
70
+ }
71
+ return rewriteAppAssetURL(assetId, appId, host);
72
+ }
73
+ if (/^https?:\/\//.test(assetId)) {
74
+ const trimmedAssetId = assetId.trim();
75
+ try {
76
+ const parsedUrl = new URL(trimmedAssetId, host);
77
+ const appAssetPathMatch = appAssetPathMatcher.exec(parsedUrl.pathname);
78
+ if (appAssetPathMatch && hasRejectedTokens(appAssetPathMatch[1])) {
79
+ return null;
80
+ }
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ return rewriteAppAssetURL(assetId, appId, host);
86
+ }
87
+ if (hasRejectedTokens(assetId)) {
88
+ return null;
89
+ }
90
+ return String(new URL(`/api/apps/${appId}/assets/${assetId}`, host));
91
+ }
92
+ /**
93
+ * Resolve `asset()` functions and app asset URLs in CSS to absolute app asset URLs.
94
+ *
95
+ * @param css The CSS to process.
96
+ * @param appId The id of the app the assets belong to.
97
+ * @param host The host on which the app asset endpoints are available.
98
+ * @returns The CSS with all app asset references resolved.
99
+ */
100
+ export function replaceAssetFunctions(css, appId, host) {
101
+ if (!appId) {
102
+ return css;
103
+ }
104
+ const replaceAssetFunction = (match, ...args) => {
105
+ const quotedAssetId = args[1];
106
+ const unquotedAssetId = args[2];
107
+ const assetId = (quotedAssetId || unquotedAssetId)?.trim();
108
+ if (!assetId) {
109
+ return match;
110
+ }
111
+ const resolvedAssetUrl = resolveAssetURL(appId, assetId, host);
112
+ if (!resolvedAssetUrl) {
113
+ return match;
114
+ }
115
+ return `url('${resolvedAssetUrl}')`;
116
+ };
117
+ const cssWithResolvedAssetFunctionURLs = css
118
+ .replaceAll(assetUrlMatcher, replaceAssetFunction)
119
+ .replaceAll(standaloneAssetMatcher, replaceAssetFunction);
120
+ return cssWithResolvedAssetFunctionURLs.replaceAll(urlMatcher, (match, ...args) => {
121
+ const quotedUrl = args[1];
122
+ const unquotedUrl = args[2];
123
+ const url = (quotedUrl || unquotedUrl)?.trim();
124
+ if (!url) {
125
+ return match;
126
+ }
127
+ const rewrittenUrl = rewriteAppAssetURL(url, appId, host);
128
+ if (rewrittenUrl === url) {
129
+ return match;
130
+ }
131
+ return `url('${rewrittenUrl}')`;
132
+ });
133
+ }
134
+ //# sourceMappingURL=assetCssURL.js.map
@@ -1,6 +1,14 @@
1
1
  export declare const CREDENTIALS_ENV_VAR = "APPSEMBLE_CLIENT_CREDENTIALS";
2
2
  export declare function getService(remote: string): string;
3
- export declare function getKeytar(): Promise<typeof import('keytar')>;
3
+ export interface Keyring {
4
+ findCredentials: (service: string) => Promise<{
5
+ account: string;
6
+ password: string;
7
+ }[]>;
8
+ setPassword: (service: string, account: string, password: string) => Promise<void>;
9
+ deletePassword: (service: string, account: string) => Promise<void>;
10
+ }
11
+ export declare function getKeyring(): Promise<Keyring>;
4
12
  /**
5
13
  * Login to the server using OAuth2 client credentials.
6
14
  *
package/authentication.js CHANGED
@@ -6,15 +6,24 @@ const authorizedRemotes = new Set();
6
6
  export function getService(remote) {
7
7
  return `appsemble://${new URL(remote).host}`;
8
8
  }
9
- export async function getKeytar() {
9
+ async function importKeyring() {
10
10
  try {
11
- const { default: keytar } = await import('keytar');
12
- return keytar;
11
+ return await import('@napi-rs/keyring');
13
12
  }
14
13
  catch {
15
- throw new AppsembleError('Couldn’t find module keytar. Either install libsecret and reinstall @appsemble/cli, or pass --client-credentials on the command line.');
14
+ throw new AppsembleError('Couldn’t access the system keyring. Reinstall @appsemble/cli, or pass --client-credentials on the command line.');
16
15
  }
17
16
  }
17
+ export async function getKeyring() {
18
+ const { AsyncEntry, findCredentialsAsync } = await importKeyring();
19
+ return {
20
+ findCredentials: (service) => findCredentialsAsync(service),
21
+ setPassword: (service, account, password) => new AsyncEntry(service, account).setPassword(password),
22
+ async deletePassword(service, account) {
23
+ await new AsyncEntry(service, account).deletePassword();
24
+ },
25
+ };
26
+ }
18
27
  async function getClientCredentials(remote, inputCredentials) {
19
28
  if (inputCredentials) {
20
29
  return inputCredentials;
@@ -24,7 +33,7 @@ async function getClientCredentials(remote, inputCredentials) {
24
33
  logger.info(`Detected client credentials from ${CREDENTIALS_ENV_VAR} environment variable`);
25
34
  return envCredentials;
26
35
  }
27
- const { findCredentials } = await getKeytar();
36
+ const { findCredentials } = await getKeyring();
28
37
  const choices = await findCredentials(getService(remote));
29
38
  if (choices.length === 0) {
30
39
  throw new AppsembleError(`No client credentials found. Register them using:\n\nappsemble login --remote ${remote}`);
@@ -0,0 +1,3 @@
1
+ import { type BlockManifest } from '@appsemble/types';
2
+ export declare function isValidBlockAssetFilename(filename: string): boolean;
3
+ export declare function getBlockAssetDownloadUrl(blockUrl: string, fileUrls: BlockManifest['fileUrls'], filename: string): string;
@@ -0,0 +1,22 @@
1
+ const blockAssetUnsafePathPattern = /(^|\/)\.{1,2}(\/|$)|^\/|\\/;
2
+ export function isValidBlockAssetFilename(filename) {
3
+ if (!filename || blockAssetUnsafePathPattern.test(filename)) {
4
+ return false;
5
+ }
6
+ for (let i = 0; i < filename.length; i += 1) {
7
+ if (filename.charCodeAt(i) < 0x20) {
8
+ return false;
9
+ }
10
+ }
11
+ return true;
12
+ }
13
+ export function getBlockAssetDownloadUrl(blockUrl, fileUrls, filename) {
14
+ const fileUrl = fileUrls && Object.hasOwn(fileUrls, filename) ? fileUrls[filename] : undefined;
15
+ if (fileUrl) {
16
+ return fileUrl;
17
+ }
18
+ const fallbackUrl = new URL(`${blockUrl.replace(/\/+$/, '')}/asset`);
19
+ fallbackUrl.searchParams.set('filename', filename);
20
+ return String(fallbackUrl);
21
+ }
22
+ //# sourceMappingURL=getBlockAssetDownloadUrl.js.map
package/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from './icon.js';
19
19
  export * from './readAsset.js';
20
20
  export * from './render.js';
21
21
  export * from './getAppsembleMessages.js';
22
+ export * from './getBlockAssetDownloadUrl.js';
22
23
  export * from './odata.js';
23
24
  export * from './resource.js';
24
25
  export * from './resourceEtag.js';
@@ -43,6 +44,7 @@ export * from './container/index.js';
43
44
  export * from './s3.js';
44
45
  export * from './uploads.js';
45
46
  export * from './assets.js';
47
+ export * from './assetCssURL.js';
46
48
  export * from './getValidTrainings.js';
47
49
  export * from './PhoneNumberValidationError.js';
48
50
  export * from './createUser.js';
package/index.js CHANGED
@@ -19,6 +19,7 @@ export * from './icon.js';
19
19
  export * from './readAsset.js';
20
20
  export * from './render.js';
21
21
  export * from './getAppsembleMessages.js';
22
+ export * from './getBlockAssetDownloadUrl.js';
22
23
  export * from './odata.js';
23
24
  export * from './resource.js';
24
25
  export * from './resourceEtag.js';
@@ -43,6 +44,7 @@ export * from './container/index.js';
43
44
  export * from './s3.js';
44
45
  export * from './uploads.js';
45
46
  export * from './assets.js';
47
+ export * from './assetCssURL.js';
46
48
  export * from './getValidTrainings.js';
47
49
  export * from './PhoneNumberValidationError.js';
48
50
  export * from './createUser.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.37.4",
3
+ "version": "0.37.6",
4
4
  "description": "NodeJS utilities used by Appsemble internally.",
5
5
  "keywords": [
6
6
  "app",
@@ -40,13 +40,14 @@
40
40
  "test": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "@appsemble/lang-sdk": "0.37.4",
44
- "@appsemble/types": "0.37.4",
45
- "@appsemble/utils": "0.37.4",
43
+ "@appsemble/lang-sdk": "0.37.6",
44
+ "@appsemble/types": "0.37.6",
45
+ "@appsemble/utils": "0.37.6",
46
46
  "@formatjs/fast-memoize": "^2.0.0",
47
47
  "@fortawesome/fontawesome-free": "^6.0.0",
48
48
  "@inquirer/prompts": "^8.0.0",
49
49
  "@kubernetes/client-node": "1.4.0",
50
+ "@napi-rs/keyring": "^1.3.0",
50
51
  "@odata/parser": "^0.2.0",
51
52
  "@types/koa": "^2.0.0",
52
53
  "axios": "^1.0.0",
@@ -69,7 +70,6 @@
69
70
  "intl-messageformat": "^11.0.0",
70
71
  "ipaddr.js": "^2.4.0",
71
72
  "jsonschema": "~1.4.1",
72
- "keytar": "^7.0.0",
73
73
  "koa": "^3.0.0",
74
74
  "koa-compose": "^4.0.0",
75
75
  "koa-mount": "^4.0.0",
@@ -83,9 +83,9 @@
83
83
  "lodash": "^4.0.0",
84
84
  "lodash-es": "^4.0.0",
85
85
  "logform": "^2.0.0",
86
- "memfs": "4.57.8",
86
+ "memfs": "4.68.1",
87
87
  "mime-types": "^2.0.0",
88
- "minio": "^8.0.3",
88
+ "minio": "^8.0.7",
89
89
  "mustache": "^4.0.0",
90
90
  "openapi-types": "^12.1.3",
91
91
  "parse-duration": "^1.0.0",
@@ -115,7 +115,7 @@
115
115
  "@types/uuid": "10.0.0",
116
116
  "axios-mock-adapter": "1.22.0",
117
117
  "axios-test-instance": "7.0.0",
118
- "vitest": "2.1.9"
118
+ "vitest": "4.1.10"
119
119
  },
120
120
  "peerDependencies": {
121
121
  "prettier": "^3"
package/s3.d.ts CHANGED
@@ -1,5 +1,12 @@
1
- import { type Readable } from 'node:stream';
1
+ import { Readable } from 'node:stream';
2
2
  import { type BucketItemStat } from 'minio';
3
+ export interface S3FileReference {
4
+ etag: string;
5
+ key: string;
6
+ lastModified: Date;
7
+ metadata: BucketItemStat['metaData'];
8
+ size: number;
9
+ }
3
10
  export interface InitS3ClientParams {
4
11
  endPoint: string;
5
12
  port?: number;
@@ -8,11 +15,13 @@ export interface InitS3ClientParams {
8
15
  secretKey: string;
9
16
  }
10
17
  export declare function initS3Client({ accessKey, endPoint, port, secretKey, useSSL, }: InitS3ClientParams): void;
11
- export declare function uploadS3File(bucket: string, key: string, content: Buffer | Readable | string, size?: number): Promise<void>;
18
+ export declare function uploadS3File(bucket: string, key: string, content: Buffer | Readable | string, size?: number, metadata?: Record<string, string>): Promise<void>;
12
19
  export declare function uploadS3FileFromPath(bucket: string, key: string, path: string): Promise<void>;
13
20
  export declare function getS3File(bucket: string, key: string): Promise<Readable>;
14
21
  export declare function getS3FileBuffer(bucket: string, key: string): Promise<Buffer>;
15
22
  export declare function getS3FileStats(bucket: string, key: string): Promise<BucketItemStat>;
23
+ export declare function listS3Files(bucket: string): Promise<S3FileReference[]>;
24
+ export declare function setS3BucketPolicy(bucket: string, policy: string): Promise<void>;
16
25
  export declare function deleteS3Files(bucket: string, keys: string[]): Promise<void>;
17
26
  export declare function deleteS3File(bucket: string, key: string): Promise<void>;
18
27
  export declare function clearAllS3Buckets(): Promise<void>;
package/s3.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Readable } from 'node:stream';
1
2
  import { buffer as streamToBuffer } from 'node:stream/consumers';
2
3
  import { Client, S3Error } from 'minio';
3
4
  import { logger } from './logger.js';
@@ -41,12 +42,21 @@ async function ensureBucket(name) {
41
42
  throw error;
42
43
  }
43
44
  }
44
- export async function uploadS3File(bucket, key, content, size) {
45
+ function isS3ErrorCode(error, code) {
46
+ return error instanceof S3Error && error.code === code;
47
+ }
48
+ export async function uploadS3File(bucket, key, content, size, metadata) {
45
49
  try {
46
50
  await ensureBucket(bucket);
47
- await s3Client.putObject(bucket, key, content, size);
51
+ await s3Client.putObject(bucket, key, content, size, metadata);
48
52
  }
49
53
  catch (error) {
54
+ if (isS3ErrorCode(error, 'NoSuchBucket') && !(content instanceof Readable)) {
55
+ logger.warn(error);
56
+ await ensureBucket(bucket);
57
+ await s3Client.putObject(bucket, key, content, size, metadata);
58
+ return;
59
+ }
50
60
  logger.error(error);
51
61
  throw error;
52
62
  }
@@ -57,6 +67,12 @@ export async function uploadS3FileFromPath(bucket, key, path) {
57
67
  await s3Client.fPutObject(bucket, key, path);
58
68
  }
59
69
  catch (error) {
70
+ if (isS3ErrorCode(error, 'NoSuchBucket')) {
71
+ logger.warn(error);
72
+ await ensureBucket(bucket);
73
+ await s3Client.fPutObject(bucket, key, path);
74
+ return;
75
+ }
60
76
  logger.error(error);
61
77
  throw error;
62
78
  }
@@ -92,6 +108,39 @@ export async function getS3FileStats(bucket, key) {
92
108
  throw error;
93
109
  }
94
110
  }
111
+ export async function listS3Files(bucket) {
112
+ const keys = await new Promise((resolve, reject) => {
113
+ const objects = [];
114
+ const stream = s3Client.listObjectsV2(bucket, '', true);
115
+ stream.on('data', (item) => {
116
+ if (item.name) {
117
+ objects.push(item.name);
118
+ }
119
+ });
120
+ stream.on('error', reject);
121
+ stream.on('end', () => resolve(objects));
122
+ });
123
+ return Promise.all(keys.map(async (key) => {
124
+ const stats = await getS3FileStats(bucket, key);
125
+ return {
126
+ etag: stats.etag,
127
+ key,
128
+ lastModified: stats.lastModified,
129
+ metadata: stats.metaData,
130
+ size: stats.size,
131
+ };
132
+ }));
133
+ }
134
+ export async function setS3BucketPolicy(bucket, policy) {
135
+ try {
136
+ await ensureBucket(bucket);
137
+ await s3Client.setBucketPolicy(bucket, policy);
138
+ }
139
+ catch (error) {
140
+ logger.error(error);
141
+ throw error;
142
+ }
143
+ }
95
144
  export async function deleteS3Files(bucket, keys) {
96
145
  try {
97
146
  await s3Client.removeObjects(bucket, keys);
@@ -112,13 +161,37 @@ export async function clearAllS3Buckets() {
112
161
  try {
113
162
  const buckets = await s3Client.listBuckets();
114
163
  for (const bucket of buckets) {
115
- const objectsStream = s3Client.listObjectsV2(bucket.name, '', true);
116
- const objects = [];
117
- for await (const o of objectsStream) {
118
- objects.push(o.name);
164
+ try {
165
+ const objectsStream = s3Client.listObjectsV2(bucket.name, '', true);
166
+ const objects = [];
167
+ for await (const o of objectsStream) {
168
+ objects.push(o.name);
169
+ }
170
+ await s3Client.removeObjects(bucket.name, objects);
171
+ try {
172
+ await s3Client.removeBucket(bucket.name);
173
+ }
174
+ catch (error) {
175
+ if (isS3ErrorCode(error, 'NoSuchBucket')) {
176
+ continue;
177
+ }
178
+ if (!isS3ErrorCode(error, 'BucketNotEmpty')) {
179
+ throw error;
180
+ }
181
+ const remainingObjectsStream = s3Client.listObjectsV2(bucket.name, '', true);
182
+ const remainingObjects = [];
183
+ for await (const o of remainingObjectsStream) {
184
+ remainingObjects.push(o.name);
185
+ }
186
+ await s3Client.removeObjects(bucket.name, remainingObjects);
187
+ await s3Client.removeBucket(bucket.name);
188
+ }
189
+ }
190
+ catch (error) {
191
+ if (!isS3ErrorCode(error, 'NoSuchBucket')) {
192
+ throw error;
193
+ }
119
194
  }
120
- await s3Client.removeObjects(bucket.name, objects);
121
- await s3Client.removeBucket(bucket.name);
122
195
  }
123
196
  }
124
197
  catch (error) {
@@ -6,8 +6,17 @@ export function createBlockAssetHandler({ getBlockAsset }) {
6
6
  params: { filename, name, version }, } = ctx;
7
7
  const blockAsset = await getBlockAsset({ filename, name, version, context: ctx });
8
8
  assertKoaCondition(blockAsset != null, ctx, 404, 'Block asset not found');
9
- ctx.set('Cache-Control', 'max-age=31536000,immutable');
10
- ctx.body = blockAsset.content;
9
+ ctx.set('Cache-Control', 'public,max-age=31536000,immutable');
10
+ if (blockAsset.size != null) {
11
+ ctx.set('Content-Length', String(blockAsset.size));
12
+ }
13
+ if (blockAsset.etag) {
14
+ ctx.set('ETag', blockAsset.etag);
15
+ }
16
+ if (blockAsset.lastModified) {
17
+ ctx.set('Last-Modified', blockAsset.lastModified.toUTCString());
18
+ }
19
+ ctx.body = blockAsset.stream ?? blockAsset.content;
11
20
  ctx.type = blockAsset.mime;
12
21
  };
13
22
  }
package/server/types.d.ts CHANGED
@@ -391,7 +391,11 @@ export interface AppAsset extends Asset {
391
391
  export interface ProjectAsset {
392
392
  filename: string;
393
393
  mime: string;
394
- content: Buffer;
394
+ content?: Buffer;
395
+ stream?: Readable;
396
+ size?: number;
397
+ etag?: string;
398
+ lastModified?: Date;
395
399
  }
396
400
  export interface Block extends BlockDefinition {
397
401
  OrganizationId: string;