@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/dist/index.d.ts +205 -4
- package/dist/index.js +339 -35
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +203 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/dashboard.ts +40 -0
- package/src/device-identity.ts +338 -0
- package/src/index.ts +222 -27
- package/src/logger.ts +98 -0
- package/src/plugin-storage.ts +25 -5
- package/src/types.ts +216 -1
- package/src/webhooks.ts +15 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
911
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
1409
|
+
logger.info({ plugin: plugin.name }, 'Plugin initialized');
|
|
1221
1410
|
} catch (error) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
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
|
+
}
|
package/src/plugin-storage.ts
CHANGED
|
@@ -19,7 +19,11 @@ export function createPluginStorageAdapter(db: DatabaseAdapter): PluginStorageAd
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Fallback: not supported
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|