@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.
- package/README.md +45 -0
- package/docs/NODE_INTEGRATION_GUIDE.zh-CN.md +414 -0
- package/index.d.ts +930 -0
- package/index.js +945 -0
- package/lib/artifacts.js +279 -0
- package/lib/camera.js +316 -0
- package/lib/config.js +257 -0
- package/lib/connect.js +718 -0
- package/lib/durable.js +85 -0
- package/lib/errors.js +70 -0
- package/lib/http.js +188 -0
- package/lib/jobs.js +54 -0
- package/lib/jose.js +146 -0
- package/lib/journal.js +116 -0
- package/lib/keystore.js +585 -0
- package/lib/resources.js +408 -0
- package/lib/tokens.js +311 -0
- package/lib/upload.js +188 -0
- package/package.json +44 -0
- package/tools/migrate-key-to-dpapi.js +386 -0
package/lib/resources.js
ADDED
|
@@ -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
|
+
};
|