@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,279 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const fsp = require('node:fs/promises');
6
+ const path = require('node:path');
7
+
8
+ const { MeteorCloudError, fail, isObject } = require('./errors');
9
+
10
+ const ROLES = Object.freeze(['manifest', 'ecsv', 'media', 'preview']);
11
+ const REQUIRED_ROLES = Object.freeze(['manifest', 'ecsv', 'media']);
12
+ const ROLE_SET = new Set(ROLES);
13
+ const MAX_ARTIFACT_SIZE = 50 * 1024 * 1024 * 1024;
14
+ const MAX_MEDIA_SIZE = 100 * 1024 * 1024;
15
+ /**
16
+ * The server's per-role ceilings (`MsCloudUploadSessionServiceImpl.validateRoleSize`).
17
+ * Mirrored so an oversize artifact is refused before the SDK spends a
18
+ * full-file SHA-256 on it and gets back an opaque
19
+ * `upload session request is invalid` 400 that names no role and no limit.
20
+ * A full-resolution PNG preview clears 20 MiB easily, so this is not exotic.
21
+ */
22
+ const MAX_ROLE_SIZE = Object.freeze({
23
+ manifest: 1024 * 1024,
24
+ ecsv: 2 * 1024 * 1024 * 1024,
25
+ preview: 20 * 1024 * 1024,
26
+ media: MAX_MEDIA_SIZE
27
+ });
28
+ /**
29
+ * `MsCloudUploadSessionFileReqVO.originalFilename`, mirrored verbatim:
30
+ * `@Size(max = 255)` plus `@Pattern("[^\\x00\\r\\n]+")`.
31
+ *
32
+ * It used to be `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`, a rule neither the server
33
+ * nor the C++ SDK has — `client_impl.hpp.inc` checks non-empty, `size() > 255`
34
+ * and `contains_crlf_or_nul`, and nothing else. The cost was not theoretical: a
35
+ * Chinese filename or one with a space — `流星 2026-01-01.mp4`, which is what a
36
+ * capture app writes by default — was refused locally by the Node SDK while the
37
+ * identical event package uploaded fine from the C++ one. The value is pure
38
+ * metadata: `path.basename()` produces it, it is sent in the authorize body, and
39
+ * nothing local is built from it (the PUT URL is derived from `sessionUid` and
40
+ * `role`, and the journal keys off that same target path). So there is nothing
41
+ * for a stricter local rule to protect, and plenty for it to break.
42
+ *
43
+ * One residual difference, and it lands on the server's side: `@Size` counts
44
+ * Java chars and `String.length` counts UTF-16 units, so these two agree
45
+ * exactly, while the C++ `size()` counts UTF-8 bytes and is therefore stricter
46
+ * for non-ASCII. A name Node accepts and C++ refuses is one the server accepts.
47
+ */
48
+ const FILENAME_MAX = 255;
49
+ const FILENAME_RE = /^[^\0\r\n]+$/;
50
+ /**
51
+ * `MsCloudUploadSessionFileReqVO.contentType`: `@Size(max = 128)` and
52
+ * `@Pattern("[A-Za-z0-9][A-Za-z0-9!#$&^_.+\\-/;= ]*")`, anchored here because
53
+ * Jakarta's `@Pattern` matches the whole value.
54
+ *
55
+ * The local check used to reject only CR, LF and NUL, so `image/*` and
56
+ * `text/plain; charset="utf-8"` sailed past it and came back from the server as
57
+ * a bare 400 `upload session request is invalid` naming neither the field nor
58
+ * the role — after the full-file hashing pass. The C++ SDK has applied this
59
+ * exact pattern and this exact ceiling all along
60
+ * (`client_impl.hpp.inc:1786-1791`); this is the Node half catching up.
61
+ */
62
+ const CONTENT_TYPE_MAX = 128;
63
+ const CONTENT_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+\-/;= ]*$/;
64
+
65
+ /**
66
+ * Renders a limit the way the docs and the server write it.
67
+ *
68
+ * Only exact multiples become units. Every ceiling above is round by
69
+ * construction so it always renders as one, while a real file size almost never
70
+ * is — and a rounded "20.3 MiB" would be a number the caller cannot match
71
+ * against anything they were told.
72
+ */
73
+ function humanBytes(bytes) {
74
+ const units = [
75
+ [1024 * 1024 * 1024, 'GiB'],
76
+ [1024 * 1024, 'MiB'],
77
+ [1024, 'KiB']
78
+ ];
79
+ for (const [unit, suffix] of units) {
80
+ if (bytes >= unit && bytes % unit === 0) return `${bytes / unit} ${suffix}`;
81
+ }
82
+ return `${bytes} bytes`;
83
+ }
84
+
85
+ async function sha256Path(filePath, { start = 0, end } = {}) {
86
+ const digest = crypto.createHash('sha256');
87
+ try {
88
+ const options = end === undefined ? { start } : { start, end };
89
+ const stream = fs.createReadStream(filePath, options);
90
+ for await (const chunk of stream) digest.update(chunk);
91
+ return digest.digest('hex');
92
+ } catch (cause) {
93
+ throw new MeteorCloudError(`cannot hash artifact: ${filePath}`, { kind: 'io', cause });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Hashes and measures the local artifacts that will be authorized.
99
+ *
100
+ * The digests computed here are what the server records as authoritative, and
101
+ * `finalize` on the local plane re-derives them from the bytes it received. A
102
+ * file mutated between this call and the last chunk therefore fails at finalize
103
+ * with an object mismatch rather than being silently ingested.
104
+ */
105
+ async function describeArtifacts(artifacts) {
106
+ if (!Array.isArray(artifacts) || ![3, 4].includes(artifacts.length)) {
107
+ fail('exactly three or four artifacts are required');
108
+ }
109
+ const result = [];
110
+ const seen = new Set();
111
+ for (const artifact of artifacts) {
112
+ if (!isObject(artifact) || !ROLE_SET.has(artifact.role)) fail('unsupported artifact role');
113
+ if (seen.has(artifact.role)) fail(`duplicate artifact role: ${artifact.role}`);
114
+ seen.add(artifact.role);
115
+ if (typeof artifact.path !== 'string' || !artifact.path) fail(`artifact path is invalid: ${artifact.role}`);
116
+ if (
117
+ typeof artifact.contentType !== 'string' ||
118
+ artifact.contentType.length > CONTENT_TYPE_MAX ||
119
+ !CONTENT_TYPE_RE.test(artifact.contentType)
120
+ ) {
121
+ fail(
122
+ `${artifact.role} artifact contentType is invalid. The server accepts at most ` +
123
+ `${CONTENT_TYPE_MAX} characters matching [A-Za-z0-9][A-Za-z0-9!#$&^_.+\\-/;= ]* ` +
124
+ '— so no quotes, commas or asterisks: write text/plain; charset=utf-8, ' +
125
+ 'not text/plain; charset="utf-8", and name a concrete type rather than image/*'
126
+ );
127
+ }
128
+ const originalFilename = path.basename(artifact.path);
129
+ if (originalFilename.length > FILENAME_MAX || !FILENAME_RE.test(originalFilename)) {
130
+ fail(
131
+ `${artifact.role} artifact filename is invalid: the server accepts 1 to ` +
132
+ `${FILENAME_MAX} characters with no NUL, CR or LF`
133
+ );
134
+ }
135
+ let stat;
136
+ try {
137
+ stat = await fsp.lstat(artifact.path);
138
+ } catch (cause) {
139
+ throw new MeteorCloudError(`artifact does not exist: ${artifact.role}`, { kind: 'io', cause });
140
+ }
141
+ if (!stat.isFile() || stat.isSymbolicLink()) fail(`artifact must be a regular file: ${artifact.role}`);
142
+ if (stat.size < 1 || stat.size > MAX_ARTIFACT_SIZE) fail(`artifact size is invalid: ${artifact.role}`);
143
+ const roleCeiling = MAX_ROLE_SIZE[artifact.role] ?? MAX_ARTIFACT_SIZE;
144
+ if (stat.size > roleCeiling) {
145
+ // Every role phrases the ceiling in the units the guide and the server
146
+ // use. A raw byte count makes the reader do arithmetic to find out
147
+ // whether they hit the limit they were told about.
148
+ fail(
149
+ `${artifact.role} artifact is ${humanBytes(stat.size)}; ` +
150
+ `the server accepts at most ${humanBytes(roleCeiling)}`
151
+ );
152
+ }
153
+ result.push({
154
+ role: artifact.role,
155
+ originalFilename,
156
+ sizeBytes: stat.size,
157
+ sha256: await sha256Path(artifact.path),
158
+ contentType: artifact.contentType
159
+ });
160
+ }
161
+ for (const role of REQUIRED_ROLES) {
162
+ if (!seen.has(role)) fail('manifest, ecsv and media roles are required');
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * The manifest is the contract between client and pipeline, so it is validated
169
+ * locally before a session is opened: `local_event_id` must equal the idempotency
170
+ * key, and the bytes on disk must equal the JSON being submitted. Without the
171
+ * second check a client could authorize one manifest and upload another.
172
+ */
173
+ async function validateManifest(request) {
174
+ if (typeof request.manifestJson !== 'string') fail('manifestJson must be a string');
175
+ let manifest;
176
+ try {
177
+ manifest = JSON.parse(request.manifestJson);
178
+ } catch (cause) {
179
+ throw new MeteorCloudError('manifestJson is invalid JSON', { kind: 'validation', cause });
180
+ }
181
+ if (
182
+ !isObject(manifest) ||
183
+ manifest.schema !== 'mlc.manifest/2' ||
184
+ manifest.local_event_id !== request.clientRequestKey
185
+ ) {
186
+ fail('mlc.manifest/2 local_event_id must equal clientRequestKey');
187
+ }
188
+ const manifestArtifact = request.artifacts.find((item) => item.role === 'manifest');
189
+ if (!manifestArtifact) fail('a manifest artifact is required');
190
+ const submitted = Buffer.from(request.manifestJson, 'utf8');
191
+ // Compare sizes before reading. `readFile` on a path the caller mislabelled
192
+ // as the manifest — pointing at the media file, say — used to buffer the
193
+ // whole thing: straight RSS on a machine that is also capturing video, and
194
+ // past ~2 GiB a raw ERR_FS_FILE_TOO_LARGE rather than a MeteorCloudError.
195
+ // Wrapped: these are the only two unguarded fs calls on the public upload
196
+ // path, and they run *after* describeArtifacts has hashed every artifact —
197
+ // minutes, on a large event. Anything that removes, replaces or exclusively
198
+ // locks the manifest in that window (on Windows, the capture app reopening it
199
+ // with FILE_SHARE_NONE gives EBUSY) used to escape as a raw fs Error, so an
200
+ // integrator's `catch (e) { switch (e.kind) }` saw undefined. lib/journal.js
201
+ // had the same hole around mkdir and it was closed for the same reason:
202
+ // MeteorCloudError is promised to be the only error this SDK throws.
203
+ let onDisk;
204
+ try {
205
+ const onDiskSize = (await fsp.stat(manifestArtifact.path)).size;
206
+ if (onDiskSize !== submitted.length) {
207
+ fail('manifest file bytes must exactly equal manifestJson');
208
+ }
209
+ onDisk = await fsp.readFile(manifestArtifact.path);
210
+ } catch (cause) {
211
+ if (cause instanceof MeteorCloudError) throw cause;
212
+ throw new MeteorCloudError('cannot read the manifest artifact', { kind: 'io', cause });
213
+ }
214
+ if (onDisk.length !== submitted.length || !crypto.timingSafeEqual(onDisk, submitted)) {
215
+ fail('manifest file bytes must exactly equal manifestJson');
216
+ }
217
+ return manifest;
218
+ }
219
+
220
+ /**
221
+ * Cross-checks the digests the manifest declares against the artifacts actually
222
+ * being submitted.
223
+ *
224
+ * The server does this too (`validateManifestDigests`) — but it answers a
225
+ * mismatch with **401**, which reads as a credential failure for what is a
226
+ * manifest content error. The digests are already computed by the time we get
227
+ * here, so checking locally costs nothing and turns the most confusing error on
228
+ * the whole plane into a plain sentence. The usual way to hit it: switching on
229
+ * preview generation while the manifest writer still emits `"preview"` as `"-"`.
230
+ */
231
+ function validateManifestDigests(manifest, files) {
232
+ const declared = isObject(manifest.files) ? manifest.files : {};
233
+ for (const role of ['ecsv', 'media', 'preview']) {
234
+ const file = files.find((item) => item.role === role);
235
+ const entry = declared[role];
236
+ const claimed = isObject(entry) ? String(entry.sha256 || '') : entry === undefined ? undefined : String(entry);
237
+ if (!file) {
238
+ // Not supplied: the manifest must say so, by omission or by "-".
239
+ if (claimed !== undefined && claimed !== '-' && claimed !== '') {
240
+ fail(`manifest declares a ${role} digest but no ${role} artifact was supplied`);
241
+ }
242
+ continue;
243
+ }
244
+ if (claimed === undefined || claimed === '-' || claimed === '') {
245
+ fail(`manifest omits the ${role} digest, but a ${role} artifact was supplied`);
246
+ }
247
+ if (claimed.toLowerCase() !== file.sha256) {
248
+ fail(`manifest ${role} sha256 does not match the file being uploaded`);
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The server's own normalisation (`trim().toLowerCase()`), mirrored so a client
255
+ * comparing its own contentType against the echoed plan agrees with the value
256
+ * the server actually stored.
257
+ */
258
+ function normalizeContentType(value) {
259
+ return typeof value === 'string' ? value.trim().toLowerCase() : value;
260
+ }
261
+
262
+ module.exports = {
263
+ normalizeContentType,
264
+ validateManifestDigests,
265
+ FILENAME_MAX,
266
+ FILENAME_RE,
267
+ CONTENT_TYPE_MAX,
268
+ CONTENT_TYPE_RE,
269
+ MAX_ROLE_SIZE,
270
+ ROLES,
271
+ REQUIRED_ROLES,
272
+ ROLE_SET,
273
+ MAX_ARTIFACT_SIZE,
274
+ MAX_MEDIA_SIZE,
275
+ humanBytes,
276
+ sha256Path,
277
+ describeArtifacts,
278
+ validateManifest
279
+ };
package/lib/camera.js ADDED
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ const { fail, isObject } = require('./errors');
4
+
5
+ /**
6
+ * Machine-position declaration: local validation of the `camera` block.
7
+ *
8
+ * From v0.5.0 every upload names where it came from, and the host application
9
+ * no longer pre-registers anything. It passes either a `camera` block (its own
10
+ * stable key plus the station's coordinates) or a `cameraUid` it happens to
11
+ * already know; the server registers on first sight and returns the uids purely
12
+ * as information. The SDK holds no camera list, no station list and no
13
+ * key-to-uid map — see contract §1.2, §7.9 and plan §0.2.
14
+ *
15
+ * This module exists because of one sentence in contract §7.6: a missing or
16
+ * malformed declaration is a *caller argument* error and must be caught
17
+ * "before reading and hashing large files". `uploadEvent` otherwise spends a
18
+ * full SHA-256 pass over a multi-gigabyte capture — minutes of disk on an
19
+ * unattended station — before the server answers 400 CAMERA_REQUIRED. So
20
+ * everything here is synchronous, allocation-only, and touches no file, no
21
+ * socket and no clock: it is safe to call as the very first statement of
22
+ * `uploadEvent`, ahead of `_prepareFiles`.
23
+ */
24
+
25
+ /**
26
+ * Server-minted camera identifier, the same literal `index.js` uses. Duplicated
27
+ * rather than imported because this module must stay loadable without pulling
28
+ * in the client (and its keystore/transport); the integration point owns the
29
+ * dedupe.
30
+ */
31
+ const CAMERA_RE = /^cam_[0-9a-f]{32}$/;
32
+
33
+ /**
34
+ * The host application's own stable key for one channel (contract §7.9: "1-64
35
+ * safe ASCII ... no PII").
36
+ *
37
+ * Deliberately the same character class as `EVENT_RE` in `index.js`, one
38
+ * length-cap shorter, because it lands in the same kind of place: a database
39
+ * unique index, log lines, and eventually a URL path segment. Restricting it
40
+ * this way is what enforces the prose rules for free — no control characters
41
+ * (charset), no leading or trailing whitespace (whitespace is not in the
42
+ * charset at all), and no `@`, so the single most likely PII smuggle, an email
43
+ * address, cannot even be spelled.
44
+ *
45
+ * A channel name works as a key when it is ASCII; a non-ASCII one ("北向广角")
46
+ * does not, and the caller should keep the display string in `camera.name` and
47
+ * generate the key with `newCameraKey()`. Changing the key means a new camera,
48
+ * so it must be something the host persists, never something it re-derives.
49
+ */
50
+ const CAMERA_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
51
+
52
+ /**
53
+ * Shape gate for an IANA zone id, applied *before* the runtime check below.
54
+ *
55
+ * `Intl` also accepts fixed offsets — `+08:00`, `-0500` — and those are exactly
56
+ * what must not reach the server: an offset has no DST rule, so every local
57
+ * timestamp this station reports across a transition is silently an hour wrong,
58
+ * and a meteor solved from multi-station timing has no tolerance for that. Zone
59
+ * ids start with a letter and are spelled from `[A-Za-z0-9_+/-]`, which keeps
60
+ * `Etc/GMT+8`, `W-SU` and `America/Argentina/Buenos_Aires` while rejecting
61
+ * anything beginning with a sign. 64 is the server column width
62
+ * (`MsCloudStationSaveReqVO.timezone`).
63
+ */
64
+ const TIMEZONE_RE = /^[A-Za-z][A-Za-z0-9_+/-]{0,63}$/;
65
+
66
+ /**
67
+ * C0 plus DEL. Names are stored and later rendered in the web console, and a
68
+ * stray CR or NUL in a display name is a log-forging and rendering hazard that
69
+ * costs nothing to refuse here. Written as a scan rather than a regex literal
70
+ * so the control points stay legible as code points instead of escapes.
71
+ */
72
+ function hasControlChar(value) {
73
+ for (let i = 0; i < value.length; i += 1) {
74
+ const code = value.charCodeAt(i);
75
+ if (code < 0x20 || code === 0x7f) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /** `displayName` on both `MsCloudStationSaveReqVO` and `MsCloudCameraSaveReqVO`. */
81
+ const MAX_NAME_LENGTH = 128;
82
+
83
+ /** Contract §7.9: "1-64 safe ASCII". Kept beside `CAMERA_KEY_RE`, which encodes it. */
84
+ const MAX_CAMERA_KEY_LENGTH = 64;
85
+
86
+ /**
87
+ * Mirrors the admin-side bean validation the registry reuses
88
+ * (`MsCloudStationSaveReqVO`): latitude `@DecimalMin/-90 @DecimalMax/90`,
89
+ * longitude `-180/180`, elevation `@Min/-500 @Max/10000` on an `Integer`.
90
+ * Duplicated locally so the caller learns the bound at the call site instead of
91
+ * from a round trip; the server stays the authority.
92
+ */
93
+ const STATION_RANGES = Object.freeze({
94
+ latitude: Object.freeze([-90, 90]),
95
+ longitude: Object.freeze([-180, 180]),
96
+ elevationM: Object.freeze([-500, 10000])
97
+ });
98
+
99
+ const CAMERA_FIELDS = Object.freeze(['key', 'name', 'station']);
100
+ const STATION_FIELDS = Object.freeze(['latitude', 'longitude', 'elevationM', 'timezone', 'name']);
101
+
102
+ /**
103
+ * Every failure in this module is the same class of failure: the caller passed
104
+ * something wrong, retrying it will fail identically, and no amount of waiting
105
+ * helps. `kind` and `retryable` are stated rather than left to `fail`'s
106
+ * defaults so that a later change to those defaults cannot quietly turn a
107
+ * caller bug into something an upload loop retries forever.
108
+ *
109
+ * The message names the field and the rule it broke and **never the value**:
110
+ * contract §10.1 keeps payload detail out of anything a host routinely logs,
111
+ * and coordinates are the location of somebody's home.
112
+ */
113
+ function invalid(message) {
114
+ fail(message, { kind: 'validation', retryable: false });
115
+ }
116
+
117
+ /**
118
+ * Unknown keys are refused so a near miss cannot pass as an omission — the
119
+ * motivating case is `elevation` for `elevationM`, which would otherwise be
120
+ * dropped on the floor and re-read as "no elevation given", failing as a
121
+ * *missing* required field or, worse, registering a station at the wrong
122
+ * altitude if the field were ever optional.
123
+ *
124
+ * The offending key names are echoed only when they look like identifiers.
125
+ * A key is caller-authored text, and while it is structure rather than payload,
126
+ * there is no reason for this module to be the one that copies an arbitrary
127
+ * string into an error a host will log.
128
+ */
129
+ function assertKnownFields(value, allowed, path) {
130
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
131
+ if (unknown.length === 0) return;
132
+ const named = unknown.map((key) => (/^[A-Za-z0-9_]{1,40}$/.test(key) ? key : '<unprintable>'));
133
+ invalid(`${path} has unknown field(s): ${named.join(', ')}; allowed: ${allowed.join(', ')}`);
134
+ }
135
+
136
+ /**
137
+ * `undefined` means absent, `null` does not.
138
+ *
139
+ * `{ name: undefined }` is how JavaScript spells "I have no value for this",
140
+ * and the existing wire code already treats it that way (`_authorizePrepared`
141
+ * omits `cameraUid` on `!== undefined`). `null` is a decision, it survives
142
+ * `JSON.stringify`, and the server's `@NotBlank` would answer it with an opaque
143
+ * 400 — so it is caught here with a message that says which field.
144
+ */
145
+ function isAbsent(value) {
146
+ return value === undefined;
147
+ }
148
+
149
+ function normalizeName(value, path) {
150
+ if (isAbsent(value)) return undefined;
151
+ if (typeof value !== 'string') invalid(`${path} must be a string when present`);
152
+ // Blank is refused rather than treated as absent: silently rewriting a
153
+ // caller's value is the behaviour this module exists to avoid, and the
154
+ // server's @NotBlank would reject it anyway once it reached the wire.
155
+ if (value.trim() === '') invalid(`${path} must not be blank; omit it instead`);
156
+ if (value.length > MAX_NAME_LENGTH) invalid(`${path} must be at most ${MAX_NAME_LENGTH} characters`);
157
+ if (hasControlChar(value)) invalid(`${path} must not contain control characters`);
158
+ return value;
159
+ }
160
+
161
+ /**
162
+ * Coordinates are read as numbers only. A numeric *string* is refused rather
163
+ * than coerced because `'36.07'` and `'36,07'` are both strings and only one of
164
+ * them is a latitude — `Number()` turns the other into `NaN` and, before the
165
+ * finiteness check, into a station somewhere undefined. Making the caller hand
166
+ * over a number puts that parse in their code, where the locale is known.
167
+ */
168
+ function normalizeNumber(value, path, [min, max], { integer = false } = {}) {
169
+ if (isAbsent(value)) invalid(`${path} is required`);
170
+ if (typeof value !== 'number') {
171
+ invalid(`${path} must be a number; a numeric string or null is not accepted`);
172
+ }
173
+ if (!Number.isFinite(value)) invalid(`${path} must be a finite number; NaN and Infinity are not positions`);
174
+ if (integer && !Number.isInteger(value)) invalid(`${path} must be a whole number of metres`);
175
+ if (value < min || value > max) invalid(`${path} must be within [${min}, ${max}]`);
176
+ return value;
177
+ }
178
+
179
+ /**
180
+ * The zone is validated for real, not merely shape-checked: `Intl` carries the
181
+ * ICU zone database this runtime already ships, so a retired or misspelled id
182
+ * ("Asia/Peking", "Mars/Olympus") throws a `RangeError` here instead of being
183
+ * stored and only noticed when a solved trajectory's local time makes no sense.
184
+ * Zero cost, zero dependencies, and it is the same database the pipeline will
185
+ * later use to interpret the station's timestamps.
186
+ *
187
+ * The accepted string is passed through byte-for-byte, not canonicalised. Zone
188
+ * identity belongs to the server (station identity is by coordinates anyway,
189
+ * contract §7.9), and rewriting a caller's value would be the silent
190
+ * normalisation this module refuses everywhere else.
191
+ */
192
+ function normalizeTimezone(value, path) {
193
+ if (isAbsent(value)) invalid(`${path} is required`);
194
+ if (typeof value !== 'string') invalid(`${path} must be a string`);
195
+ if (!TIMEZONE_RE.test(value)) {
196
+ invalid(`${path} must be an IANA zone id such as Asia/Shanghai; a UTC offset is not accepted`);
197
+ }
198
+ try {
199
+ new Intl.DateTimeFormat(undefined, { timeZone: value });
200
+ } catch {
201
+ invalid(`${path} is not a zone this runtime's IANA database knows`);
202
+ }
203
+ return value;
204
+ }
205
+
206
+ function normalizeStation(input, path) {
207
+ if (isAbsent(input)) invalid(`${path} is required: a camera declares the position it observes from`);
208
+ if (!isObject(input)) invalid(`${path} must be an object`);
209
+ assertKnownFields(input, STATION_FIELDS, path);
210
+
211
+ // Built field by field rather than by spreading the caller's object, so the
212
+ // wire body can only ever contain what was validated above.
213
+ const station = {
214
+ latitude: normalizeNumber(input.latitude, `${path}.latitude`, STATION_RANGES.latitude),
215
+ longitude: normalizeNumber(input.longitude, `${path}.longitude`, STATION_RANGES.longitude),
216
+ elevationM: normalizeNumber(input.elevationM, `${path}.elevationM`, STATION_RANGES.elevationM, {
217
+ integer: true
218
+ }),
219
+ ...(input.timezone == null || input.timezone === '' ? {} : { timezone: normalizeTimezone(input.timezone, `${path}.timezone`) })
220
+ };
221
+ // Adopted only when the station is first created (contract §7.9), so it is
222
+ // omitted rather than sent empty: an absent name and a blank one must not
223
+ // look alike to a server that will fall back to a coordinate summary.
224
+ const name = normalizeName(input.name, `${path}.name`);
225
+ if (name !== undefined) station.name = name;
226
+ return station;
227
+ }
228
+
229
+ /**
230
+ * Validates one `camera` block and returns the exact object to put on the wire.
231
+ *
232
+ * Exported on its own because `ensureCamera()` — the optional pre-registration
233
+ * endpoint — takes the identical block and performs the identical upsert
234
+ * (contract §7.9), and the two must not drift into two dialects of the same
235
+ * declaration.
236
+ */
237
+ function normalizeCameraBlock(input, path = 'camera') {
238
+ if (!isObject(input)) invalid(`${path} must be an object`);
239
+ assertKnownFields(input, CAMERA_FIELDS, path);
240
+
241
+ if (isAbsent(input.key)) invalid(`${path}.key is required: the host's own stable key for this channel`);
242
+ if (typeof input.key !== 'string') invalid(`${path}.key must be a string`);
243
+ if (!CAMERA_KEY_RE.test(input.key)) {
244
+ invalid(
245
+ `${path}.key must be 1-${MAX_CAMERA_KEY_LENGTH} characters of A-Z a-z 0-9 . _ : - ` +
246
+ 'starting with a letter or digit; use newCameraKey() for a generated one'
247
+ );
248
+ }
249
+
250
+ const camera = { key: input.key, station: normalizeStation(input.station, `${path}.station`) };
251
+ const name = normalizeName(input.name, `${path}.name`);
252
+ if (name !== undefined) camera.name = name;
253
+ return camera;
254
+ }
255
+
256
+ /**
257
+ * Turns whatever machine position an upload request declared into the fragment
258
+ * to merge into the `upload-sessions/authorize` body.
259
+ *
260
+ * Accepts the whole request object and reads only `camera` and `cameraUid`:
261
+ * every other key (`clientRequestKey`, `manifestJson`, `artifacts`,
262
+ * `journalPath`, `onProgress`, …) belongs to the caller of `uploadEvent` and is
263
+ * none of this function's business. Unknown-field strictness applies *inside*
264
+ * the declaration, where a typo is silent, not at the top level, where it is
265
+ * not.
266
+ *
267
+ * Exactly one of the two is required; giving both is legal and both are passed
268
+ * through. The server resolves the pair — `cameraUid` is authoritative and it
269
+ * checks the registered `client_key` against `camera.key`, answering 400 on a
270
+ * mismatch (contract §7.9). Cross-checking them here is impossible without the
271
+ * camera list the SDK deliberately does not hold, and guessing would be worse
272
+ * than asking.
273
+ *
274
+ * @param {object} request an upload request, or any object carrying the two fields
275
+ * @returns {{cameraUid?: string, camera?: object}} a fresh object, safe to
276
+ * `Object.assign` into the authorize payload
277
+ * @throws {MeteorCloudError} kind `validation`, never retryable
278
+ */
279
+ function normalizeCameraSelector(request = {}) {
280
+ if (!isObject(request)) invalid('upload request must be an object');
281
+
282
+ const hasUid = !isAbsent(request.cameraUid);
283
+ const hasBlock = !isAbsent(request.camera);
284
+ if (!hasUid && !hasBlock) {
285
+ // The local twin of the server's 400 CAMERA_REQUIRED (1030003035). Phrased
286
+ // as the fix rather than the symptom, because "camera is required" tells a
287
+ // host application nothing about where the value is supposed to come from,
288
+ // and there is no console page to copy it from any more.
289
+ invalid(
290
+ 'an upload must declare where it came from: pass camera ' +
291
+ '{ key, station: { latitude, longitude, elevationM, timezone } }, ' +
292
+ 'or a cameraUid you already know'
293
+ );
294
+ }
295
+
296
+ const payload = {};
297
+ if (hasUid) {
298
+ if (typeof request.cameraUid !== 'string' || !CAMERA_RE.test(request.cameraUid)) {
299
+ invalid('cameraUid is not canonical: expected cam_ followed by 32 lowercase hex characters');
300
+ }
301
+ payload.cameraUid = request.cameraUid;
302
+ }
303
+ if (hasBlock) payload.camera = normalizeCameraBlock(request.camera);
304
+ return payload;
305
+ }
306
+
307
+ module.exports = {
308
+ normalizeCameraSelector,
309
+ normalizeCameraBlock,
310
+ CAMERA_RE,
311
+ CAMERA_KEY_RE,
312
+ TIMEZONE_RE,
313
+ STATION_RANGES,
314
+ MAX_NAME_LENGTH,
315
+ MAX_CAMERA_KEY_LENGTH
316
+ };