@openmdm/core 0.7.0 → 0.9.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.
package/src/index.ts CHANGED
@@ -73,6 +73,8 @@ import type {
73
73
  PluginStorageAdapter,
74
74
  GroupTreeNode,
75
75
  GroupHierarchyStats,
76
+ Logger,
77
+ EnrollmentChallenge,
76
78
  } from './types';
77
79
  import {
78
80
  DeviceNotFoundError,
@@ -88,6 +90,17 @@ import { createScheduleManager } from './schedule';
88
90
  import { createMessageQueueManager } from './queue';
89
91
  import { createDashboardManager } from './dashboard';
90
92
  import { createPluginStorageAdapter, createMemoryPluginStorageAdapter } from './plugin-storage';
93
+ import { createConsoleLogger, createSilentLogger } from './logger';
94
+ import {
95
+ importPublicKeyFromSpki,
96
+ verifyEcdsaSignature,
97
+ canonicalEnrollmentMessage,
98
+ canonicalDeviceRequestMessage,
99
+ verifyDeviceRequest,
100
+ InvalidPublicKeyError,
101
+ PublicKeyMismatchError,
102
+ ChallengeInvalidError,
103
+ } from './device-identity';
91
104
 
92
105
  // Re-export all types
93
106
  export * from './types';
@@ -95,6 +108,19 @@ export * from './schema';
95
108
  export * from './agent-protocol';
96
109
  export { createWebhookManager, verifyWebhookSignature } from './webhooks';
97
110
  export type { WebhookPayload } from './webhooks';
111
+ export { createConsoleLogger, createSilentLogger } from './logger';
112
+
113
+ // Device identity (Phase 2b)
114
+ export {
115
+ importPublicKeyFromSpki,
116
+ verifyEcdsaSignature,
117
+ canonicalEnrollmentMessage,
118
+ canonicalDeviceRequestMessage,
119
+ verifyDeviceRequest,
120
+ InvalidPublicKeyError,
121
+ PublicKeyMismatchError,
122
+ ChallengeInvalidError,
123
+ } from './device-identity';
98
124
 
99
125
  // Re-export enterprise manager factories
100
126
  export { createTenantManager } from './tenant';
@@ -111,17 +137,36 @@ export { createPluginStorageAdapter, createMemoryPluginStorageAdapter, createPlu
111
137
  export function createMDM(config: MDMConfig): MDMInstance {
112
138
  const { database, push, enrollment, webhooks: webhooksConfig, plugins = [] } = config;
113
139
 
140
+ // Structured logger. Falls back to the console-backed default if
141
+ // the host doesn't pass one. Host code is expected to pass a real
142
+ // pino/winston instance in production.
143
+ const logger = config.logger ?? createConsoleLogger();
144
+
145
+ // Extract a stable message from an unknown thrown value so it
146
+ // survives JSON serialization into the log context. Error objects
147
+ // stringify to `{}` otherwise, which is the #1 cause of "we can't
148
+ // tell why this failed" in production logs.
149
+ const errorMessage = (err: unknown): string => {
150
+ if (err instanceof Error) return err.message;
151
+ if (typeof err === 'string') return err;
152
+ try {
153
+ return JSON.stringify(err);
154
+ } catch {
155
+ return String(err);
156
+ }
157
+ };
158
+
114
159
  // Event handlers registry
115
160
  const eventHandlers = new Map<EventType, Set<EventHandler<EventType>>>();
116
161
 
117
162
  // Create push adapter
118
163
  const pushAdapter: PushAdapter = push
119
- ? createPushAdapter(push, database)
120
- : createStubPushAdapter();
164
+ ? createPushAdapter(push, database, logger)
165
+ : createStubPushAdapter(logger);
121
166
 
122
167
  // Create webhook manager if configured
123
168
  const webhookManager: WebhookManager | undefined = webhooksConfig
124
- ? createWebhookManager(webhooksConfig)
169
+ ? createWebhookManager(webhooksConfig, logger)
125
170
  : undefined;
126
171
 
127
172
  // ============================================
@@ -205,13 +250,16 @@ export function createMDM(config: MDMConfig): MDMInstance {
205
250
  payload: eventRecord.payload as Record<string, unknown>,
206
251
  });
207
252
  } catch (error) {
208
- console.error('[OpenMDM] Failed to persist event:', error);
253
+ logger.error({ err: errorMessage(error), event }, 'Failed to persist event');
209
254
  }
210
255
 
211
256
  // Deliver webhooks (async, don't wait)
212
257
  if (webhookManager) {
213
258
  webhookManager.deliver(eventRecord).catch((error) => {
214
- console.error('[OpenMDM] Webhook delivery error:', error);
259
+ logger.error(
260
+ { err: errorMessage(error), event },
261
+ 'Webhook delivery error',
262
+ );
215
263
  });
216
264
  }
217
265
 
@@ -221,7 +269,10 @@ export function createMDM(config: MDMConfig): MDMInstance {
221
269
  try {
222
270
  await handler(eventRecord);
223
271
  } catch (error) {
224
- console.error(`[OpenMDM] Event handler error for ${event}:`, error);
272
+ logger.error(
273
+ { err: errorMessage(error), event },
274
+ 'Event handler threw',
275
+ );
225
276
  }
226
277
  }
227
278
  }
