@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/index.js ADDED
@@ -0,0 +1,945 @@
1
+ 'use strict';
2
+
3
+ const { jobCapability, parseJob, jobErrorAction } = require('./lib/jobs');
4
+ const fs = require('node:fs');
5
+
6
+ const { MeteorCloudError, fail, isObject } = require('./lib/errors');
7
+ const {
8
+ CONFIG_SCHEMA,
9
+ loadInstallationConfig,
10
+ saveInstallationConfig,
11
+ normalizeApiBase,
12
+ normalizeIssuer,
13
+ validateInstallationConfig
14
+ } = require('./lib/config');
15
+ const {
16
+ KeyStore,
17
+ FileKeyStore,
18
+ DpapiKeyStore,
19
+ CngKeyStore,
20
+ createKeyStore,
21
+ parseKeyReference,
22
+ defaultKeyStoreScheme,
23
+ DPAPI_MODULE,
24
+ CNG_MODULE
25
+ } = require('./lib/keystore');
26
+ const { InstallationTokenSource, CLIENT_ASSERTION_TYPE, DEVICE_SCOPES } = require('./lib/tokens');
27
+ const {
28
+ connect: bindThroughBrowser,
29
+ beginConnect: beginBindThroughBrowser,
30
+ BIND_SCOPE
31
+ } = require('./lib/connect');
32
+ const { createResources, newCameraKey } = require('./lib/resources');
33
+ const { normalizeCameraSelector, normalizeCameraBlock } = require('./lib/camera');
34
+ const { defaultTransport, decodeEnvelope, headerValue, backoffSeconds, RETRYABLE_HTTP } = require('./lib/http');
35
+ const { UploadJournal, JOURNAL_VERSION } = require('./lib/journal');
36
+ const {
37
+ ROLES,
38
+ ROLE_SET,
39
+ describeArtifacts,
40
+ validateManifest,
41
+ validateManifestDigests,
42
+ sha256Path,
43
+ normalizeContentType
44
+ } = require('./lib/artifacts');
45
+ const {
46
+ DEFAULT_CHUNK_SIZE,
47
+ MAX_CHUNK_SIZE,
48
+ API_CODE,
49
+ normalizeChunkSize,
50
+ uploadArtifactChunks
51
+ } = require('./lib/upload');
52
+ const jose = require('./lib/jose');
53
+
54
+ /** Version matches package.json and index.d.ts. */
55
+ const SDK_VERSION = '0.5.1';
56
+ /** Data-plane contract advertised by the server as `capabilities.directPut.contractVersion`. */
57
+ const DIRECT_PUT_CONTRACT = 'mlc.device-direct-put/1';
58
+ const DEVICE_ROOT = '/cloud/device-api/mscloud';
59
+ const CONTROL_ROOT = `${DEVICE_ROOT}/upload-sessions`;
60
+ const SESSION_RE = /^us_[0-9a-f]{32}$/;
61
+ const FILE_ID_RE = /^uf_[0-9a-f]{32}$/;
62
+ const JOB_RE = /^job_[0-9a-f]{32}$/;
63
+ const EVENT_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
64
+
65
+ // The scope group an installation client asks for (contract §7.1 plus plan §0.5)
66
+ // is defined once in `lib/tokens.js` and re-exported here. Keeping a second copy
67
+ // in this file would let the public constant drift away from the list actually
68
+ // put on the wire by the token source — which is the bug this consolidation
69
+ // removes, since `InstallationTokenSource` is itself part of the public surface
70
+ // and a caller may construct it directly.
71
+
72
+ /**
73
+ * What a server must have deployed for the account-level methods to exist.
74
+ *
75
+ * The server half is written and on `main` (commit f079ef19): account, station
76
+ * and camera endpoints, passive machine registration, the owner-chain
77
+ * authorizer, the eight scopes and the browser login handoff. What is missing
78
+ * is a deployment carrying it. Naming the commit rather than a release version
79
+ * is deliberate — there is no released build to name, and the commit is what an
80
+ * operator can actually go and check a deployment against.
81
+ */
82
+ const ACCOUNT_API_REQUIREMENT =
83
+ 'a MeteorCloud deployment carrying the account-level Device API ' +
84
+ '(contract §7.2 GET /account; implemented on main in f079ef19, not yet deployed)';
85
+
86
+ /**
87
+ * MeteorCloud device client, token stack.
88
+ *
89
+ * Everything it can do is gated by a 10 minute opaque token minted on demand
90
+ * from the local P-256 key. There is no long-lived credential on disk, no COS
91
+ * SDK, and no runtime dependency: `require('@thuzjq/meteorcloud-device-sdk-node')` pulls
92
+ * in nothing but `node:` builtins.
93
+ */
94
+ class MeteorCloudClient {
95
+ constructor(input = {}) {
96
+ if (!isObject(input)) fail('client config must be an object');
97
+ const installation = validateInstallationConfig({
98
+ schema: input.schema ?? CONFIG_SCHEMA,
99
+ issuer: input.issuer,
100
+ client_id: input.clientId ?? input.client_id,
101
+ installation_uid: input.installationUid ?? input.installation_uid,
102
+ key_reference: input.keyReference ?? input.key_reference
103
+ });
104
+
105
+ this.issuer = installation.issuer;
106
+ this.clientId = installation.clientId;
107
+ this.installationUid = installation.installationUid;
108
+ this.keyReference = installation.keyReference;
109
+ // The Device API and the authorization server share an origin in every
110
+ // supported topology; `apiBase` exists so a split deployment stays possible
111
+ // without reintroducing a second persisted field.
112
+ this.apiBase = normalizeApiBase(input.apiBase || normalizeIssuer(installation.issuer));
113
+
114
+ this._jobQuery = Object.freeze({ observed: false, supported: false, compatible: false, minimumPollSeconds: 10 });
115
+ this.httpTimeoutMs = input.httpTimeoutMs ?? 30000;
116
+ this.chunkTimeoutMs = input.chunkTimeoutMs ?? 600000;
117
+ this.controlAttempts = input.controlAttempts ?? 3;
118
+ this.chunkAttempts = input.chunkAttempts ?? 5;
119
+ this.chunkSize = normalizeChunkSize(input.chunkSize ?? DEFAULT_CHUNK_SIZE, undefined);
120
+ this.transport = input.transport || defaultTransport;
121
+ this.now = input.now || (() => Math.floor(Date.now() / 1000));
122
+ this.random = input.random || Math.random;
123
+ this.sleep = input.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
124
+
125
+ // Both tier options travel, not just `dpapi`: a `cng://` reference resolves
126
+ // to a CngKeyStore that needs its backend, and `fromConfig()` is the route
127
+ // where nobody else can supply one.
128
+ this.keyStore =
129
+ input.keyStore || createKeyStore(this.keyReference, { dpapi: input.dpapi, cng: input.cng });
130
+ this.tokens =
131
+ input.tokenSource ||
132
+ new InstallationTokenSource({
133
+ issuer: this.issuer,
134
+ installationUid: this.installationUid,
135
+ keyStore: this.keyStore,
136
+ keyReference: this.keyReference,
137
+ scopes: input.scopes || DEVICE_SCOPES,
138
+ dpop: input.dpop !== false,
139
+ authScheme: input.authScheme,
140
+ transport: this.transport,
141
+ timeoutMs: this.httpTimeoutMs,
142
+ attempts: this.controlAttempts,
143
+ marginSeconds: input.tokenMarginSeconds,
144
+ now: this.now,
145
+ random: this.random,
146
+ sleep: this.sleep,
147
+ jti: input.jti
148
+ });
149
+
150
+ /**
151
+ * The account-scoped reads and the optional pre-registration write, bound to
152
+ * this client's authenticated `_request`.
153
+ *
154
+ * `validateCamera` is injected rather than left to `lib/resources.js`'s own
155
+ * copy so that `ensureCamera()` and `uploadEvent()` validate a `camera`
156
+ * block through the *same* function. Contract §7.9 requires the
157
+ * pre-registration path to behave identically to the upsert an upload
158
+ * performs, and two validators drift the moment one of them learns a rule
159
+ * the other does not.
160
+ */
161
+ this._resources = createResources({
162
+ request: (method, requestPath, payload, expected) =>
163
+ this._request(method, requestPath, payload, expected),
164
+ validateCamera: normalizeCameraBlock
165
+ });
166
+ }
167
+
168
+ /** Builds a client from the zero-secret config written by {@link connect}. */
169
+ static async fromConfig(configPath, options = {}) {
170
+ const config = await loadInstallationConfig(configPath);
171
+ return new MeteorCloudClient({ ...options, ...config });
172
+ }
173
+
174
+ // There is deliberately no `static connect()`. Plan §0.9 froze the public
175
+ // entry points at `MeteorCloud.connect()` and `MeteorCloud.beginConnect()`
176
+ // (with the top-level named exports being the same function objects, not a
177
+ // compatibility layer). A second spelling on the class was a 0.4.x carry-over
178
+ // with no users to carry — 0.4.x was never published — and every extra name
179
+ // for one entry point is a name the docs, the types and the C++ SDK have to
180
+ // keep agreeing about.
181
+
182
+ async _emit(callback, progress) {
183
+ if (!callback) return;
184
+ if ((await callback(progress)) === false) {
185
+ throw new MeteorCloudError('upload cancelled', { kind: 'cancelled' });
186
+ }
187
+ }
188
+
189
+ _url(requestPath) {
190
+ return `${this.apiBase}${requestPath}`;
191
+ }
192
+
193
+ /**
194
+ * One authenticated control-plane call, with retries.
195
+ *
196
+ * A 401 is treated as "the token died" exactly **once per request** (contract
197
+ * §12.1): the cached token is dropped and the next attempt re-asserts, and a
198
+ * second 401 is raised rather than re-asserted again. The distinction matters
199
+ * because the two 401s have different causes. The first is ordinary — a token
200
+ * that expired inside its margin, or a revocation. A *second* one, against a
201
+ * token minted seconds earlier, is a configuration disagreement the client
202
+ * cannot fix by trying harder: a DPoP `htu` computed from the wrong base, an
203
+ * `ath` the resource server does not expect, a clock outside the assertion
204
+ * window. Retrying that burns one signed assertion and one `jti` budget entry
205
+ * per attempt, on every request, for as long as the drift lasts.
206
+ */
207
+ async _request(method, requestPath, payload, expectedStatuses, attempts = this.controlAttempts) {
208
+ const url = this._url(requestPath);
209
+ const body = payload === undefined ? undefined : Buffer.from(JSON.stringify(payload), 'utf8');
210
+ let lastError;
211
+ let reauthenticated = false;
212
+
213
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
214
+ const headers = {
215
+ Accept: 'application/json',
216
+ ...(await this.tokens.authorizationHeaders(method, url))
217
+ };
218
+ if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8';
219
+
220
+ let response;
221
+ try {
222
+ response = await this.transport({
223
+ method,
224
+ url,
225
+ headers,
226
+ body,
227
+ timeoutMs: this.httpTimeoutMs
228
+ });
229
+ } catch (error) {
230
+ lastError = error instanceof MeteorCloudError
231
+ ? error
232
+ : new MeteorCloudError('Device API transport failed', {
233
+ kind: 'transport',
234
+ retryable: true,
235
+ cause: error
236
+ });
237
+ if (!lastError.retryable || attempt === attempts) throw lastError;
238
+ await this.sleep(backoffSeconds(attempt, lastError.retryAfterSeconds, this.random) * 1000);
239
+ continue;
240
+ }
241
+
242
+ this.tokens.noteResourceNonce(headerValue(response.headers, 'dpop-nonce'));
243
+
244
+ try {
245
+ const data = decodeEnvelope(response, expectedStatuses);
246
+ if (requestPath.startsWith(CONTROL_ROOT) && isObject(data)) this._jobQuery = jobCapability(data);
247
+ return data;
248
+ } catch (error) {
249
+ if (this._shouldReauthenticate(error) && !reauthenticated && attempt < attempts) {
250
+ reauthenticated = true;
251
+ this.tokens.invalidate();
252
+ lastError = error;
253
+ continue;
254
+ }
255
+ if (!error.retryable || attempt === attempts) throw error;
256
+ lastError = error;
257
+ await this.sleep(backoffSeconds(attempt, error.retryAfterSeconds, this.random) * 1000);
258
+ }
259
+ }
260
+ throw lastError;
261
+ }
262
+
263
+ /**
264
+ * 401 means re-assert — except when it carries the digest-mismatch code, which
265
+ * the server also answers with 401 and which no new token can fix.
266
+ */
267
+ _shouldReauthenticate(error) {
268
+ return error.httpStatus === 401 && error.apiCode !== API_CODE.DIGEST_MISMATCH;
269
+ }
270
+
271
+ _sessionPath(sessionUid, operation) {
272
+ if (!SESSION_RE.test(sessionUid || '')) fail('sessionUid is not canonical');
273
+ return `${CONTROL_ROOT}/${sessionUid}/${operation}`;
274
+ }
275
+
276
+ /**
277
+ * The short-lived operational token, minted on demand and renewed inside its
278
+ * margin (contract §2.2).
279
+ *
280
+ * Returned for interoperability — a host that has to sign its own request
281
+ * against another MeteorCloud endpoint should not have to re-implement the
282
+ * assertion — and not as an invitation to manage renewal by hand: call it
283
+ * again and it hands back a live one.
284
+ *
285
+ * A copy, so a caller cannot reach into the token source's cache and change
286
+ * the expiry the renewal decision is made from. `accessToken` is a secret:
287
+ * §2.2 keeps it in memory only, and `fingerprint` exists for the log line.
288
+ *
289
+ * @returns {Promise<{accessToken: string, tokenType: string, scope: string,
290
+ * expiresAt: number, fingerprint: string}>}
291
+ */
292
+ async getAccessToken() {
293
+ return { ...(await this.tokens.getToken()) };
294
+ }
295
+
296
+ /**
297
+ * The authorized account, contract §7.2.
298
+ *
299
+ * Also the SDK's capability probe (§11.2, plan §0.6): it is the first
300
+ * account-level call `connect()` makes, and on a server that predates the
301
+ * account API it is a 404 for a *route*, not for a resource. §9.4 has the
302
+ * server answer 404 for "no such resource, or not yours", so letting that
303
+ * through unchanged would tell an integrator their account does not exist —
304
+ * the one reading that is certainly wrong, since the token was just minted
305
+ * from a key the same server registered.
306
+ */
307
+ async getAccount() {
308
+ try {
309
+ return await this._resources.getAccount();
310
+ } catch (error) {
311
+ if (error instanceof MeteorCloudError && error.httpStatus === 404) {
312
+ throw new MeteorCloudError(
313
+ 'this MeteorCloud server does not support the account-level SDK: ' +
314
+ `GET ${DEVICE_ROOT}/account is not routed. It requires ${ACCOUNT_API_REQUIREMENT}. ` +
315
+ 'There is no earlier SDK to fall back to: 0.4.x had no users, was never ' +
316
+ 'published, and the account model replaced it outright. Wait for the deployment.',
317
+ { kind: 'device_api', httpStatus: 404, action: 'account_api_unsupported', cause: error }
318
+ );
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ /** Stations already registered under this account (§7.3). A query, never a cache. */
325
+ listStations(query) {
326
+ return this._resources.listStations(query);
327
+ }
328
+
329
+ /** Cameras already registered under this account (§7.4). A query, never a cache. */
330
+ listCameras(query) {
331
+ return this._resources.listCameras(query);
332
+ }
333
+
334
+ /** One registered camera by uid (§7.4). */
335
+ getCamera(cameraUid) {
336
+ return this._resources.getCamera(cameraUid);
337
+ }
338
+
339
+ /**
340
+ * Optional pre-registration (§7.9): the same upsert an upload performs, run
341
+ * early. The argument is the `camera` block itself, flat.
342
+ *
343
+ * Nothing has to be done with the uid it returns. The block identifies the
344
+ * camera on every later upload, which is why the SDK stores none of this.
345
+ */
346
+ ensureCamera(input) {
347
+ return this._resources.ensureCamera(input);
348
+ }
349
+
350
+ // There is deliberately no profileStatus(), and no deviceContext() either.
351
+ //
352
+ // `/profile/status` and `/profile/latest` are defined over a single camera
353
+ // and a credential row; an installation token is scoped to an account and has
354
+ // neither shape, so the server answers 409 to both. `/device-context` was the
355
+ // token-stack answer to that, but it reported the installation's bound camera
356
+ // set — a concept 0.5.0 deleted outright (plan §0.9), and the route is gone
357
+ // from the server with it. What replaces it is not one document but four
358
+ // deliberate questions: getAccount(), listStations(), listCameras() and
359
+ // getInstallationContext()'s eventual successor. Shipping any of the three
360
+ // removed methods would ship a call that cannot succeed.
361
+
362
+ /**
363
+ * Whether this client has a complete identity AND a private key it can
364
+ * actually sign with.
365
+ *
366
+ * `fromConfig()` deliberately does not touch the key — it only parses the
367
+ * reference — so a config that outlived its key (profile wipe, restored
368
+ * backup, a different Windows user) loads perfectly and then fails at the
369
+ * first token mint. Without this, the earliest an integrator learns that is
370
+ * *after* `uploadEvent()` has hashed every artifact: a full pass over the
371
+ * media plus up to 2 GiB of ecsv, thrown away.
372
+ *
373
+ * This signs a fixed probe rather than calling `keyStore.exists()`, because
374
+ * `exists()` only lstats. On Windows the common failure is a DPAPI blob that
375
+ * is present but cannot be unprotected by the current user — `exists()` says
376
+ * true, `sign()` is what tells the truth. Mirrors `is_bound()` in the C++ SDK.
377
+ *
378
+ * @returns {Promise<boolean>} false means "re-run the bind flow"
379
+ */
380
+ async isBound() {
381
+ try {
382
+ await this.keyStore.sign(this.keyReference, Buffer.from('mlc.is-bound-probe', 'utf8'));
383
+ return true;
384
+ } catch (_) {
385
+ return false;
386
+ }
387
+ }
388
+
389
+ jobQueryCapability() {
390
+ return { ...this._jobQuery };
391
+ }
392
+
393
+ async jobStatus(jobUid) {
394
+ if (!JOB_RE.test(jobUid || '')) fail('jobUid is not canonical', { action: 'job_request_invalid' });
395
+ if (this._jobQuery.observed && !this._jobQuery.compatible) {
396
+ fail('job query is not enabled or compatible; retain the jobUid and stop polling', {
397
+ kind: 'device_api', action: 'job_query_not_enabled'
398
+ });
399
+ }
400
+ // One resource request: the host owns the per-job poll budget and schedule.
401
+ try {
402
+ const data = await this._request('GET', `${DEVICE_ROOT}/jobs/${jobUid}`, undefined, new Set([200]), 1);
403
+ return parseJob(data, jobUid, this._jobQuery.minimumPollSeconds);
404
+ } catch (error) {
405
+ if (this._shouldReauthenticate(error)) this.tokens.invalidate();
406
+ error.action = error.action || jobErrorAction(error);
407
+ if (['job_query_not_enabled', 'job_unavailable', 'job_expired', 'job_request_invalid', 'contract_invalid'].includes(error.action)) {
408
+ error.retryable = false;
409
+ }
410
+ if (error.action === 'job_query_not_enabled') {
411
+ this._jobQuery = jobCapability({});
412
+ }
413
+ throw error;
414
+ }
415
+ }
416
+
417
+ uploadSessionStatus(sessionUid) {
418
+ return this._request('GET', this._sessionPath(sessionUid, 'status'), undefined, new Set([200]));
419
+ }
420
+
421
+ refresh(sessionUid) {
422
+ return this._request('POST', this._sessionPath(sessionUid, 'refresh'), {}, new Set([200]));
423
+ }
424
+
425
+ /**
426
+ * `{files: []}` is the local plane's finalize. The server already holds the
427
+ * bytes and re-digests them itself, so there are no receipts to present — but
428
+ * the member must be present and empty: an absent `files` is rejected as a
429
+ * malformed COS-plane finalize.
430
+ */
431
+ finalize(sessionUid) {
432
+ return this._request('POST', this._sessionPath(sessionUid, 'finalize'), { files: [] }, new Set([200, 201]));
433
+ }
434
+
435
+ abort(sessionUid, reason = '') {
436
+ if (typeof reason !== 'string' || reason.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$|^$/.test(reason)) {
437
+ fail('abort reason is invalid');
438
+ }
439
+ return this._request('POST', this._sessionPath(sessionUid, 'abort'), reason ? { reason } : {}, new Set([200]));
440
+ }
441
+
442
+ /**
443
+ * Opens an upload session.
444
+ *
445
+ * Every upload declares where it came from (§7.6, §7.9): either a `camera`
446
+ * block — the host's own stable key plus the station coordinates — or a
447
+ * `cameraUid` it already holds. The declaration is validated *first*, for the
448
+ * same reason it is in `uploadEvent`: a caller that split authorize and upload
449
+ * must not learn about a missing coordinate after the hashing pass either.
450
+ */
451
+ async authorize(request) {
452
+ const selector = normalizeCameraSelector(request);
453
+ return this._authorizePrepared(request, await this._prepareFiles(request), selector);
454
+ }
455
+
456
+ /**
457
+ * Local validation and the one full-file hashing pass, split out so
458
+ * `uploadEvent` can hand the resulting digests to `uploadAuthorized` instead
459
+ * of computing them a second time.
460
+ *
461
+ * The machine declaration is *not* checked here: it is checked by
462
+ * `normalizeCameraSelector` before this method is entered at all, so a typo in
463
+ * a coordinate cannot cost a full pass over a 40 GiB capture (§7.6).
464
+ */
465
+ async _prepareFiles(request) {
466
+ if (!isObject(request) || !EVENT_RE.test(request.clientRequestKey || '')) {
467
+ fail('clientRequestKey is not canonical');
468
+ }
469
+ await this._emit(request.onProgress, { stage: 'hashing' });
470
+ const files = await describeArtifacts(request.artifacts);
471
+ const manifest = await validateManifest(request);
472
+ // Locally, because the server answers a digest mismatch with 401 — which
473
+ // reads as "your credentials are wrong" for a manifest content error.
474
+ validateManifestDigests(manifest, files);
475
+ return files;
476
+ }
477
+
478
+ /**
479
+ * @param {object} request the caller's upload request
480
+ * @param {Array<object>} files the digests from {@link _prepareFiles}
481
+ * @param {{camera?: object, cameraUid?: string}} selector the validated machine
482
+ * declaration. Required, and produced by the caller rather than here: it has
483
+ * to be computed before hashing, and passing it down is what proves it was.
484
+ */
485
+ async _authorizePrepared(request, files, selector) {
486
+ await this._emit(request.onProgress, { stage: 'authorizing' });
487
+ const payload = { clientRequestKey: request.clientRequestKey, manifestJson: request.manifestJson, files };
488
+ // Whichever of `camera` / `cameraUid` the caller declared, already validated
489
+ // and rebuilt field by field. Absent keys stay absent rather than being sent
490
+ // null: the server's @Pattern rejects a present blank.
491
+ Object.assign(payload, selector);
492
+ try {
493
+ return await this._request('POST', `${CONTROL_ROOT}/authorize`, payload, new Set([200, 201]));
494
+ } catch (error) {
495
+ if (error.apiCode === API_CODE.CAMERA_REQUIRED) {
496
+ // The SDK has already refused an upload that declares nothing, so
497
+ // reaching this means the server did not accept what *was* declared —
498
+ // in practice a server that predates the §7.9 `camera` block and knows
499
+ // only `cameraUid`. The server's own text describes a multi-binding
500
+ // installation, which stopped being the cause.
501
+ error.message =
502
+ 'the server rejected this upload for naming no camera it recognises. ' +
503
+ (selector && selector.camera && !selector.cameraUid
504
+ ? `A camera block was sent, which needs ${ACCOUNT_API_REQUIREMENT}; ` +
505
+ 'against an older server, pass a cameraUid you already hold instead.'
506
+ : 'Pass a camera block ({ key, station: { latitude, longitude, elevationM, timezone } }) ' +
507
+ 'or a cameraUid that belongs to this account.');
508
+ }
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Turns an authorize/status response into the per-role upload plan, refusing
515
+ * anything that is not a local-direct session.
516
+ *
517
+ * The PUT URL is derived from `sessionUid` and `role` here and merely
518
+ * *cross-checked* against the server's `path`. A server-supplied path is data,
519
+ * not a routing instruction: accepting it verbatim would let a compromised or
520
+ * confused control plane redirect file bytes anywhere the client can reach.
521
+ */
522
+ _uploadTargets(session) {
523
+ if (!isObject(session) || !SESSION_RE.test(session.sessionUid || '')) fail('sessionUid is invalid');
524
+ this._assertDirectPutContract(session);
525
+ if (session.credentials) {
526
+ fail('server returned COS credentials for a local session', { kind: 'device_api' });
527
+ }
528
+ if (!Array.isArray(session.uploadTargets) || session.uploadTargets.length === 0) {
529
+ fail(
530
+ 'server returned a COS session; this SDK speaks the local direct-PUT plane only ' +
531
+ '(enable mscloud.direct-put.enabled)',
532
+ { kind: 'device_api' }
533
+ );
534
+ }
535
+ const targets = new Map();
536
+ for (const target of session.uploadTargets) {
537
+ if (!isObject(target) || !ROLE_SET.has(target.role)) fail('upload target role is invalid');
538
+ if (targets.has(target.role)) fail(`duplicate upload target role: ${target.role}`);
539
+ const expectedPath = `upload-sessions/${session.sessionUid}/artifacts/${target.role}`;
540
+ if (String(target.path).replace(/^\//, '') !== expectedPath) {
541
+ fail(`upload target path is not canonical: ${target.role}`, { kind: 'device_api' });
542
+ }
543
+ const receivedBytes = Number(target.receivedBytes ?? 0);
544
+ const sizeBytes = Number(target.sizeBytes);
545
+ if (!Number.isSafeInteger(receivedBytes) || receivedBytes < 0) fail('receivedBytes is invalid');
546
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1) fail('upload target sizeBytes is invalid');
547
+ targets.set(target.role, {
548
+ role: target.role,
549
+ targetPath: expectedPath,
550
+ requestPath: `${CONTROL_ROOT}/${session.sessionUid}/artifacts/${target.role}`,
551
+ receivedBytes,
552
+ sizeBytes
553
+ });
554
+ }
555
+ return targets;
556
+ }
557
+
558
+ /**
559
+ * Fails closed when the server advertises a direct-PUT contract whose major
560
+ * version this SDK does not implement.
561
+ *
562
+ * A major bump means the chunk framing itself changed — `Content-Range`
563
+ * semantics, the digest header, or the offset rule — so continuing would put
564
+ * bytes on the wire under a contract neither side agrees on, and the failure
565
+ * would surface much later as a digest mismatch at finalize. An absent
566
+ * `contractVersion` is accepted: the server omits the whole block when the
567
+ * plane is unsupported, and that case is already caught by `uploadTargets`.
568
+ */
569
+ _assertDirectPutContract(session) {
570
+ const advertised = isObject(session.capabilities) && isObject(session.capabilities.directPut)
571
+ ? session.capabilities.directPut.contractVersion
572
+ : undefined;
573
+ if (typeof advertised !== 'string' || advertised === '') return;
574
+ const major = (value) => String(value).split('/')[1] ?? '';
575
+ if (major(advertised) !== major(DIRECT_PUT_CONTRACT)) {
576
+ fail(
577
+ `server speaks direct-PUT contract ${advertised}; this SDK implements ${DIRECT_PUT_CONTRACT}`,
578
+ { kind: 'device_api' }
579
+ );
580
+ }
581
+ }
582
+
583
+ /** Chunk size, clamped to whatever this server says it will accept. */
584
+ _chunkSizeFor(session) {
585
+ const advertised = isObject(session.capabilities) && isObject(session.capabilities.directPut)
586
+ ? Number(session.capabilities.directPut.maxChunkBytes)
587
+ : undefined;
588
+ return normalizeChunkSize(this.chunkSize, advertised);
589
+ }
590
+
591
+ /**
592
+ * One chunk PUT. `Content-Length` is set explicitly and the body is a fresh
593
+ * stream per attempt: the server refuses chunked transfer-encoding outright
594
+ * (it cannot enforce the size ceiling on a length it does not know yet).
595
+ */
596
+ async _putChunk(chunk) {
597
+ const url = this._url(chunk.requestPath);
598
+ const headers = {
599
+ Accept: 'application/json',
600
+ 'Content-Type': 'application/octet-stream',
601
+ 'Content-Length': String(chunk.length),
602
+ 'Content-Range': `bytes ${chunk.start}-${chunk.endInclusive}/${chunk.total}`,
603
+ 'X-Content-SHA256': chunk.sha256,
604
+ ...(await this.tokens.authorizationHeaders('PUT', url))
605
+ };
606
+ const response = await this.transport({
607
+ method: 'PUT',
608
+ url,
609
+ headers,
610
+ bodyStream: () => fs.createReadStream(chunk.filePath, { start: chunk.start, end: chunk.endInclusive }),
611
+ timeoutMs: this.chunkTimeoutMs
612
+ });
613
+ this.tokens.noteResourceNonce(headerValue(response.headers, 'dpop-nonce'));
614
+ try {
615
+ return decodeEnvelope(response, new Set([200]));
616
+ } catch (error) {
617
+ if (this._shouldReauthenticate(error)) this.tokens.invalidate();
618
+ // Unknown/complete offsets are reconciled through status by the upload loop.
619
+ throw error;
620
+ }
621
+ }
622
+
623
+ async _readReceivedBytes(sessionUid, role) {
624
+ const status = await this.uploadSessionStatus(sessionUid);
625
+ const targets = this._uploadTargets(status);
626
+ const target = targets.get(role);
627
+ if (!target) fail(`status response omitted the ${role} upload target`, { kind: 'device_api' });
628
+ return target.receivedBytes;
629
+ }
630
+
631
+ /**
632
+ * Streams every artifact of an authorized session, resuming from the server's
633
+ * own byte counts. Safe to call repeatedly: an artifact the server already
634
+ * holds in full costs one status read and no bytes.
635
+ */
636
+ async uploadAuthorized(session, artifacts, journalPath, onProgress, precomputed) {
637
+ if (typeof journalPath !== 'string' || !journalPath) fail('journalPath is required');
638
+ const targets = this._uploadTargets(session);
639
+ // After `_uploadTargets`, which is where `sessionUid` is actually validated.
640
+ const sessionUid = session.sessionUid;
641
+ const journal = await new UploadJournal(journalPath, sessionUid).load();
642
+ const chunkSize = this._chunkSizeFor(session);
643
+
644
+ const local = new Map(artifacts.map((item) => [item.role, item]));
645
+ const plans = Array.isArray(session.files) ? session.files : [];
646
+ if (plans.length !== targets.size) fail('session file plan and upload targets disagree');
647
+ // Before streaming, not after: called directly with four local artifacts
648
+ // against a three-target session, the old check ran past the upload loop and
649
+ // only complained once every byte was already on the wire. The guard below
650
+ // compares two server-supplied numbers and so can never catch this.
651
+ if (local.size !== targets.size || [...local.keys()].some((role) => !targets.has(role))) {
652
+ fail('local and authorized role sets differ');
653
+ }
654
+ // Digests `uploadEvent` already computed for exactly these files, moments
655
+ // ago. Re-reading a 40 GiB media file to reach the same answer costs ~7
656
+ // minutes of disk on a machine that is simultaneously capturing video, and
657
+ // it is the third full pass in one call. Skipping it does not weaken the
658
+ // contract: every chunk is digested from the live file as it is sent, and
659
+ // finalize re-digests the whole thing server-side against the size and
660
+ // sha256 declared at authorize.
661
+ //
662
+ // Reused only when `precomputed` describes *this* artifacts array by
663
+ // identity. A public caller reaching uploadAuthorized() directly, after a
664
+ // restart, gets the full re-hash — there the local digest is the only
665
+ // check that the file on disk is still the one that was authorized.
666
+ const reusable = isObject(precomputed) && precomputed.artifacts === artifacts;
667
+ const known = new Map(reusable ? precomputed.files.map((file) => [file.role, file]) : []);
668
+
669
+ const emit = (progress) => this._emit(onProgress, progress);
670
+
671
+ for (const plan of plans) {
672
+ if (!isObject(plan) || !FILE_ID_RE.test(plan.clientFileId || '')) fail('authorized file plan is invalid');
673
+ const target = targets.get(plan.role);
674
+ if (!target) fail(`session plan has no upload target: ${plan.role}`);
675
+ const artifact = local.get(plan.role);
676
+ if (!artifact) fail(`local artifact is missing: ${plan.role}`);
677
+
678
+ const cached = known.get(plan.role);
679
+ const digest = cached ? cached.sha256 : await sha256Path(artifact.path);
680
+ const planSize = Number(plan.sizeBytes);
681
+ // The server lowercases and trims contentType before it stores it, and
682
+ // echoes the stored form back. Comparing raw strings made `Video/MP4`
683
+ // authorize successfully and then fail locally with an error blaming the
684
+ // caller's files, leaving an orphan session holding quota until expiry.
685
+ if (
686
+ planSize !== target.sizeBytes ||
687
+ String(plan.sha256 || '').toLowerCase() !== digest ||
688
+ normalizeContentType(plan.contentType) !== normalizeContentType(artifact.contentType)
689
+ ) {
690
+ fail(`local artifact does not match the authorized plan: ${plan.role}`);
691
+ }
692
+
693
+ await uploadArtifactChunks(
694
+ {
695
+ chunkSize,
696
+ attempts: this.chunkAttempts,
697
+ sleep: this.sleep,
698
+ random: this.random,
699
+ putChunk: (chunk) => this._putChunk({ ...chunk, requestPath: target.requestPath }),
700
+ readReceivedBytes: (role) => this._readReceivedBytes(sessionUid, role)
701
+ },
702
+ {
703
+ role: plan.role,
704
+ filePath: artifact.path,
705
+ sizeBytes: planSize,
706
+ targetPath: target.targetPath,
707
+ sha256: digest,
708
+ receivedBytes: target.receivedBytes
709
+ },
710
+ journal,
711
+ emit
712
+ );
713
+ }
714
+
715
+ if (local.size !== targets.size || [...local.keys()].some((role) => !targets.has(role))) {
716
+ fail('local and authorized role sets differ');
717
+ }
718
+ return journal.state;
719
+ }
720
+
721
+ /** authorize → chunked PUT → finalize, resumable at every step. */
722
+ async uploadEvent(request) {
723
+ // The very first statement, ahead of every file read. Contract §7.6: a
724
+ // missing or malformed machine declaration is a caller argument error, and
725
+ // it must be caught before multi-gigabyte artifacts are hashed — otherwise
726
+ // an unattended station spends minutes of disk to earn a 400.
727
+ const selector = normalizeCameraSelector(request);
728
+ const files = await this._prepareFiles(request);
729
+ const session = await this._authorizePrepared(request, files, selector);
730
+ if (session.status === 'completed') {
731
+ await this._emit(request.onProgress, { stage: 'completed' });
732
+ return session;
733
+ }
734
+ await this.uploadAuthorized(session, request.artifacts, request.journalPath, request.onProgress, {
735
+ files,
736
+ artifacts: request.artifacts
737
+ });
738
+ await this._emit(request.onProgress, { stage: 'finalizing' });
739
+ const result = await this.finalize(session.sessionUid);
740
+ if (!isObject(result) || result.status !== 'completed') {
741
+ fail('finalize did not return completed', { kind: 'device_api' });
742
+ }
743
+ await this._emit(request.onProgress, { stage: 'completed' });
744
+ return result;
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Turns the raw bind outcome from `lib/connect.js` into the contract §6.1
750
+ * `ConnectResult`.
751
+ *
752
+ * Two round trips happen here and both are required by §2.1. The token the
753
+ * browser flow produced carries `mscloud.bind` and nothing else — it cannot
754
+ * upload, cannot read the account, and must never be handed to a caller as if
755
+ * it could — so the operational token is minted separately, from the local
756
+ * private key via `private_key_jwt`. Reading the account then proves the whole
757
+ * chain works before `connect()` resolves: key on disk, assertion accepted,
758
+ * scope group granted, server new enough to answer `/account`.
759
+ */
760
+ async function finishConnect(outcome, options) {
761
+ const clientOptions = { ...(options.clientOptions || {}) };
762
+ // A caller who handed connect() a DPAPI/CNG store or a tighter timeout meant
763
+ // it for the installation, not just for the bind: the client that comes back
764
+ // has to be able to sign with the key that was just created.
765
+ if (options.keyStore !== undefined && clientOptions.keyStore === undefined) {
766
+ clientOptions.keyStore = options.keyStore;
767
+ }
768
+ // Same reasoning one level down, and this one is not optional. A caller who
769
+ // asked for `keyStoreScheme: 'dpapi'` never constructs a KeyStore at all —
770
+ // `resolveKeyStore()` builds it from these two blocks — so dropping them here
771
+ // leaves the client to build a store of its own with no backend and no
772
+ // entropy. The key on disk is then unreadable by the very client that just
773
+ // created it, and because the installation is already registered by then the
774
+ // caller sees `bind_registered_load_config` on a bind that succeeded.
775
+ if (options.dpapi !== undefined && clientOptions.dpapi === undefined) {
776
+ clientOptions.dpapi = options.dpapi;
777
+ }
778
+ if (options.cng !== undefined && clientOptions.cng === undefined) {
779
+ clientOptions.cng = options.cng;
780
+ }
781
+ if (options.httpTimeoutMs !== undefined && clientOptions.httpTimeoutMs === undefined) {
782
+ clientOptions.httpTimeoutMs = options.httpTimeoutMs;
783
+ }
784
+ // `transport` travels for the same reason, and it is the one that bites in
785
+ // tests and in hosts that tunnel through their own agent: `lib/connect.js`
786
+ // really does use it for the code exchange (it is a declared ConnectOptions
787
+ // member), so a caller who injected one saw the bind go through their
788
+ // transport and the very first call on the returned client silently go out
789
+ // through the default one.
790
+ if (options.transport !== undefined && clientOptions.transport === undefined) {
791
+ clientOptions.transport = options.transport;
792
+ }
793
+
794
+ // Everything from here on happens AFTER the server registered the
795
+ // installation and after `installation.json` reached the disk. A transient
796
+ // 5xx or a deployment whose grant does not cover `account:read` must not be
797
+ // reported as "the bind failed", because it did not: the caller would retry
798
+ // `connect()`, mint a second key, register a second installation, and strand
799
+ // the first one — which can then only be revoked by hand in the console.
800
+ // Contract §10.3 legislates the same asymmetry for the key itself.
801
+ //
802
+ // So the bind is preserved and the error says so: `action` marks it, and the
803
+ // message names the one correct recovery, which is to load the config that is
804
+ // already on disk rather than to authorize again.
805
+ //
806
+ // The construction itself is inside the try for the same reason. It is not a
807
+ // formality: the constructor validates `apiBase`, resolves a key store from
808
+ // `key_reference` and can reject a keyStoreScheme option — all of it *after*
809
+ // the installation exists on the server. Left outside, any of those threw a
810
+ // bare `kind: 'validation'` / `kind: 'keystore'` error carrying neither
811
+ // `action` nor `configPath`, which reads as "your arguments were wrong" and
812
+ // invites precisely the connect() retry this block exists to forbid.
813
+ let client;
814
+ let token;
815
+ let account;
816
+ try {
817
+ client = new MeteorCloudClient({ ...clientOptions, ...outcome.config });
818
+ token = await client.getAccessToken();
819
+ account = await client.getAccount();
820
+ } catch (cause) {
821
+ // One failure here already explains itself and already tells the caller not
822
+ // to bind again: `account_api_unsupported`, raised by getAccount() when the
823
+ // server routes no account API. Wrapping it would bury the one sentence
824
+ // that names the actual fix (upgrade the server) under a generic one.
825
+ if (cause instanceof MeteorCloudError && cause.action === 'account_api_unsupported') {
826
+ throw cause;
827
+ }
828
+ throw new MeteorCloudError(
829
+ 'the installation was registered and its config was written, but reading '
830
+ + 'the first token or the account failed. Do NOT call connect() again — '
831
+ + 'that would mint a second key, register a second installation and strand '
832
+ + 'this one. Load the existing config with MeteorCloudClient.fromConfig() '
833
+ + '(the path is on this error as `configPath`) and retry the failing call.',
834
+ {
835
+ kind: cause instanceof MeteorCloudError ? cause.kind : 'transport',
836
+ httpStatus: cause instanceof MeteorCloudError ? cause.httpStatus : undefined,
837
+ apiCode: cause instanceof MeteorCloudError ? cause.apiCode : undefined,
838
+ retryable: false,
839
+ // Same identifier as the C++ SDK's ErrorAction. The action string is the
840
+ // cross-language stable discriminator, so the two must not drift.
841
+ action: 'bind_registered_load_config',
842
+ configPath: outcome.configPath,
843
+ cause
844
+ }
845
+ );
846
+ }
847
+
848
+ return {
849
+ client,
850
+ token,
851
+ account,
852
+ installation: {
853
+ installationUid: outcome.installationUid,
854
+ productClientId: outcome.config.clientId,
855
+ // The server registered it moments ago in this very flow. Any other state
856
+ // would have failed the code exchange.
857
+ status: 'active'
858
+ },
859
+ config: outcome.config,
860
+ configPath: outcome.configPath
861
+ };
862
+ }
863
+
864
+ /**
865
+ * First bind, one call (contract §6.1): browser authorization, local key,
866
+ * installation registration, first operational token and the account summary.
867
+ *
868
+ * @param {object} options see contract §6.1 `ConnectOptions`
869
+ * @returns {Promise<{client: MeteorCloudClient, token: object, account: object,
870
+ * installation: {installationUid: string, productClientId: string, status: 'active'},
871
+ * config: object, configPath: string}>}
872
+ */
873
+ async function connect(options = {}) {
874
+ return finishConnect(await bindThroughBrowser(options), options);
875
+ }
876
+
877
+ /**
878
+ * Two-phase bind (contract §6.2), for software that opens the URL itself.
879
+ *
880
+ * The listener is already up when this returns, so the URL is safe to hand to
881
+ * an Electron shell, a controlled browser or another machine.
882
+ *
883
+ * `wait()` is memoized: it resolves the same `ConnectResult` however often it is
884
+ * called, rather than minting a second token and re-reading the account.
885
+ */
886
+ async function beginConnect(options = {}) {
887
+ const pending = await beginBindThroughBrowser(options);
888
+ let result;
889
+ return {
890
+ authorizationUrl: pending.authorizationUrl,
891
+ wait() {
892
+ if (!result) {
893
+ result = pending.wait().then((outcome) => finishConnect(outcome, options));
894
+ // cancel() rejects this without anybody necessarily awaiting it yet.
895
+ result.catch(() => {});
896
+ }
897
+ return result;
898
+ },
899
+ cancel: () => pending.cancel()
900
+ };
901
+ }
902
+
903
+ /**
904
+ * The namespace contract §1.1 opens with: `const { MeteorCloud } = require(...)`.
905
+ *
906
+ * Three entries because those are the three things a host application calls
907
+ * before it holds a client — authorize, authorize in two phases, and mint the
908
+ * stable key it will store in its own configuration. Everything else hangs off
909
+ * the client the first two return.
910
+ */
911
+ const MeteorCloud = Object.freeze({ connect, beginConnect, newCameraKey });
912
+
913
+ module.exports = {
914
+ MeteorCloud,
915
+ MeteorCloudClient,
916
+ MeteorCloudError,
917
+ connect,
918
+ beginConnect,
919
+ newCameraKey,
920
+ loadInstallationConfig,
921
+ saveInstallationConfig,
922
+ KeyStore,
923
+ FileKeyStore,
924
+ DpapiKeyStore,
925
+ CngKeyStore,
926
+ createKeyStore,
927
+ parseKeyReference,
928
+ defaultKeyStoreScheme,
929
+ InstallationTokenSource,
930
+ UploadJournal,
931
+ jose,
932
+ SDK_VERSION,
933
+ CONFIG_SCHEMA,
934
+ DIRECT_PUT_CONTRACT,
935
+ JOURNAL_VERSION,
936
+ DEVICE_SCOPES,
937
+ BIND_SCOPE,
938
+ CLIENT_ASSERTION_TYPE,
939
+ DPAPI_MODULE,
940
+ CNG_MODULE,
941
+ ROLES,
942
+ DEFAULT_CHUNK_SIZE,
943
+ MAX_CHUNK_SIZE,
944
+ API_CODE
945
+ };