@logto/cli 1.37.1 → 1.39.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.
@@ -27,6 +27,12 @@ export const getAvailableAlterations = async (pool, compareMode = 'gt') => {
27
27
  ? getTimestampFromFilename(filename) > databaseTimestamp
28
28
  : getTimestampFromFilename(filename) <= databaseTimestamp);
29
29
  };
30
+ /**
31
+ * IMPORTANT: Logto Cloud has a parallel implementation of this logic in `CoreAlterationClient`
32
+ * (logto-cloud repo: `packages/azure-functions/src/utils/core-alteration-client.ts`).
33
+ * Any changes to the alteration execution behavior here (e.g., new hooks, execution order)
34
+ * must be synchronized with the Cloud implementation.
35
+ */
30
36
  const deployAlteration = async (pool, { path: filePath, filename }, action = 'up') => {
31
37
  const { up, down, beforeUp, beforeDown } = await importAlterationScript(filePath);
32
38
  const timestamp = getTimestampFromFilename(filename);
@@ -0,0 +1,25 @@
1
+ import { LogtoOidcConfigKey, type SupportedSigningKeyAlgorithm, type OidcConfigKey, type OidcPrivateKey } from '@logto/schemas';
2
+ import type { CommonQueryMethods } from '@silverhand/slonik';
3
+ type RotateConfigResult = {
4
+ keys: OidcPrivateKey[] | OidcConfigKey[];
5
+ };
6
+ export declare const parseRotationGracePeriod: (value?: string) => number | undefined;
7
+ export declare const getEffectiveRotationGracePeriod: ({ key, gracePeriod, envGracePeriod, }: {
8
+ key: LogtoOidcConfigKey.PrivateKeys | LogtoOidcConfigKey.CookieKeys;
9
+ gracePeriod?: number;
10
+ envGracePeriod?: string;
11
+ }) => number;
12
+ export declare const rotateConfigKey: ({ connection, tenantId, key, privateKeyType, gracePeriod, }: {
13
+ connection: CommonQueryMethods;
14
+ tenantId: string;
15
+ key: LogtoOidcConfigKey.PrivateKeys | LogtoOidcConfigKey.CookieKeys;
16
+ privateKeyType: SupportedSigningKeyAlgorithm;
17
+ gracePeriod: number;
18
+ }) => Promise<RotateConfigResult>;
19
+ export declare const trimConfigKey: ({ connection, tenantId, key, length, }: {
20
+ connection: CommonQueryMethods;
21
+ tenantId: string;
22
+ key: LogtoOidcConfigKey.PrivateKeys | LogtoOidcConfigKey.CookieKeys;
23
+ length: number;
24
+ }) => Promise<RotateConfigResult>;
25
+ export {};
@@ -0,0 +1,111 @@
1
+ import { getSeededOidcPrivateKeys, getImmediatelyRotatedOidcPrivateKeys, getRotationStateForCacheInvalidation, getRotationStateForStagedRotation, getStagedRotatedOidcPrivateKeys, getTrimmedOidcPrivateKeys, LogtoOidcConfigKey, LogtoTenantConfigKey, normalizeOidcPrivateKeys, logtoConfigGuards, } from '@logto/schemas';
2
+ import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js';
3
+ import { consoleLog } from '../../utils.js';
4
+ import { generateOidcCookieKey, generateOidcPrivateKey } from './utils.js';
5
+ export const parseRotationGracePeriod = (value) => {
6
+ if (value === undefined || value.trim() === '') {
7
+ return;
8
+ }
9
+ const parsed = Number(value);
10
+ if (!Number.isInteger(parsed) || parsed < 0) {
11
+ throw new TypeError('Invalid PRIVATE_KEY_ROTATION_GRACE_PERIOD env value');
12
+ }
13
+ return parsed;
14
+ };
15
+ export const getEffectiveRotationGracePeriod = ({ key, gracePeriod, envGracePeriod, }) => {
16
+ if (key === LogtoOidcConfigKey.CookieKeys) {
17
+ if (gracePeriod !== undefined) {
18
+ throw new TypeError(`${LogtoOidcConfigKey.CookieKeys} does not support grace period`);
19
+ }
20
+ return 0;
21
+ }
22
+ if (gracePeriod !== undefined) {
23
+ if (!Number.isInteger(gracePeriod) || gracePeriod < 0) {
24
+ throw new TypeError('Invalid grace period provided');
25
+ }
26
+ return gracePeriod;
27
+ }
28
+ return parseRotationGracePeriod(envGracePeriod) ?? 0;
29
+ };
30
+ const getRotationStateRow = (rows) => {
31
+ const row = rows.find(({ key }) => key === LogtoTenantConfigKey.SigningKeyRotationState);
32
+ if (!row) {
33
+ return undefined;
34
+ }
35
+ const parsed = logtoConfigGuards[LogtoTenantConfigKey.SigningKeyRotationState].safeParse(row.value);
36
+ return parsed.success ? parsed.data : undefined;
37
+ };
38
+ export const rotateConfigKey = async ({ connection, tenantId, key, privateKeyType, gracePeriod, }) => {
39
+ const { rows } = await getRowsByKeys(connection, tenantId, [
40
+ key,
41
+ LogtoTenantConfigKey.SigningKeyRotationState,
42
+ ]);
43
+ const configRow = rows.find((row) => row.key === key);
44
+ const currentRotationState = getRotationStateRow(rows);
45
+ if (!configRow) {
46
+ consoleLog.warn('No key found, create a new one');
47
+ }
48
+ switch (key) {
49
+ case LogtoOidcConfigKey.PrivateKeys: {
50
+ const parsed = logtoConfigGuards[key].safeParse(configRow?.value);
51
+ const original = parsed.success ? parsed.data : [];
52
+ const hasExistingPrivateKeys = original.length > 0;
53
+ const newPrivateKey = await generateOidcPrivateKey(privateKeyType);
54
+ const rotatedPrivateKeys = hasExistingPrivateKeys
55
+ ? gracePeriod === 0
56
+ ? getImmediatelyRotatedOidcPrivateKeys(original, newPrivateKey)
57
+ : getStagedRotatedOidcPrivateKeys(original, newPrivateKey)
58
+ : getSeededOidcPrivateKeys([newPrivateKey]);
59
+ const rotationState = !hasExistingPrivateKeys || gracePeriod === 0
60
+ ? getRotationStateForCacheInvalidation(currentRotationState)
61
+ : getRotationStateForStagedRotation(currentRotationState, gracePeriod);
62
+ await updateValueByKey(connection, tenantId, key, rotatedPrivateKeys);
63
+ await updateValueByKey(connection, tenantId, LogtoTenantConfigKey.SigningKeyRotationState, rotationState);
64
+ return { keys: rotatedPrivateKeys };
65
+ }
66
+ case LogtoOidcConfigKey.CookieKeys: {
67
+ const parsed = logtoConfigGuards[key].safeParse(configRow?.value);
68
+ const original = parsed.success ? parsed.data : [];
69
+ const rotatedCookieKeys = [generateOidcCookieKey(), ...original];
70
+ const rotationState = getRotationStateForCacheInvalidation(currentRotationState);
71
+ await updateValueByKey(connection, tenantId, key, rotatedCookieKeys);
72
+ await updateValueByKey(connection, tenantId, LogtoTenantConfigKey.SigningKeyRotationState, rotationState);
73
+ return { keys: rotatedCookieKeys };
74
+ }
75
+ }
76
+ };
77
+ export const trimConfigKey = async ({ connection, tenantId, key, length, }) => {
78
+ const { rows } = await getRowsByKeys(connection, tenantId, [
79
+ key,
80
+ LogtoTenantConfigKey.SigningKeyRotationState,
81
+ ]);
82
+ const configRow = rows.find((row) => row.key === key);
83
+ const currentRotationState = getRotationStateRow(rows);
84
+ if (!configRow) {
85
+ consoleLog.fatal('No key found');
86
+ }
87
+ switch (key) {
88
+ case LogtoOidcConfigKey.PrivateKeys: {
89
+ const value = normalizeOidcPrivateKeys(logtoConfigGuards[key].parse(configRow.value));
90
+ const trimmedPrivateKeys = getTrimmedOidcPrivateKeys(value, length);
91
+ const rotationState = getRotationStateForCacheInvalidation(currentRotationState);
92
+ if (trimmedPrivateKeys.length === 0) {
93
+ consoleLog.fatal(`You should keep at least one key in the array, current length=${value.length}`);
94
+ }
95
+ await updateValueByKey(connection, tenantId, key, trimmedPrivateKeys);
96
+ await updateValueByKey(connection, tenantId, LogtoTenantConfigKey.SigningKeyRotationState, rotationState);
97
+ return { keys: trimmedPrivateKeys };
98
+ }
99
+ case LogtoOidcConfigKey.CookieKeys: {
100
+ const value = logtoConfigGuards[key].parse(configRow.value);
101
+ const rotationState = getRotationStateForCacheInvalidation(currentRotationState);
102
+ if (value.length - length < 1) {
103
+ consoleLog.fatal(`You should keep at least one key in the array, current length=${value.length}`);
104
+ }
105
+ const trimmedCookieKeys = value.slice(0, -length);
106
+ await updateValueByKey(connection, tenantId, key, trimmedCookieKeys);
107
+ await updateValueByKey(connection, tenantId, LogtoTenantConfigKey.SigningKeyRotationState, rotationState);
108
+ return { keys: trimmedCookieKeys };
109
+ }
110
+ }
111
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ const { getEffectiveRotationGracePeriod, parseRotationGracePeriod } = await import('./config-rotation.js');
3
+ const { LogtoOidcConfigKey } = await import('@logto/schemas');
4
+ describe('parseRotationGracePeriod()', () => {
5
+ it('returns undefined when env is not set', () => {
6
+ expect(parseRotationGracePeriod()).toBeUndefined();
7
+ expect(parseRotationGracePeriod(' ')).toBeUndefined();
8
+ });
9
+ it('parses a valid non-negative integer', () => {
10
+ expect(parseRotationGracePeriod('14400')).toBe(14_400);
11
+ });
12
+ it('throws on malformed input instead of silently falling back to 0', () => {
13
+ expect(() => parseRotationGracePeriod('4h')).toThrow('Invalid PRIVATE_KEY_ROTATION_GRACE_PERIOD env value');
14
+ expect(() => parseRotationGracePeriod('-1')).toThrow('Invalid PRIVATE_KEY_ROTATION_GRACE_PERIOD env value');
15
+ });
16
+ });
17
+ describe('getEffectiveRotationGracePeriod()', () => {
18
+ it('uses the explicit private-key grace period when provided', () => {
19
+ expect(getEffectiveRotationGracePeriod({
20
+ key: LogtoOidcConfigKey.PrivateKeys,
21
+ gracePeriod: 60,
22
+ })).toBe(60);
23
+ });
24
+ it('uses the env default for private keys when no explicit grace period is provided', () => {
25
+ expect(getEffectiveRotationGracePeriod({
26
+ key: LogtoOidcConfigKey.PrivateKeys,
27
+ envGracePeriod: '120',
28
+ })).toBe(120);
29
+ });
30
+ it('rejects cookie-key grace period even when the explicit value is 0', () => {
31
+ expect(() => getEffectiveRotationGracePeriod({
32
+ key: LogtoOidcConfigKey.CookieKeys,
33
+ gracePeriod: 0,
34
+ })).toThrow(`${LogtoOidcConfigKey.CookieKeys} does not support grace period`);
35
+ });
36
+ });
@@ -4,7 +4,7 @@ import chalk from 'chalk';
4
4
  import { createPoolFromConfig } from '../../database.js';
5
5
  import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js';
6
6
  import { consoleLog } from '../../utils.js';
7
- import { generateOidcCookieKey, generateOidcPrivateKey } from './utils.js';
7
+ import { getEffectiveRotationGracePeriod, rotateConfigKey, trimConfigKey, } from './config-rotation.js';
8
8
  const validKeysDisplay = chalk.green(logtoConfigKeys.join(', '));
9
9
  const validateKeys = (keys) => {
10
10
  const invalidKey = (Array.isArray(keys) ? keys : [keys]).find(
@@ -37,6 +37,10 @@ const validatePrivateKeyType = (key) => {
37
37
  consoleLog.fatal(`Invalid private key type ${chalk.red(key)} found, expected one of ${validPrivateKeyTypes.join(', ')}`);
38
38
  }
39
39
  };
40
+ const getValidatedPrivateKeyType = (key) => {
41
+ validatePrivateKeyType(key);
42
+ return key;
43
+ };
40
44
  const getConfig = {
41
45
  command: 'get <key> [keys...]',
42
46
  describe: 'Get config value(s) of the given key(s) in Logto database',
@@ -103,7 +107,7 @@ const setConfig = {
103
107
  };
104
108
  const rotateConfig = {
105
109
  command: 'rotate <key>',
106
- describe: 'Generate a new private or secret key for the given config key and prepend to the key array',
110
+ describe: 'Generate a new private or secret key for the given config key and update the key state',
107
111
  builder: (yargs) => yargs
108
112
  .positional('key', {
109
113
  describe: `The key to rotate, one of ${chalk.green(validRotateKeys.join(', '))}`,
@@ -119,31 +123,37 @@ const rotateConfig = {
119
123
  describe: `The key type for ${LogtoOidcConfigKey.PrivateKeys}, one of ${validPrivateKeyTypes.join(', ')}`,
120
124
  type: 'string',
121
125
  default: 'ec',
126
+ })
127
+ .option('gracePeriod', {
128
+ describe: `Grace period in seconds for ${LogtoOidcConfigKey.PrivateKeys} staged rotation`,
129
+ type: 'number',
122
130
  }),
123
- handler: async ({ key, tenantId, type }) => {
131
+ handler: async ({ key, tenantId, type, gracePeriod }) => {
124
132
  const keyType = type.toUpperCase();
133
+ const privateKeyType = key === LogtoOidcConfigKey.PrivateKeys
134
+ ? getValidatedPrivateKeyType(keyType)
135
+ : SupportedSigningKeyAlgorithm.EC;
125
136
  validateRotateKey(key);
126
- validatePrivateKeyType(keyType);
127
- const pool = await createPoolFromConfig();
128
- const { rows } = await getRowsByKeys(pool, tenantId, [key]);
129
- if (!rows[0]) {
130
- consoleLog.warn('No key found, create a new one');
131
- }
132
- const getValue = async () => {
133
- const parsed = logtoConfigGuards[key].safeParse(rows[0]?.value);
134
- const original = parsed.success ? parsed.data : [];
135
- // No need for default. It's already exhaustive
136
- switch (key) {
137
- case LogtoOidcConfigKey.PrivateKeys: {
138
- return [await generateOidcPrivateKey(keyType), ...original];
139
- }
140
- case LogtoOidcConfigKey.CookieKeys: {
141
- return [generateOidcCookieKey(), ...original];
142
- }
137
+ const effectiveGracePeriod = (() => {
138
+ try {
139
+ return getEffectiveRotationGracePeriod({
140
+ key,
141
+ gracePeriod,
142
+ envGracePeriod: process.env.PRIVATE_KEY_ROTATION_GRACE_PERIOD,
143
+ });
143
144
  }
144
- };
145
- const rotated = await getValue();
146
- await updateValueByKey(pool, tenantId, key, rotated);
145
+ catch (error) {
146
+ return consoleLog.fatal(error instanceof Error ? error.message : 'Invalid grace period provided');
147
+ }
148
+ })();
149
+ const pool = await createPoolFromConfig();
150
+ const { keys: rotated } = await pool.transaction(async (connection) => rotateConfigKey({
151
+ connection,
152
+ tenantId,
153
+ key,
154
+ privateKeyType,
155
+ gracePeriod: effectiveGracePeriod,
156
+ }));
147
157
  await pool.end();
148
158
  consoleLog.info(`Rotate ${chalk.green(key)} succeeded, now it has ${rotated.length} keys`);
149
159
  },
@@ -174,20 +184,12 @@ const trimConfig = {
174
184
  consoleLog.fatal('Invalid length provided');
175
185
  }
176
186
  const pool = await createPoolFromConfig();
177
- const { rows } = await getRowsByKeys(pool, tenantId, [key]);
178
- if (!rows[0]) {
179
- consoleLog.warn('No key found, create a new one');
180
- }
181
- const getValue = async () => {
182
- const value = logtoConfigGuards[key].parse(rows[0]?.value);
183
- if (value.length - length < 1) {
184
- await pool.end();
185
- consoleLog.fatal(`You should keep at least one key in the array, current length=${value.length}`);
186
- }
187
- return value.slice(0, -length);
188
- };
189
- const trimmed = await getValue();
190
- await updateValueByKey(pool, tenantId, key, trimmed);
187
+ const { keys: trimmed } = await pool.transaction(async (connection) => trimConfigKey({
188
+ connection,
189
+ tenantId,
190
+ key,
191
+ length,
192
+ }));
191
193
  await pool.end();
192
194
  consoleLog.info(`Trim ${chalk.green(key)} succeeded, now it has ${trimmed.length} keys`);
193
195
  },
@@ -1,6 +1,7 @@
1
1
  import type { LogtoOidcConfigType } from '@logto/schemas';
2
2
  import { LogtoOidcConfigKey } from '@logto/schemas';
3
3
  import type { DatabaseTransactionConnection } from '@silverhand/slonik';
4
+ type OidcConfigKeyToSeed = Exclude<LogtoOidcConfigKey, LogtoOidcConfigKey.Session>;
4
5
  export declare const seedOidcConfigs: (pool: DatabaseTransactionConnection, tenantId: string) => Promise<void>;
5
6
  /**
6
7
  * Each config reader will do the following things in order:
@@ -8,8 +9,9 @@ export declare const seedOidcConfigs: (pool: DatabaseTransactionConnection, tena
8
9
  * 2. Generate value if #1 doesn't work
9
10
  */
10
11
  export declare const oidcConfigReaders: {
11
- [key in LogtoOidcConfigKey]: () => Promise<{
12
+ [key in OidcConfigKeyToSeed]: () => Promise<{
12
13
  value: LogtoOidcConfigType[key];
13
14
  fromEnv: boolean;
14
15
  }>;
15
16
  };
17
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { LogtoOidcConfigKey, logtoConfigGuards } from '@logto/schemas';
2
+ import { LogtoOidcConfigKey, getSeededOidcPrivateKeys, logtoConfigGuards } from '@logto/schemas';
3
3
  import { generateStandardId } from '@logto/shared';
4
4
  import { getEnvAsStringArray } from '@silverhand/essentials';
5
5
  import chalk from 'chalk';
@@ -8,13 +8,14 @@ import { getRowsByKeys, updateValueByKey } from '../../../queries/logto-config.j
8
8
  import { consoleLog } from '../../../utils.js';
9
9
  import { buildOidcKeyFromRawString, generateOidcCookieKey, generateOidcPrivateKey, } from '../utils.js';
10
10
  const isBase64FormatPrivateKey = (key) => !key.includes('-');
11
+ const oidcConfigKeysToSeed = Object.values(LogtoOidcConfigKey).filter((key) => key !== LogtoOidcConfigKey.Session);
11
12
  export const seedOidcConfigs = async (pool, tenantId) => {
12
13
  const tenantPrefix = `[${tenantId}]`;
13
14
  const configGuard = z.object({
14
15
  key: z.nativeEnum(LogtoOidcConfigKey),
15
16
  value: z.unknown(),
16
17
  });
17
- const { rows } = await getRowsByKeys(pool, tenantId, Object.values(LogtoOidcConfigKey));
18
+ const { rows } = await getRowsByKeys(pool, tenantId, oidcConfigKeysToSeed);
18
19
  // Filter out valid keys that hold a valid value
19
20
  const result = await Promise.all(rows.map(async (row) => {
20
21
  try {
@@ -25,7 +26,7 @@ export const seedOidcConfigs = async (pool, tenantId) => {
25
26
  catch { }
26
27
  }));
27
28
  const existingKeys = new Set(result.filter(Boolean));
28
- const validOptions = Object.values(LogtoOidcConfigKey).filter((key) => {
29
+ const validOptions = oidcConfigKeysToSeed.filter((key) => {
29
30
  const included = existingKeys.has(key);
30
31
  if (included) {
31
32
  consoleLog.info(tenantPrefix, `Key ${chalk.green(key)} exists, skipping`);
@@ -68,7 +69,7 @@ export const oidcConfigReaders = {
68
69
  const privateKeys = getEnvAsStringArray('OIDC_PRIVATE_KEYS');
69
70
  if (privateKeys.length > 0) {
70
71
  return {
71
- value: privateKeys.map((key) => buildOidcKeyFromRawString(isBase64FormatPrivateKey(key) ? Buffer.from(key, 'base64').toString('utf8') : key)),
72
+ value: getSeededOidcPrivateKeys(privateKeys.map((key) => buildOidcKeyFromRawString(isBase64FormatPrivateKey(key) ? Buffer.from(key, 'base64').toString('utf8') : key))),
72
73
  fromEnv: true,
73
74
  };
74
75
  }
@@ -77,12 +78,12 @@ export const oidcConfigReaders = {
77
78
  if (privateKeyPaths.length > 0) {
78
79
  const privateKeys = await Promise.all(privateKeyPaths.map(async (path) => readFile(path, 'utf8')));
79
80
  return {
80
- value: privateKeys.map((key) => buildOidcKeyFromRawString(key)),
81
+ value: getSeededOidcPrivateKeys(privateKeys.map((key) => buildOidcKeyFromRawString(key))),
81
82
  fromEnv: true,
82
83
  };
83
84
  }
84
85
  return {
85
- value: [await generateOidcPrivateKey()],
86
+ value: getSeededOidcPrivateKeys([await generateOidcPrivateKey()]),
86
87
  fromEnv: false,
87
88
  };
88
89
  },
@@ -29,6 +29,7 @@ export declare const packageJson: {
29
29
  lint: string;
30
30
  "lint:report": string;
31
31
  test: string;
32
+ "test:watch": string;
32
33
  "test:ci": string;
33
34
  prepack: string;
34
35
  };
@@ -1,6 +1,6 @@
1
1
  export const packageJson = {
2
2
  "name": "@logto/cli",
3
- "version": "1.37.1",
3
+ "version": "1.39.0",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -31,7 +31,8 @@ export const packageJson = {
31
31
  "start:dev": "pnpm build && node .",
32
32
  "lint": "eslint --ext .ts src",
33
33
  "lint:report": "pnpm lint --format json --output-file report.json",
34
- "test": "vitest src",
34
+ "test": "vitest run src",
35
+ "test:watch": "vitest src --watch",
35
36
  "test:ci": "pnpm run test --silent --coverage",
36
37
  "prepack": "pnpm build"
37
38
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/cli",
3
- "version": "1.37.1",
3
+ "version": "1.39.0",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -45,10 +45,10 @@
45
45
  "tar": "^7.5.7",
46
46
  "yargs": "^17.6.0",
47
47
  "zod": "3.24.3",
48
- "@logto/core-kit": "^2.7.1",
49
- "@logto/schemas": "1.37.1",
50
- "@logto/shared": "^3.3.1",
51
- "@logto/connector-kit": "^4.7.0"
48
+ "@logto/connector-kit": "^5.0.0",
49
+ "@logto/core-kit": "^2.9.0",
50
+ "@logto/schemas": "1.39.0",
51
+ "@logto/shared": "^3.4.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@silverhand/eslint-config": "6.0.1",
@@ -88,7 +88,8 @@
88
88
  "start:dev": "pnpm build && node .",
89
89
  "lint": "eslint --ext .ts src",
90
90
  "lint:report": "pnpm lint --format json --output-file report.json",
91
- "test": "vitest src",
91
+ "test": "vitest run src",
92
+ "test:watch": "vitest src --watch",
92
93
  "test:ci": "pnpm run test --silent --coverage"
93
94
  }
94
95
  }