@@ -231,7 +282,7 @@ export function createMDM(config: MDMConfig): MDMInstance {
231
282
  try {
232
283
  await config.onEvent(eventRecord);
233
284
  } catch (error) {
234
- console.error('[OpenMDM] onEvent hook error:', error);
285
+ logger.error({ err: errorMessage(error) }, 'onEvent hook threw');
235
286
  }
236
287
  }
237
288
  };
@@ -907,8 +958,24 @@ export function createMDM(config: MDMConfig): MDMInstance {
907
958
  );
908
959
  }
909
960
 
910
- // Verify signature if secret is configured
911
- if (enrollment?.deviceSecret) {
961
+ // Determine which enrollment path the request is asking for.
962
+ // The presence of `publicKey` is the signal: if the device
963
+ // supplies a public key, it is attempting the Phase 2b
964
+ // device-pinned-key path and must also supply a valid
965
+ // attestation challenge. Otherwise we fall through to the
966
+ // legacy HMAC path.
967
+ const isPinnedKeyPath = Boolean(request.publicKey);
968
+
969
+ if (!isPinnedKeyPath && enrollment?.pinnedKey?.required) {
970
+ throw new EnrollmentError(
971
+ 'Pinned-key enrollment is required but the request carried no publicKey. ' +
972
+ 'The agent must generate a Keystore keypair and submit the SPKI public key ' +
973
+ 'alongside an ECDSA signature over the canonical enrollment message.',
974
+ );
975
+ }
976
+
977
+ // HMAC path (Phase 2a): unchanged behavior.
978
+ if (!isPinnedKeyPath && enrollment?.deviceSecret) {
912
979
  const isValid = verifyEnrollmentSignature(
913
980
  request,
914
981
  enrollment.deviceSecret
@@ -918,6 +985,81 @@ export function createMDM(config: MDMConfig): MDMInstance {
918
985
  }
919
986
  }
920
987
 
988
+ // Pinned-key path (Phase 2b).
989
+ let challengeRecord: EnrollmentChallenge | null = null;
990
+ let importedPublicKey: ReturnType<typeof importPublicKeyFromSpki> | null = null;
991
+ if (isPinnedKeyPath) {
992
+ if (!request.attestationChallenge) {
993
+ throw new EnrollmentError(
994
+ 'Pinned-key enrollment requires attestationChallenge. ' +
995
+ 'Fetch a fresh challenge from /agent/enroll/challenge first.',
996
+ );
997
+ }
998
+ if (!database.consumeEnrollmentChallenge) {
999
+ throw new EnrollmentError(
1000
+ 'Pinned-key enrollment requires an adapter that implements enrollment ' +
1001
+ 'challenge storage. Upgrade to a database adapter that supports it, or ' +
1002
+ 'submit an HMAC-signed enrollment instead.',
1003
+ );
1004
+ }
1005
+
1006
+ // Parse the public key first — if it's malformed the signature
1007
+ // cannot possibly verify and we want a specific error.
1008
+ try {
1009
+ importedPublicKey = importPublicKeyFromSpki(request.publicKey as string);
1010
+ } catch (err) {
1011
+ throw new EnrollmentError(
1012
+ err instanceof Error
1013
+ ? `Invalid enrollment public key: ${err.message}`
1014
+ : 'Invalid enrollment public key',
1015
+ );
1016
+ }
1017
+
1018
+ // Atomically consume the challenge. This must happen BEFORE
1019
+ // signature verification, otherwise two concurrent requests
1020
+ // with the same challenge could both succeed.
1021
+ challengeRecord = await database.consumeEnrollmentChallenge(
1022
+ request.attestationChallenge,
1023
+ );
1024
+ if (!challengeRecord) {
1025
+ throw new ChallengeInvalidError(
1026
+ 'Enrollment challenge is missing, expired, or already consumed',
1027
+ request.attestationChallenge,
1028
+ );
1029
+ }
1030
+ if (challengeRecord.expiresAt.getTime() < Date.now()) {
1031
+ throw new ChallengeInvalidError(
1032
+ 'Enrollment challenge has expired',
1033
+ request.attestationChallenge,
1034
+ );
1035
+ }
1036
+
1037
+ const canonical = canonicalEnrollmentMessage({
1038
+ publicKey: request.publicKey as string,
1039
+ model: request.model,
1040
+ manufacturer: request.manufacturer,
1041
+ osVersion: request.osVersion,
1042
+ serialNumber: request.serialNumber,
1043
+ imei: request.imei,
1044
+ macAddress: request.macAddress,
1045
+ androidId: request.androidId,
1046
+ method: request.method,
1047
+ timestamp: request.timestamp,
1048
+ challenge: request.attestationChallenge,
1049
+ });
1050
+
1051
+ const verified = verifyEcdsaSignature(
1052
+ importedPublicKey,
1053
+ canonical,
1054
+ request.signature,
1055
+ );
1056
+ if (!verified) {
1057
+ throw new EnrollmentError(
1058
+ 'Invalid enrollment signature (device-pinned-key path)',
1059
+ );
1060
+ }
1061
+ }
1062
+
921
1063
  // Custom validation
922
1064
  if (enrollment?.validate) {
923
1065
  const isValid = await enrollment.validate(request);
@@ -943,14 +1085,40 @@ export function createMDM(config: MDMConfig): MDMInstance {
943
1085
  let device = await database.findDeviceByEnrollmentId(enrollmentId);
944
1086
 
945
1087
  if (device) {
946
- // Device re-enrolling
947
- device = await database.updateDevice(device.id, {
1088
+ // Device re-enrolling. If the device is already on the
1089
+ // pinned-key path, the submitted public key MUST match the
1090
+ // pinned one — otherwise we reject loudly. This is how we
1091
+ // prevent an attacker who extracted the enrollment secret
1092
+ // from hijacking an enrolled device's identity: without the
1093
+ // original private key they cannot produce a valid signature,
1094
+ // and even if they could (via a forged HMAC fallback), the
1095
+ // pinned key still identifies the legitimate device.
1096
+ if (isPinnedKeyPath && device.publicKey) {
1097
+ if (device.publicKey !== request.publicKey) {
1098
+ throw new PublicKeyMismatchError(device.id);
1099
+ }
1100
+ }
1101
+
1102
+ const updateInput: UpdateDeviceInput = {
948
1103
  status: 'enrolled',
949
1104
  model: request.model,
950
1105
  manufacturer: request.manufacturer,
951
1106
  osVersion: request.osVersion,
952
1107
  lastSync: new Date(),
953
- });
1108
+ };
1109
+
1110
+ // Pin the key on first pinned-key enrollment for a device
1111
+ // that originally enrolled on HMAC. This is the migration
1112
+ // path: a device that used to sign with the shared secret
1113
+ // can upgrade by sending its freshly-generated public key on
1114
+ // its next enrollment, and the server will pin it from then
1115
+ // on.
1116
+ if (isPinnedKeyPath && !device.publicKey) {
1117
+ updateInput.publicKey = request.publicKey;
1118
+ updateInput.enrollmentMethod = 'pinned-key';
1119
+ }
1120
+
1121
+ device = await database.updateDevice(device.id, updateInput);
954
1122
  } else if (enrollment?.autoEnroll) {
955
1123
  // Auto-create device
956
1124
  device = await database.createDevice({
@@ -965,6 +1133,17 @@ export function createMDM(config: MDMConfig): MDMInstance {
965
1133
  policyId: request.policyId || enrollment.defaultPolicyId,
966
1134
  });
967
1135
 
1136
+ // Pin the public key on first enrollment for pinned-key path.
1137
+ // `CreateDeviceInput` deliberately doesn't carry auth fields —
1138
+ // we keep auth state a post-creation concern so legacy
1139
+ // adapters don't have to know about it.
1140
+ if (isPinnedKeyPath) {
1141
+ device = await database.updateDevice(device.id, {
1142
+ publicKey: request.publicKey,
1143
+ enrollmentMethod: 'pinned-key',
1144
+ });
1145
+ }
1146
+
968
1147
  // Add to default group if configured
969
1148
  if (enrollment.defaultGroupId) {
970
1149
  await database.addDeviceToGroup(device.id, enrollment.defaultGroupId);
@@ -981,6 +1160,15 @@ export function createMDM(config: MDMConfig): MDMInstance {
981
1160
  macAddress: request.macAddress,
982
1161
  androidId: request.androidId,
983
1162
  });
1163
+
1164
+ // Pin the public key even for pending devices — we want to
1165
+ // know which key originally enrolled once an admin approves.
1166
+ if (isPinnedKeyPath) {
1167
+ device = await database.updateDevice(device.id, {
1168
+ publicKey: request.publicKey,
1169
+ enrollmentMethod: 'pinned-key',
1170
+ });
1171
+ }
984
1172
  // Status remains 'pending'
985
1173
  } else {
986
1174
  throw new EnrollmentError(
@@ -1193,6 +1381,7 @@ export function createMDM(config: MDMConfig): MDMInstance {
1193
1381
  push: pushAdapter,
1194
1382
  webhooks: webhookManager,
1195
1383
  db: database,
1384
+ logger,
1196
1385
  config,
1197
1386
  on,
1198
1387
  emit,
@@ -1217,11 +1406,11 @@ export function createMDM(config: MDMConfig): MDMInstance {
1217
1406
  if (plugin.onInit) {
1218
1407
  try {
1219
1408
  await plugin.onInit(instance);
1220
- console.log(`[OpenMDM] Plugin initialized: ${plugin.name}`);
1409
+ logger.info({ plugin: plugin.name }, 'Plugin initialized');
1221
1410
  } catch (error) {
1222
- console.error(
1223
- `[OpenMDM] Failed to initialize plugin ${plugin.name}:`,
1224
- error
1411
+ logger.error(
1412
+ { plugin: plugin.name, err: errorMessage(error) },
1413
+ 'Failed to initialize plugin',
1225
1414
  );
1226
1415
  }
1227
1416
  }
@@ -1237,19 +1426,22 @@ export function createMDM(config: MDMConfig): MDMInstance {
1237
1426
 
1238
1427
  function createPushAdapter(
1239
1428
  config: MDMConfig['push'],
1240
- database: MDMConfig['database']
1429
+ database: MDMConfig['database'],
1430
+ logger: Logger,
1241
1431
  ): PushAdapter {
1242
1432
  if (!config) {
1243
- return createStubPushAdapter();
1433
+ return createStubPushAdapter(logger);
1244
1434
  }
1245
1435
 
1436
+ const pushLogger = logger.child({ component: 'push' });
1437
+
1246
1438
  // The actual implementations will be provided by separate packages
1247
1439
  // This is a base implementation that logs and stores tokens
1248
1440
  return {
1249
1441
  async send(deviceId: string, message: PushMessage): Promise<PushResult> {
1250
- console.log(
1251
- `[OpenMDM] Push to ${deviceId}: ${message.type}`,
1252
- message.payload
1442
+ pushLogger.debug(
1443
+ { deviceId, type: message.type, payload: message.payload },
1444
+ 'send',
1253
1445
  );
1254
1446
 
1255
1447
  // In production, this would be replaced by FCM/MQTT adapter
@@ -1260,8 +1452,9 @@ function createPushAdapter(
1260
1452
  deviceIds: string[],
1261
1453
  message: PushMessage
1262
1454
  ): Promise<PushBatchResult> {
1263
- console.log(
1264
- `[OpenMDM] Push to ${deviceIds.length} devices: ${message.type}`
1455
+ pushLogger.debug(
1456
+ { count: deviceIds.length, type: message.type },
1457
+ 'sendBatch',
1265
1458
  );
1266
1459
 
1267
1460
  const results = deviceIds.map((deviceId) => ({
@@ -1298,10 +1491,11 @@ function createPushAdapter(
1298
1491
  };
1299
1492
  }
1300
1493
 
1301
- function createStubPushAdapter(): PushAdapter {
1494
+ function createStubPushAdapter(logger: Logger): PushAdapter {
1495
+ const stubLogger = logger.child({ component: 'push-stub' });
1302
1496
  return {
1303
1497
  async send(deviceId: string, message: PushMessage): Promise<PushResult> {
1304
- console.log(`[OpenMDM] Push (stub): ${deviceId} <- ${message.type}`);
1498
+ stubLogger.debug({ deviceId, type: message.type }, 'send (stub)');
1305
1499
  return { success: true, messageId: 'stub' };
1306
1500
  },
1307
1501
 
@@ -1309,8 +1503,9 @@ function createStubPushAdapter(): PushAdapter {
1309
1503
  deviceIds: string[],
1310
1504
  message: PushMessage
1311
1505
  ): Promise<PushBatchResult> {
1312
- console.log(
1313
- `[OpenMDM] Push (stub): ${deviceIds.length} devices <- ${message.type}`
1506
+ stubLogger.debug(
1507
+ { count: deviceIds.length, type: message.type },
1508
+ 'sendBatch (stub)',
1314
1509
  );
1315
1510
  return {
1316
1511
  successCount: deviceIds.length,
package/src/logger.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * OpenMDM Logger
3
+ *
4
+ * Default logger implementations and helpers. Production users are
5
+ * expected to pass their own pino/winston/bunyan instance via
6
+ * `createMDM({ logger })`; these defaults are for development and
7
+ * for the zero-config path.
8
+ */
9
+
10
+ import type { Logger, LogContext } from './types';
11
+
12
+ /**
13
+ * Resolve (context, message) or (message) argument forms into a
14
+ * single shape. Matches the pino call convention used by the Logger
15
+ * interface.
16
+ */
17
+ function normalize(
18
+ ...args: [LogContext, string] | [string]
19
+ ): { context: LogContext | undefined; message: string } {
20
+ if (args.length === 1) {
21
+ return { context: undefined, message: args[0] };
22
+ }
23
+ return { context: args[0], message: args[1] };
24
+ }
25
+
26
+ /**
27
+ * Console-backed logger. Writes JSON-ish lines to stdout/stderr with
28
+ * an `[openmdm]` prefix so they stand out in a mixed-log stream.
29
+ *
30
+ * This is the zero-config default — it intentionally does the
31
+ * minimum viable thing. Hosts running in production should replace
32
+ * it with a real structured logger.
33
+ */
34
+ export function createConsoleLogger(scope: string[] = []): Logger {
35
+ const prefix = scope.length > 0 ? `[openmdm:${scope.join(':')}]` : '[openmdm]';
36
+
37
+ const render = (context: LogContext | undefined): string => {
38
+ if (!context || Object.keys(context).length === 0) return '';
39
+ // Keep single-line to remain friendly to `grep`. JSON.stringify is
40
+ // the cheapest structured-output format that every production
41
+ // logger can consume as-is.
42
+ try {
43
+ return ' ' + JSON.stringify(context);
44
+ } catch {
45
+ // Fall back to a string cast when the context has a circular
46
+ // reference — losing structure is better than crashing the call
47
+ // site.
48
+ return ' ' + String(context);
49
+ }
50
+ };
51
+
52
+ return {
53
+ debug: (...args: [LogContext, string] | [string]) => {
54
+ const { context, message } = normalize(...args);
55
+ // Debug is off by default when no DEBUG env var is set — keeps
56
+ // the dev experience quiet unless someone opts in.
57
+ if (!process.env.DEBUG) return;
58
+ console.debug(`${prefix} ${message}${render(context)}`);
59
+ },
60
+ info: (...args: [LogContext, string] | [string]) => {
61
+ const { context, message } = normalize(...args);
62
+ console.log(`${prefix} ${message}${render(context)}`);
63
+ },
64
+ warn: (...args: [LogContext, string] | [string]) => {
65
+ const { context, message } = normalize(...args);
66
+ console.warn(`${prefix} ${message}${render(context)}`);
67
+ },
68
+ error: (...args: [LogContext, string] | [string]) => {
69
+ const { context, message } = normalize(...args);
70
+ console.error(`${prefix} ${message}${render(context)}`);
71
+ },
72
+ child: (bindings: LogContext): Logger => {
73
+ // Console logger's `child` extends the scope with any
74
+ // `component` field if provided, otherwise appends nothing
75
+ // meaningful and just returns a new logger with the same
76
+ // scope. Real loggers (pino) properly attach bindings to every
77
+ // subsequent call — we do the simplest thing that won't lie.
78
+ const componentPart =
79
+ typeof bindings.component === 'string' ? [bindings.component] : [];
80
+ return createConsoleLogger([...scope, ...componentPart]);
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * No-op logger. Use to silence OpenMDM entirely — e.g. in tests or in
87
+ * environments where log noise is inappropriate.
88
+ */
89
+ export function createSilentLogger(): Logger {
90
+ const silent: Logger = {
91
+ debug: () => undefined,
92
+ info: () => undefined,
93
+ warn: () => undefined,
94
+ error: () => undefined,
95
+ child: () => silent,
96
+ };
97
+ return silent;
98
+ }
@@ -19,7 +19,11 @@ export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAd
19
19
  }
20
20
 
21
21
  // Fallback: not supported
22
- console.warn('Plugin storage not supported by database adapter');
22
+ // Silently no-op: the plugin-storage contract treats missing
23
+ // adapter methods as "not configured", which is the same
24
+ // branch plugins handle via their in-memory fallback. A warn
25
+ // log here would flood production with one line per hit.
26
+ //
23
27
  return null;
24
28
  },
25
29
 
@@ -29,7 +33,11 @@ export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAd
29
33
  return;
30
34
  }
31
35
 
32
- console.warn('Plugin storage not supported by database adapter');
36
+ // Silently no-op: the plugin-storage contract treats missing
37
+ // adapter methods as "not configured", which is the same
38
+ // branch plugins handle via their in-memory fallback. A warn
39
+ // log here would flood production with one line per hit.
40
+ //
33
41
  },
34
42
 
35
43
  async delete(pluginName: string, key: string): Promise<void> {
@@ -38,7 +46,11 @@ export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAd
38
46
  return;
39
47
  }
40
48
 
41
- console.warn('Plugin storage not supported by database adapter');
49
+ // Silently no-op: the plugin-storage contract treats missing
50
+ // adapter methods as "not configured", which is the same
51
+ // branch plugins handle via their in-memory fallback. A warn
52
+ // log here would flood production with one line per hit.
53
+ //
42
54
  },
43
55
 
44
56
  async list(pluginName: string, prefix?: string): Promise<string[]> {
@@ -46,7 +58,11 @@ export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAd
46
58
  return db.listPluginKeys(pluginName, prefix);
47
59
  }
48
60
 
49
- console.warn('Plugin storage not supported by database adapter');
61
+ // Silently no-op: the plugin-storage contract treats missing
62
+ // adapter methods as "not configured", which is the same
63
+ // branch plugins handle via their in-memory fallback. A warn
64
+ // log here would flood production with one line per hit.
65
+ //
50
66
  return [];
51
67
  },
52
68
 
@@ -56,7 +72,11 @@ export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAd
56
72
  return;
57
73
  }
58
74
 
59
- console.warn('Plugin storage not supported by database adapter');
75
+ // Silently no-op: the plugin-storage contract treats missing
76
+ // adapter methods as "not configured", which is the same
77
+ // branch plugins handle via their in-memory fallback. A warn
78
+ // log here would flood production with one line per hit.
79
+ //
60
80
  },
61
81
  };
62
82
  }