@openmdm/core 0.4.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmdm/core",
3
- "version": "0.4.1",
3
+ "version": "0.7.0",
4
4
  "description": "Core MDM SDK - device management, policies, and commands",
5
5
  "author": "OpenMDM Contributors",
6
6
  "type": "module",
@@ -33,7 +33,8 @@
33
33
  "tsup": "^8.0.0",
34
34
  "typescript": "^5.5.0",
35
35
  "vitest": "^2.0.0",
36
- "@vitest/coverage-v8": "^2.0.0"
36
+ "@vitest/coverage-v8": "^2.0.0",
37
+ "@openmdm/client": "0.2.1"
37
38
  },
38
39
  "peerDependencies": {},
39
40
  "keywords": [
@@ -0,0 +1,130 @@
1
+ /**
2
+ * OpenMDM Agent Wire Protocol v2.
3
+ *
4
+ * A unified response envelope for every `/agent/*` endpoint, plus the
5
+ * version-selection rules that let the server serve v1 and v2 clients
6
+ * simultaneously during a fleet rollout.
7
+ *
8
+ * ## Background
9
+ *
10
+ * Until now, agent-facing handlers returned either a bare JSON body
11
+ * on success or raised an `HTTPException(401|404|5xx)` on failure.
12
+ * The agent had to interpret five different HTTP status codes and
13
+ * infer what to do about each — which in practice meant "on auth
14
+ * error, wipe local enrollment state and re-enroll". That single
15
+ * ambiguity produced the auto-unenroll behavior we saw in production:
16
+ * a transient 401 or 404 was indistinguishable from "you are really
17
+ * unenrolled", so the agent self-destructed.
18
+ *
19
+ * ## Protocol v2
20
+ *
21
+ * Every agent-facing endpoint replies with HTTP 200 and a body of
22
+ * shape {@link AgentResponse}:
23
+ *
24
+ * ```json
25
+ * { "ok": true, "action": "none", "data": { ... } }
26
+ * { "ok": false, "action": "retry", "message": "..." }
27
+ * { "ok": false, "action": "reauth", "message": "..." }
28
+ * { "ok": false, "action": "unenroll", "message": "..." }
29
+ * ```
30
+ *
31
+ * - `ok` is the boolean the agent checks first.
32
+ * - `action` is the *only* field the agent reads to decide what to do
33
+ * next. There is exactly one handler per action on the client, so
34
+ * adding a new server response path is a matter of picking an
35
+ * existing action.
36
+ * - `data` carries the handler-specific payload (heartbeat response,
37
+ * policy update, etc.) on success.
38
+ * - `message` is a human-readable hint, for logs.
39
+ *
40
+ * HTTP 5xx is still used for real infrastructure failures (the Lambda
41
+ * timed out, the database connection dropped, etc.). v2 envelopes are
42
+ * reserved for *application-level* failures the agent can reason about.
43
+ *
44
+ * ## Versioning and rollout
45
+ *
46
+ * The agent opts into v2 by sending the header
47
+ * `X-Openmdm-Protocol: 2` on every request. When absent, the server
48
+ * falls back to the legacy v1 behavior — bare JSON on success,
49
+ * `HTTPException(401|404|…)` on failure — so a fleet still running
50
+ * older APKs keeps working during rollout.
51
+ *
52
+ * After the fleet has been upgraded, v1 can be dropped in a future
53
+ * major release by ignoring the header and always emitting v2.
54
+ */
55
+
56
+ /**
57
+ * Instruction the server gives the agent on how to react to this
58
+ * response. This is the entire client-side decision space.
59
+ *
60
+ * - `none`: happy path. The agent consumes `data` and continues.
61
+ * - `retry`: transient problem. The agent re-tries later without
62
+ * touching local state.
63
+ * - `reauth`: the agent's access token is no longer valid. It should
64
+ * call the refresh flow. It must NOT wipe enrollment state.
65
+ * - `unenroll`: the server-side record for this device is gone or
66
+ * blocked and the agent's credentials will never work again. The
67
+ * agent should stop making requests and surface this to the user.
68
+ * In Phase 2b this will be further softened: the agent will attempt
69
+ * a hardware-identity-based rebind before treating this as terminal.
70
+ */
71
+ export type AgentAction = 'none' | 'retry' | 'reauth' | 'unenroll';
72
+
73
+ /**
74
+ * Unified response envelope for every `/agent/*` endpoint under
75
+ * protocol v2.
76
+ *
77
+ * Successful responses carry `data`; failure responses carry
78
+ * `message`. The envelope never carries both the happy-path payload
79
+ * and an error hint at the same time.
80
+ */
81
+ export type AgentResponse<T = unknown> =
82
+ | {
83
+ ok: true;
84
+ action: 'none';
85
+ data: T;
86
+ }
87
+ | {
88
+ ok: false;
89
+ action: Exclude<AgentAction, 'none'>;
90
+ message?: string;
91
+ };
92
+
93
+ /**
94
+ * HTTP header an agent sends to opt into protocol v2. Case-insensitive
95
+ * on the wire; use the constant to avoid typos.
96
+ */
97
+ export const AGENT_PROTOCOL_HEADER = 'X-Openmdm-Protocol';
98
+
99
+ /**
100
+ * Current wire-protocol version. Agents that send
101
+ * `X-Openmdm-Protocol: 2` get envelope responses. Absent or older
102
+ * values are served with the legacy flat shape.
103
+ */
104
+ export const AGENT_PROTOCOL_V2 = '2';
105
+
106
+ /**
107
+ * Helper: build a success envelope.
108
+ */
109
+ export function agentOk<T>(data: T): AgentResponse<T> {
110
+ return { ok: true, action: 'none', data };
111
+ }
112
+
113
+ /**
114
+ * Helper: build a failure envelope.
115
+ */
116
+ export function agentFail(
117
+ action: Exclude<AgentAction, 'none'>,
118
+ message?: string,
119
+ ): AgentResponse<never> {
120
+ return { ok: false, action, message };
121
+ }
122
+
123
+ /**
124
+ * Returns `true` iff the caller should be served protocol v2. The
125
+ * input is the value of the {@link AGENT_PROTOCOL_HEADER} header,
126
+ * which may be undefined.
127
+ */
128
+ export function wantsAgentProtocolV2(headerValue: string | undefined | null): boolean {
129
+ return headerValue === AGENT_PROTOCOL_V2;
130
+ }
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ import { createPluginStorageAdapter, createMemoryPluginStorageAdapter } from './
92
92
  // Re-export all types
93
93
  export * from './types';
94
94
  export * from './schema';
95
+ export * from './agent-protocol';
95
96
  export { createWebhookManager, verifyWebhookSignature } from './webhooks';
96
97
  export type { WebhookPayload } from './webhooks';
97
98
 
@@ -1327,7 +1328,7 @@ function createStubPushAdapter(): PushAdapter {
1327
1328
  // Utility Functions
1328
1329
  // ============================================
1329
1330
 
1330
- function verifyEnrollmentSignature(
1331
+ export function verifyEnrollmentSignature(
1331
1332
  request: EnrollmentRequest,
1332
1333
  secret: string
1333
1334
  ): boolean {
@@ -1337,11 +1338,21 @@ function verifyEnrollmentSignature(
1337
1338
  return false;
1338
1339
  }
1339
1340
 
1340
- // Reconstruct the message that was signed
1341
- // Format: identifier:timestamp
1342
- const identifier =
1343
- data.macAddress || data.serialNumber || data.imei || data.androidId || '';
1344
- const message = `${identifier}:${data.timestamp}`;
1341
+ // Reconstruct the message that was signed. This must stay in lockstep with
1342
+ // @openmdm/client's generateEnrollmentSignature — any change here is a wire
1343
+ // break and must land in both places. A contract test in core/tests guards
1344
+ // the format and will fail on divergence.
1345
+ const message = [
1346
+ data.model,
1347
+ data.manufacturer,
1348
+ data.osVersion,
1349
+ data.serialNumber || '',
1350
+ data.imei || '',
1351
+ data.macAddress || '',
1352
+ data.androidId || '',
1353
+ data.method,
1354
+ data.timestamp,
1355
+ ].join('|');
1345
1356
 
1346
1357
  const expectedSignature = createHmac('sha256', secret)
1347
1358
  .update(message)