@thuzjq/meteorcloud-device-sdk-node 0.5.1

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.
@@ -0,0 +1,408 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ const { fail, isObject } = require('./errors');
6
+ const { normalizeCameraBlock } = require('./camera');
7
+
8
+ /**
9
+ * Account-scoped reads and the optional pre-registration write.
10
+ *
11
+ * Contract: `docs/architecture/account-client-sdk-contract.zh-CN.md` §6.4 (the
12
+ * method surface), §6.5 (camelCase at the boundary), §7.2–§7.4 (the endpoints)
13
+ * and §7.9 (the `camera` block); decisions in `docs/ACCOUNT_CLIENT_SDK_PLAN.md`
14
+ * §0.2 and §0.4.
15
+ *
16
+ * Two properties are load-bearing and both are the reason this module exists as
17
+ * a separate, dependency-injected unit rather than five methods pasted onto the
18
+ * client:
19
+ *
20
+ * 1. **Nothing here is cached.** §1.2 and plan §0.2 make the SDK stateless: it
21
+ * holds an installation identity and nothing else — no camera list, no
22
+ * station list, no key→uid map. `listCameras()` is an ordinary query whose
23
+ * answer is stale the moment it returns, because the account can gain, lose
24
+ * or disable a camera on the web console without rebinding anything. A
25
+ * memoized list would silently become an authorization snapshot, which is
26
+ * exactly the model §1.2 exists to forbid. Every call is a fresh request.
27
+ * 2. **No caller ever names an owner.** §7.3 and §10.4: the server narrows every
28
+ * query from the token subject's `ownerUserId` and `tenantId`. A query key
29
+ * that looks like a principal is refused here, locally, rather than being
30
+ * forwarded for the server to ignore — forwarding teaches integrators a
31
+ * parameter that does nothing, and the day it stops being ignored it is a
32
+ * cross-tenant read.
33
+ *
34
+ * The module takes its request performer by injection (`createResources`)
35
+ * because `index.js` owns the authenticated `_request` — token minting, DPoP,
36
+ * retry and 401 re-assert — and requiring `index.js` from `lib/` would close a
37
+ * cycle.
38
+ */
39
+
40
+ /** Mirrors `DEVICE_ROOT` in index.js; duplicated rather than imported to keep this module acyclic. */
41
+ const DEVICE_ROOT = '/cloud/device-api/mscloud';
42
+
43
+ const CAMERA_UID_RE = /^cam_[0-9a-f]{32}$/;
44
+ const STATION_UID_RE = /^st_[0-9a-f]{32}$/;
45
+ /**
46
+ * §7.9: `camera.key` is 1–64 safe ASCII and carries no PII. "Safe" is read here
47
+ * as the same alphabet `clientRequestKey` already uses in index.js, so the key
48
+ * survives a URL path, a log line and a SQL identifier column untouched. A host
49
+ * app that would rather use its channel name may — as long as it is in this
50
+ * alphabet, and as long as it accepts that renaming the channel creates a new
51
+ * camera (§0.2).
52
+ */
53
+ const CAMERA_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
54
+ /**
55
+ * Filter values are checked for shape, never against an enum. §6.5 wrote
56
+ * `status` as `active|disabled`, and the server has neither: stations are
57
+ * `active|revoked|archived` (`MsCloudStationSaveReqVO`,
58
+ * `MsCloudStationKinds.STATUS_ARCHIVED`) and cameras `active|revoked`
59
+ * (`MsCloudCameraSaveReqVO`). That drift is the argument for shape-only: a
60
+ * client-side allowlist of values would have refused a filter the server
61
+ * accepts, and would turn every future server state into an SDK bug. The server
62
+ * judges meaning.
63
+ */
64
+ const FILTER_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
65
+
66
+ /**
67
+ * `PageParam`'s own ceiling (`@Min(1) @Max(200)` on `pageSize`), mirrored so a
68
+ * caller asking for 1000 rows learns it here rather than from a Bean Validation
69
+ * 400 whose message is a localized "每页条数最大值为 200".
70
+ *
71
+ * Not a paging strategy of ours: the SDK caches nothing and iterates nothing on
72
+ * the caller's behalf, so this only rejects a number the server would.
73
+ */
74
+ const MAX_PAGE_SIZE = 200;
75
+
76
+ /** Query keys each list endpoint accepts, in the order they are emitted. */
77
+ const STATION_QUERY_KEYS = Object.freeze(['pageNo', 'pageSize', 'status']);
78
+ const CAMERA_QUERY_KEYS = Object.freeze(['pageNo', 'pageSize', 'stationUid', 'status']);
79
+
80
+ /** Anything smelling of a principal. Refused with a message that says why. */
81
+ const PRINCIPAL_KEY_RE = /(owner|tenant|user|account|subject|creator|dept|member)/i;
82
+
83
+ const OK = new Set([200]);
84
+ /** `cameras/ensure` upserts: 200 when it found one, 201 when it created one. */
85
+ const OK_OR_CREATED = new Set([200, 201]);
86
+
87
+ /**
88
+ * Deep key rewriting stops here. The summaries in §6.5 are flat records, so
89
+ * nothing legitimate is anywhere near this depth — but `decodeEnvelope` accepts
90
+ * a 2 MiB body from a server that may be having a bad day, and a stack overflow
91
+ * inside normalization would escape as a raw RangeError. That breaks the
92
+ * SDK-wide invariant that MeteorCloudError is the only error thrown, and any
93
+ * retry classifier that keys off `instanceof MeteorCloudError` then guesses.
94
+ * Past the cap the subtree is passed through untouched rather than rejected:
95
+ * the payload is still the server's answer, it is just not renamed.
96
+ */
97
+ const MAX_NORMALIZE_DEPTH = 32;
98
+
99
+ /** Truncated so a hostile or fat-fingered key cannot bloat a logged error. */
100
+ function describeKey(key) {
101
+ const text = String(key);
102
+ return text.length > 64 ? `${text.slice(0, 61)}...` : text;
103
+ }
104
+
105
+ /**
106
+ * `camera_uid` → `cameraUid`, `elevation_m` → `elevationM`.
107
+ *
108
+ * Only keys that actually contain an interior `_` are rewritten, so a key that
109
+ * is already camelCase — which is what §7.4's own example response shows — is
110
+ * returned by identity, and a leading/trailing/doubled underscore (`_meta`) is
111
+ * left alone rather than mangled into `Meta`.
112
+ */
113
+ function camelizeKey(key) {
114
+ if (typeof key !== 'string' || !key.includes('_')) return key;
115
+ if (key.startsWith('_') || key.endsWith('_') || key.includes('__')) return key;
116
+ const [head, ...rest] = key.split('_');
117
+ return head + rest.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('');
118
+ }
119
+
120
+ /**
121
+ * Rewrites keys to camelCase everywhere in a decoded payload.
122
+ *
123
+ * §6.5 is explicit that a developer must never read `camera_uid` and write back
124
+ * `cameraUid`, and the server is free to answer in either dialect. So this
125
+ * renames rather than projects: unknown keys survive (camelCased) instead of
126
+ * being dropped, and absent fields stay absent — `positionSigmaHorizontalM` and
127
+ * the `validFrom|ToUtc` pair are `undefined` until a later track ships them, and
128
+ * a fabricated `null` would be indistinguishable from a server that answered
129
+ * one. `kind` likewise passes through verbatim: §7.9 and plan §0.4 have it
130
+ * carrying the real value from day one.
131
+ *
132
+ * Recursion is safe on these payloads because they are records, not maps keyed
133
+ * by caller-supplied strings; there is nothing here whose *key* is data.
134
+ */
135
+ function camelizeKeys(value, depth = 0) {
136
+ if (Array.isArray(value)) {
137
+ return depth >= MAX_NORMALIZE_DEPTH ? value : value.map((item) => camelizeKeys(item, depth + 1));
138
+ }
139
+ if (!isObject(value)) return value;
140
+ if (depth >= MAX_NORMALIZE_DEPTH) return value;
141
+ const result = {};
142
+ for (const [key, item] of Object.entries(value)) {
143
+ const camel = camelizeKey(key);
144
+ // A server answering both dialects for one field (`cameraUid` *and*
145
+ // `camera_uid`) must not have a populated value clobbered by an empty one.
146
+ if (result[camel] !== undefined && result[camel] !== null) continue;
147
+ result[camel] = camelizeKeys(item, depth + 1);
148
+ }
149
+ return result;
150
+ }
151
+
152
+ function normalizeRecord(data, what) {
153
+ if (!isObject(data)) fail(`${what} response was not an object`, { kind: 'device_api' });
154
+ return camelizeKeys(data);
155
+ }
156
+
157
+ /** §7.2 `AccountSummary`. */
158
+ function normalizeAccount(data) {
159
+ return normalizeRecord(data, 'account');
160
+ }
161
+
162
+ /** §6.5 `StationSummary`. */
163
+ function normalizeStation(data) {
164
+ return normalizeRecord(data, 'station');
165
+ }
166
+
167
+ /** §6.5 `CameraSummary`. */
168
+ function normalizeCamera(data) {
169
+ return normalizeRecord(data, 'camera');
170
+ }
171
+
172
+ /**
173
+ * The `{list, total}` page both list endpoints return (§7.4).
174
+ *
175
+ * `list` defaults to `[]` when the server omits it — the method's whole contract
176
+ * is "a page", and every caller iterates the result — but `total` is passed
177
+ * through exactly as sent, including absent. Any other container key survives,
178
+ * camelCased, so a later `hasMore`/`nextCursor` reaches callers without an SDK
179
+ * release.
180
+ */
181
+ function normalizePage(data, what) {
182
+ const page = normalizeRecord(data, what);
183
+ if (page.list !== undefined && page.list !== null && !Array.isArray(page.list)) {
184
+ fail(`${what} response carried a non-array list`, { kind: 'device_api' });
185
+ }
186
+ return { ...page, list: page.list == null ? [] : page.list };
187
+ }
188
+
189
+ /**
190
+ * Turns a caller's filter object into a query string against a strict allowlist.
191
+ *
192
+ * Strict is the point. §8's parameter table gives the resource queries exactly
193
+ * one job — "query 只放分页和过滤" — and §10.4 forbids a caller-supplied owner.
194
+ * An unknown key is therefore an error rather than a silently dropped field: a
195
+ * developer who passes `ownerUserId` has a wrong mental model of the trust
196
+ * boundary, and dropping it quietly lets them keep it.
197
+ */
198
+ function buildQuery(query, allowed, methodName) {
199
+ if (query === undefined || query === null) return '';
200
+ if (!isObject(query)) fail(`${methodName} query must be an object`);
201
+
202
+ const allowedSet = new Set(allowed);
203
+ for (const key of Object.keys(query)) {
204
+ if (allowedSet.has(key)) continue;
205
+ // Checked before the value: `{ownerUserId: undefined}` is still a caller who
206
+ // believes owner scoping is theirs to set.
207
+ if (PRINCIPAL_KEY_RE.test(key)) {
208
+ fail(
209
+ `${methodName} does not accept ${describeKey(key)}: the server derives owner and tenant `
210
+ + 'from the token, and the query carries pagination and filters only'
211
+ );
212
+ }
213
+ fail(`${methodName} does not accept the query key ${describeKey(key)}; allowed: ${allowed.join(', ')}`);
214
+ }
215
+
216
+ // Emitted in allowlist order, not caller order, so the request line is
217
+ // reproducible across callers and easy to assert on.
218
+ const params = new URLSearchParams();
219
+ for (const key of allowed) {
220
+ const value = query[key];
221
+ if (value === undefined) continue;
222
+ if (key === 'pageNo' || key === 'pageSize') {
223
+ if (!Number.isSafeInteger(value) || value < 1) fail(`${methodName} ${key} must be a positive integer`);
224
+ if (key === 'pageSize' && value > MAX_PAGE_SIZE) {
225
+ fail(`${methodName} pageSize must be at most ${MAX_PAGE_SIZE}`);
226
+ }
227
+ params.set(key, String(value));
228
+ continue;
229
+ }
230
+ if (key === 'stationUid') {
231
+ if (!STATION_UID_RE.test(value || '')) fail(`${methodName} stationUid is not canonical`);
232
+ params.set(key, value);
233
+ continue;
234
+ }
235
+ if (typeof value !== 'string' || !FILTER_VALUE_RE.test(value)) {
236
+ fail(`${methodName} ${key} filter is not a safe token`);
237
+ }
238
+ params.set(key, value);
239
+ }
240
+ return params.toString();
241
+ }
242
+
243
+ function withQuery(path, search) {
244
+ return search ? `${path}?${search}` : path;
245
+ }
246
+
247
+ /**
248
+ * A stable key for one host-app channel. Pure local, no network, no state.
249
+ *
250
+ * §7.9 makes camera identity `(tenant, owner, originClientId, camera.key)`, and
251
+ * plan §0.2 puts the key in the *host application's* own configuration: it is
252
+ * generated once per channel, persisted by the host, and replayed on every
253
+ * upload. Changing it means "a different camera", so this is called once and
254
+ * written down — never on each start, and never derived from a hostname, MAC or
255
+ * anything else identifying (the key must carry no PII).
256
+ *
257
+ * 128 bits of `randomBytes` because two independently installed stations must
258
+ * not collide inside one account.
259
+ *
260
+ * @returns {string} 32 lowercase hex characters
261
+ */
262
+ function newCameraKey() {
263
+ return crypto.randomBytes(16).toString('hex');
264
+ }
265
+
266
+ /**
267
+ * Validates the §7.9 `camera` block and returns the exact body to send.
268
+ *
269
+ * Delegates to `lib/camera.js`, which is the single definition of these rules.
270
+ * This module used to carry its own copy as a fallback, and the two drifted
271
+ * exactly the way duplicated validators do: the copy here accepted `+08:00` as a
272
+ * timezone, a non-integer `elevationM`, and unbounded names. `index.js` injects
273
+ * the strict one, so nothing shipped was affected — but a caller who builds
274
+ * `createResources()` without `validateCamera` would have registered offset
275
+ * "zones" as permanent station identity, which is the precise failure
276
+ * `camera.js`'s timezone rule exists to stop. A delegating default cannot drift.
277
+ */
278
+ function validateCameraBlock(camera, what = 'camera') {
279
+ return normalizeCameraBlock(camera, what);
280
+ }
281
+
282
+ /**
283
+ * Binds the five account-scoped calls to an authenticated request performer.
284
+ *
285
+ * @param {object} options
286
+ * @param {(method: string, path: string, payload: unknown, expected: Set<number>) => Promise<any>}
287
+ * options.request the client's authenticated call, returning the decoded envelope `data`
288
+ * @param {string} [options.root] Device API root, for tests and alternate mounts
289
+ * @param {(camera: object, what?: string) => object} [options.validateCamera] the canonical
290
+ * `camera` block validator. Injected so `ensureCamera()` and `uploadEvent()` can share one
291
+ * implementation: §7.9 requires the pre-registration path to behave *identically* to the
292
+ * upsert that happens during an upload, and two copies of the rules drift.
293
+ */
294
+ function createResources(options = {}) {
295
+ const request = options.request;
296
+ if (typeof request !== 'function') fail('createResources requires a request performer');
297
+ const root = options.root || DEVICE_ROOT;
298
+ const validateCamera = options.validateCamera || validateCameraBlock;
299
+
300
+ /**
301
+ * §7.2. The minimum summary of the authorized account — no phone number, no
302
+ * department, no roles (§10.4).
303
+ *
304
+ * @returns {Promise<{accountId: string, nickname: string, tenantId?: string}>}
305
+ */
306
+ async function getAccount() {
307
+ return normalizeAccount(await request('GET', `${root}/account`, undefined, OK));
308
+ }
309
+
310
+ /**
311
+ * §7.3. The stations already registered under this account.
312
+ *
313
+ * Not a snapshot and not cached: call it when the host application's UI needs
314
+ * the list, and not on connect, renewal or upload.
315
+ *
316
+ * @param {{pageNo?: number, pageSize?: number, status?: string}} [query]
317
+ */
318
+ async function listStations(query) {
319
+ const search = buildQuery(query, STATION_QUERY_KEYS, 'listStations');
320
+ return normalizePage(
321
+ await request('GET', withQuery(`${root}/stations`, search), undefined, OK),
322
+ 'stations'
323
+ );
324
+ }
325
+
326
+ /**
327
+ * §7.4. The cameras already registered under this account.
328
+ *
329
+ * "Query the cameras this account has on MeteorCloud" — never "scan the
330
+ * machine's cameras" (§7.4 is explicit about the wording, because the two got
331
+ * confused once already). A host app that already holds a valid `cameraUid`,
332
+ * or that reports with a `camera` block, never needs this at all.
333
+ *
334
+ * @param {{pageNo?: number, pageSize?: number, stationUid?: string, status?: string}} [query]
335
+ */
336
+ async function listCameras(query) {
337
+ const search = buildQuery(query, CAMERA_QUERY_KEYS, 'listCameras');
338
+ return normalizePage(
339
+ await request('GET', withQuery(`${root}/cameras`, search), undefined, OK),
340
+ 'cameras'
341
+ );
342
+ }
343
+
344
+ /**
345
+ * §7.4. One camera by uid.
346
+ *
347
+ * The shape is checked locally first so a typo costs nothing, and the uid is
348
+ * interpolated unescaped only because that check leaves nothing but `cam_`
349
+ * and hex. A 404 is deliberately allowed to surface: §9.4 has the server
350
+ * answer 404 for both "does not exist" and "is not yours" precisely so the
351
+ * two cannot be told apart, and softening it to `null` would erase the one
352
+ * signal an integrator needs — that this uid is not usable by this account.
353
+ */
354
+ async function getCamera(cameraUid) {
355
+ if (!CAMERA_UID_RE.test(cameraUid || '')) fail('cameraUid is not canonical');
356
+ return normalizeCamera(await request('GET', `${root}/cameras/${cameraUid}`, undefined, OK));
357
+ }
358
+
359
+ /**
360
+ * §7.9's optional pre-registration endpoint. The request body *is* the
361
+ * `camera` block; the upsert is the same one an upload performs.
362
+ *
363
+ * Optional means optional: the supported path is to carry the block on each
364
+ * `uploadEvent()` and let the server register on first sight (plan §0.2). This
365
+ * exists for a host app that wants a uid before it has an event to send — and
366
+ * even then the returned uid does not have to be stored, because the block
367
+ * alone identifies the camera forever after.
368
+ *
369
+ * @param {{key: string, name?: string, station: {latitude: number, longitude: number,
370
+ * elevationM: number, timezone: string, name?: string}}} input
371
+ * @returns {Promise<{cameraUid: string, stationUid: string, createdCamera?: boolean,
372
+ * createdStation?: boolean}>}
373
+ */
374
+ async function ensureCamera(input) {
375
+ // The likeliest mistake, named rather than met with "key must be 1-64…":
376
+ // the block is nested under `camera` in an uploadEvent request, and flat
377
+ // here.
378
+ if (isObject(input) && input.camera !== undefined) {
379
+ fail('ensureCamera takes the camera block itself ({key, name?, station}), not {camera: {...}}');
380
+ }
381
+ const body = validateCamera(input, 'camera');
382
+ return normalizeRecord(
383
+ await request('POST', `${root}/cameras/ensure`, body, OK_OR_CREATED),
384
+ 'ensureCamera'
385
+ );
386
+ }
387
+
388
+ return { getAccount, listStations, listCameras, getCamera, ensureCamera, newCameraKey };
389
+ }
390
+
391
+ module.exports = {
392
+ DEVICE_ROOT,
393
+ CAMERA_UID_RE,
394
+ STATION_UID_RE,
395
+ CAMERA_KEY_RE,
396
+ MAX_PAGE_SIZE,
397
+ STATION_QUERY_KEYS,
398
+ CAMERA_QUERY_KEYS,
399
+ createResources,
400
+ newCameraKey,
401
+ validateCameraBlock,
402
+ camelizeKey,
403
+ camelizeKeys,
404
+ normalizeAccount,
405
+ normalizeStation,
406
+ normalizeCamera,
407
+ normalizePage
408
+ };