@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/lib/tokens.js ADDED
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ const { MeteorCloudError, fail, isObject } = require('./errors');
6
+ const { defaultTransport, headerValue, backoffSeconds, retryAfterSeconds, RETRYABLE_HTTP } = require('./http');
7
+ const {
8
+ ES256,
9
+ randomToken,
10
+ signCompactJws,
11
+ buildAssertionClaims,
12
+ buildDpopClaims,
13
+ dpopHtu
14
+ } = require('./jose');
15
+ const { tokenEndpoint } = require('./config');
16
+
17
+ const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
18
+
19
+ /**
20
+ * Exactly the scopes an installation client is registered for. Asking for
21
+ * anything outside this set is refused by the authorization server, so the list
22
+ * is a constant rather than a parameter.
23
+ *
24
+ * 0.5.0 replaced the 0.4.x group of four with the eight independent capabilities
25
+ * of contract §7.1 plus `camera:write` (plan §0.5). They are still issued as one
26
+ * group, but the server checks them per endpoint, so narrowing a client's grant
27
+ * later costs no second breaking change.
28
+ *
29
+ * `device-context:read` is gone from the group and is not an alias for anything.
30
+ * There are no released clients to carry (plan §0.9), so `/device-context` and
31
+ * the bound-camera set it reported were deleted rather than deprecated; the
32
+ * server does not recognise the old scope name at all.
33
+ *
34
+ * This is the single source of truth: `index.js` re-exports it rather than
35
+ * declaring a second copy, so the wire and the public constant cannot drift.
36
+ */
37
+ const DEVICE_SCOPES = Object.freeze([
38
+ 'account:read',
39
+ 'installation:read',
40
+ 'station:read',
41
+ 'camera:read',
42
+ 'camera:write',
43
+ 'ingest:create',
44
+ 'ingest:finalize',
45
+ 'job:read'
46
+ ]);
47
+
48
+ /** Contract value: the server rejects an assertion whose `exp - iat` exceeds this. */
49
+ const ASSERTION_LIFETIME_SECONDS = 60;
50
+ const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
51
+ const ACCESS_TOKEN_RE = /^[\x21\x23-\x5B\x5D-\x7E]{16,4096}$/;
52
+
53
+ /**
54
+ * Turns the local P-256 key into short-lived opaque device tokens.
55
+ *
56
+ * There is no refresh token anywhere in this design: every renewal is a fresh
57
+ * RFC 7523 assertion signed on the spot. That is why a client that has not run
58
+ * for a year behaves exactly like one that ran a minute ago, and why a token
59
+ * expiring in the middle of a 40 GiB upload costs one extra round trip rather
60
+ * than a restart.
61
+ */
62
+ class InstallationTokenSource {
63
+ constructor(options = {}) {
64
+ if (!options.installationUid) fail('installationUid is required');
65
+ if (!options.keyStore) fail('keyStore is required');
66
+ if (!options.keyReference) fail('keyReference is required');
67
+
68
+ this.issuer = options.issuer;
69
+ this.tokenEndpoint = tokenEndpoint(options.issuer);
70
+ this.installationUid = options.installationUid;
71
+ this.keyStore = options.keyStore;
72
+ this.keyReference = options.keyReference;
73
+ this.scopes = options.scopes ? [...options.scopes] : [...DEVICE_SCOPES];
74
+ this.dpopEnabled = options.dpop !== false;
75
+ this.authScheme = options.authScheme || 'auto';
76
+ if (!['auto', 'bearer', 'dpop'].includes(this.authScheme)) fail('authScheme is invalid');
77
+
78
+ this.transport = options.transport || defaultTransport;
79
+ this.timeoutMs = options.timeoutMs ?? 30000;
80
+ this.attempts = options.attempts ?? 3;
81
+ /**
82
+ * Renew this many seconds before nominal expiry. A 600s token with a 60s
83
+ * margin still leaves 540s of useful life, and no request is ever issued
84
+ * with a token that is about to die in the server's clock frame.
85
+ */
86
+ this.marginSeconds = options.marginSeconds ?? 60;
87
+ this.now = options.now || (() => Math.floor(Date.now() / 1000));
88
+ this.random = options.random || Math.random;
89
+ this.jti = options.jti || (() => randomToken(16));
90
+ this.sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
91
+
92
+ this._token = undefined;
93
+ this._inflight = undefined;
94
+ this._asNonce = undefined;
95
+ this._rsNonce = undefined;
96
+ this._publicJwk = undefined;
97
+ }
98
+
99
+ async publicJwk() {
100
+ if (!this._publicJwk) this._publicJwk = await this.keyStore.publicJwk(this.keyReference);
101
+ return this._publicJwk;
102
+ }
103
+
104
+ /** Drops the cached token. Called after a 401 so the next call re-asserts. */
105
+ invalidate() {
106
+ this._token = undefined;
107
+ }
108
+
109
+ noteResourceNonce(nonce) {
110
+ if (typeof nonce === 'string' && nonce) this._rsNonce = nonce;
111
+ }
112
+
113
+ _isFresh() {
114
+ return Boolean(this._token) && this._token.expiresAt - this.marginSeconds > this.now();
115
+ }
116
+
117
+ /**
118
+ * Returns a live token, renewing if the cached one is inside its margin.
119
+ * Concurrent callers share one in-flight renewal: four artifacts uploading in
120
+ * sequence must not burn four assertions (and four `jti` budget entries) for
121
+ * the same expiry.
122
+ */
123
+ async getToken() {
124
+ if (this._isFresh()) return this._token;
125
+ if (!this._inflight) {
126
+ this._inflight = this._requestToken().finally(() => {
127
+ this._inflight = undefined;
128
+ });
129
+ }
130
+ return this._inflight;
131
+ }
132
+
133
+ /**
134
+ * Builds the headers that authenticate one resource request. When a proof is
135
+ * attached the scheme becomes `DPoP` — RFC 9449 §7.1 forbids presenting a
136
+ * DPoP-bound token under the `Bearer` scheme, and the server accepts both.
137
+ */
138
+ async authorizationHeaders(method, url) {
139
+ const token = await this.getToken();
140
+ const scheme = this._schemeFor(token);
141
+ const headers = { Authorization: `${scheme} ${token.accessToken}` };
142
+ if (scheme === 'DPoP') {
143
+ headers.DPoP = await this.createProof(method, url, {
144
+ accessToken: token.accessToken,
145
+ nonce: this._rsNonce
146
+ });
147
+ }
148
+ return headers;
149
+ }
150
+
151
+ _schemeFor(token) {
152
+ if (this.authScheme === 'bearer') return 'Bearer';
153
+ if (this.authScheme === 'dpop') return 'DPoP';
154
+ if (!this.dpopEnabled) return 'Bearer';
155
+ return String(token.tokenType || '').toLowerCase() === 'dpop' ? 'DPoP' : 'Bearer';
156
+ }
157
+
158
+ async createProof(method, url, options = {}) {
159
+ const header = { alg: ES256, typ: 'dpop+jwt', jwk: await this.publicJwk() };
160
+ const claims = buildDpopClaims({
161
+ method,
162
+ url,
163
+ now: this.now(),
164
+ jti: this.jti(),
165
+ accessToken: options.accessToken
166
+ });
167
+ if (options.nonce) claims.nonce = options.nonce;
168
+ return signCompactJws(header, claims, (data) => this.keyStore.sign(this.keyReference, data));
169
+ }
170
+
171
+ /**
172
+ * RFC 7523 assertion. The header carries no `jwk`: the server resolves the
173
+ * verification key from `mscloud_installation_key` by `client_id` and compares
174
+ * its recomputed RFC 7638 thumbprint, so an embedded key would at best be
175
+ * ignored and at worst invite confusion about which key is authoritative.
176
+ */
177
+ async createAssertion() {
178
+ const header = { alg: ES256, typ: 'JWT' };
179
+ const claims = buildAssertionClaims({
180
+ installationUid: this.installationUid,
181
+ tokenEndpoint: this.tokenEndpoint,
182
+ now: this.now(),
183
+ lifetimeSeconds: ASSERTION_LIFETIME_SECONDS,
184
+ jti: this.jti()
185
+ });
186
+ return signCompactJws(header, claims, (data) => this.keyStore.sign(this.keyReference, data));
187
+ }
188
+
189
+ async _requestToken() {
190
+ let lastError;
191
+ for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
192
+ const body = new URLSearchParams({
193
+ grant_type: 'client_credentials',
194
+ // Mandatory beyond RFC 7523: it is what selects the registered client,
195
+ // and therefore which public key the assertion is verified against.
196
+ client_id: this.installationUid,
197
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
198
+ client_assertion: await this.createAssertion(),
199
+ scope: this.scopes.join(' ')
200
+ }).toString();
201
+
202
+ const headers = {
203
+ Accept: 'application/json',
204
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
205
+ };
206
+ if (this.dpopEnabled) {
207
+ headers.DPoP = await this.createProof('POST', this.tokenEndpoint, { nonce: this._asNonce });
208
+ }
209
+
210
+ let response;
211
+ try {
212
+ response = await this.transport({
213
+ method: 'POST',
214
+ url: this.tokenEndpoint,
215
+ headers,
216
+ body: Buffer.from(body, 'utf8'),
217
+ timeoutMs: this.timeoutMs,
218
+ maxResponseBytes: MAX_TOKEN_RESPONSE_BYTES
219
+ });
220
+ } catch (error) {
221
+ lastError = error instanceof MeteorCloudError
222
+ ? error
223
+ : new MeteorCloudError('token endpoint transport failed', {
224
+ kind: 'transport',
225
+ retryable: true,
226
+ cause: error
227
+ });
228
+ if (!lastError.retryable || attempt === this.attempts) throw lastError;
229
+ await this.sleep(backoffSeconds(attempt, lastError.retryAfterSeconds, this.random) * 1000);
230
+ continue;
231
+ }
232
+
233
+ const nonce = headerValue(response.headers, 'dpop-nonce');
234
+ if (nonce) this._asNonce = nonce;
235
+
236
+ let decoded;
237
+ try {
238
+ decoded = JSON.parse(Buffer.from(response.body || '').toString('utf8'));
239
+ } catch (cause) {
240
+ lastError = new MeteorCloudError('token endpoint returned invalid JSON', {
241
+ kind: 'auth',
242
+ httpStatus: response.status,
243
+ retryable: RETRYABLE_HTTP.has(response.status),
244
+ retryAfterSeconds: retryAfterSeconds(response.headers),
245
+ cause
246
+ });
247
+ if (!lastError.retryable || attempt === this.attempts) throw lastError;
248
+ await this.sleep(backoffSeconds(attempt, lastError.retryAfterSeconds, this.random) * 1000);
249
+ continue;
250
+ }
251
+
252
+ if (response.status !== 200) {
253
+ const oauthError = isObject(decoded) && typeof decoded.error === 'string' ? decoded.error : undefined;
254
+ // RFC 9449 §8: the AS may demand a nonce it has just supplied. Retry once
255
+ // with it rather than surfacing a failure the client can fix itself.
256
+ if (oauthError === 'use_dpop_nonce' && nonce && attempt < this.attempts) {
257
+ continue;
258
+ }
259
+ const retryable = RETRYABLE_HTTP.has(response.status);
260
+ const error = new MeteorCloudError(
261
+ oauthError ? `token endpoint rejected the assertion: ${oauthError}` : 'token endpoint rejected the assertion',
262
+ {
263
+ kind: 'auth',
264
+ httpStatus: response.status,
265
+ oauthError,
266
+ retryable,
267
+ retryAfterSeconds: retryAfterSeconds(response.headers)
268
+ }
269
+ );
270
+ if (!retryable || attempt === this.attempts) throw error;
271
+ lastError = error;
272
+ await this.sleep(backoffSeconds(attempt, error.retryAfterSeconds, this.random) * 1000);
273
+ continue;
274
+ }
275
+
276
+ this._token = parseTokenResponse(decoded, this.now());
277
+ return this._token;
278
+ }
279
+ throw lastError;
280
+ }
281
+ }
282
+
283
+ function parseTokenResponse(decoded, nowSeconds) {
284
+ if (!isObject(decoded)) fail('token response is not an object', { kind: 'auth' });
285
+ const accessToken = decoded.access_token;
286
+ if (typeof accessToken !== 'string' || !ACCESS_TOKEN_RE.test(accessToken)) {
287
+ fail('token response carries an implausible access_token', { kind: 'auth' });
288
+ }
289
+ const expiresIn = Number(decoded.expires_in);
290
+ if (!Number.isInteger(expiresIn) || expiresIn < 30 || expiresIn > 86400) {
291
+ fail('token response carries an implausible expires_in', { kind: 'auth' });
292
+ }
293
+ return {
294
+ accessToken,
295
+ tokenType: typeof decoded.token_type === 'string' ? decoded.token_type : 'Bearer',
296
+ scope: typeof decoded.scope === 'string' ? decoded.scope : '',
297
+ expiresAt: nowSeconds + expiresIn,
298
+ // Never the token itself: this is what progress callbacks and evidence files
299
+ // are allowed to see.
300
+ fingerprint: crypto.createHash('sha256').update(accessToken, 'utf8').digest('hex').slice(0, 16)
301
+ };
302
+ }
303
+
304
+ module.exports = {
305
+ InstallationTokenSource,
306
+ DEVICE_SCOPES,
307
+ CLIENT_ASSERTION_TYPE,
308
+ ASSERTION_LIFETIME_SECONDS,
309
+ parseTokenResponse,
310
+ dpopHtu
311
+ };
package/lib/upload.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const { MeteorCloudError, fail } = require('./errors');
4
+ const { sha256Path } = require('./artifacts');
5
+ const { backoffSeconds } = require('./http');
6
+
7
+ /** Production contract: one direct-PUT request carries at most 32 MiB. */
8
+ const DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024;
9
+ /** Frozen server ceiling (`MsCloudProperties.DirectPut.MAX_CHUNK_BYTES_CEILING`). */
10
+ const MAX_CHUNK_SIZE = 32 * 1024 * 1024;
11
+ const MIN_CHUNK_SIZE = 64 * 1024;
12
+
13
+ const API_CODE = Object.freeze({
14
+ DIGEST_MISMATCH: 1030002005,
15
+ REQUEST_TOO_LARGE: 1030003010,
16
+ CHUNK_OUT_OF_ORDER: 1030003030,
17
+ CHUNK_RANGE_INVALID: 1030003031,
18
+ ARTIFACT_ALREADY_COMPLETE: 1030003032,
19
+ DIRECT_PUT_DISABLED: 1030003034,
20
+ CAMERA_REQUIRED: 1030003035
21
+ });
22
+
23
+ function normalizeChunkSize(requested, advertisedMax) {
24
+ const ceiling = Number.isSafeInteger(advertisedMax) && advertisedMax >= MIN_CHUNK_SIZE
25
+ ? Math.min(advertisedMax, MAX_CHUNK_SIZE)
26
+ : MAX_CHUNK_SIZE;
27
+ const size = Number.isSafeInteger(requested) && requested > 0 ? requested : DEFAULT_CHUNK_SIZE;
28
+ if (size < MIN_CHUNK_SIZE) fail(`chunkSize must be at least ${MIN_CHUNK_SIZE} bytes`);
29
+ return Math.min(size, ceiling);
30
+ }
31
+
32
+ /**
33
+ * Streams one artifact to the local direct plane, resuming from wherever the
34
+ * server says it already is.
35
+ *
36
+ * The governing rule, and the reason this loop is shaped the way it is: the
37
+ * server accepts a chunk **only** when `Content-Range` starts at exactly its own
38
+ * durable `receivedBytes`. It will not fill a hole and it will not overwrite. So
39
+ * the client never guesses an offset — after any failure whatsoever, including a
40
+ * transport error where the response was simply lost, it re-reads the authority
41
+ * before touching the wire again. A chunk that arrived intact but whose 200 went
42
+ * missing is thereby detected as progress rather than retried into a 409.
43
+ *
44
+ * @param deps.putChunk performs one PUT, resolves to {receivedBytes, sizeBytes, complete}
45
+ * @param deps.readReceivedBytes re-reads the server's durable offset for a role
46
+ * @param plan {role, filePath, sizeBytes, targetPath, sha256, receivedBytes}
47
+ */
48
+ async function uploadArtifactChunks(deps, plan, journal, emit) {
49
+ const chunkSize = deps.chunkSize;
50
+ const attempts = deps.attempts ?? 5;
51
+ const sleep = deps.sleep;
52
+ const random = deps.random;
53
+ const total = Number(plan.sizeBytes);
54
+ if (!Number.isSafeInteger(total) || total < 1) fail(`authorized size is invalid: ${plan.role}`);
55
+
56
+ const state = journal.reconcile(plan.role, {
57
+ key: plan.targetPath,
58
+ size: total,
59
+ sha256: plan.sha256
60
+ });
61
+
62
+ let offset = Number(plan.receivedBytes) || 0;
63
+ if (offset > total) {
64
+ // The server holds more bytes than the artifact has. The session was
65
+ // authorized for different content; resuming would corrupt it.
66
+ fail(`server holds more bytes than the authorized size: ${plan.role}`, { kind: 'device_api' });
67
+ }
68
+ await noteProgress(journal, state, offset);
69
+
70
+ await emit({ stage: 'uploading', role: plan.role, transferredBytes: offset, totalBytes: total });
71
+
72
+ let attempt = 0;
73
+ while (offset < total) {
74
+ const length = Math.min(chunkSize, total - offset);
75
+ const start = offset;
76
+ const endInclusive = start + length - 1;
77
+
78
+ // Two passes over the slice: one to digest, one to send. Buffering a 32 MiB
79
+ // chunk to do it in one would trade a bounded amount of disk read for an
80
+ // unbounded amount of RSS on a machine that is also capturing video.
81
+ const digest = await sha256Path(plan.filePath, { start, end: endInclusive });
82
+
83
+ let result;
84
+ try {
85
+ result = await deps.putChunk({
86
+ role: plan.role,
87
+ targetPath: plan.targetPath,
88
+ filePath: plan.filePath,
89
+ start,
90
+ endInclusive,
91
+ total,
92
+ length,
93
+ sha256: digest
94
+ });
95
+ attempt = 0;
96
+ } catch (error) {
97
+ attempt += 1;
98
+ const fatal = isFatalChunkError(error);
99
+ if (fatal || attempt >= attempts) throw error;
100
+ await sleep(backoffSeconds(attempt, error.retryAfterSeconds, random) * 1000);
101
+ // Authority re-read: covers the lost-200 case, a competing uploader, and
102
+ // an out-of-order rejection caused by a stale local offset.
103
+ const authoritative = await deps.readReceivedBytes(plan.role);
104
+ if (!Number.isSafeInteger(authoritative) || authoritative < 0 || authoritative > total) {
105
+ fail(`server reported an implausible receivedBytes for ${plan.role}`, { kind: 'device_api' });
106
+ }
107
+ offset = authoritative;
108
+ await noteProgress(journal, state, offset);
109
+ await emit({ stage: 'uploading', role: plan.role, transferredBytes: offset, totalBytes: total });
110
+ continue;
111
+ }
112
+
113
+ const received = Number(result.receivedBytes);
114
+ // The server neither fills holes nor overwrites, so the only arithmetically
115
+ // possible answer to a chunk that was accepted is `start + length`. Merely
116
+ // requiring it to advance lets a confused server make the client skip bytes
117
+ // it never sent; finalize's full-file digest would still catch that, but
118
+ // only after the whole remaining file had been uploaded for nothing.
119
+ if (!Number.isSafeInteger(received) || received !== start + length) {
120
+ fail(
121
+ `server acknowledged ${received} bytes for ${plan.role}, expected ${start + length}`,
122
+ { kind: 'device_api' }
123
+ );
124
+ }
125
+ offset = received;
126
+ await noteProgress(journal, state, offset);
127
+ await emit({ stage: 'uploading', role: plan.role, transferredBytes: offset, totalBytes: total });
128
+ }
129
+
130
+ return offset;
131
+ }
132
+
133
+ /**
134
+ * Records the offset, best-effort.
135
+ *
136
+ * The journal is an advisory hint, not a participant in the data path: resume
137
+ * is driven entirely by the server's `receivedBytes`, which is re-read on every
138
+ * failure. So a journal that cannot be written must not be able to stop an
139
+ * upload that needs no local disk at all — and a full disk is precisely the
140
+ * situation in which an operator is anxiously watching one. Before this, an
141
+ * `ENOSPC` here aborted the transfer before a single byte moved, with
142
+ * `cannot save upload journal`, on every retry forever.
143
+ *
144
+ * The failure is not swallowed silently: the last one is left on
145
+ * `journal.lastSaveError` for a caller that wants to warn about a station whose
146
+ * disk is failing. It just no longer decides whether the upload proceeds. Only
147
+ * `io` is absorbed — a `validation` failure from the secret scan in `save()`
148
+ * means the journal was about to write something it must never write, and that
149
+ * must still stop everything.
150
+ */
151
+ async function noteProgress(journal, state, offset) {
152
+ state.offset = offset;
153
+ try {
154
+ await journal.save();
155
+ } catch (error) {
156
+ if (!(error instanceof MeteorCloudError) || error.kind !== 'io') throw error;
157
+ journal.lastSaveError = error;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Errors that no amount of retrying will fix, because the request itself is
163
+ * wrong rather than unlucky. Everything else earns a backoff and a re-read.
164
+ *
165
+ * `CHUNK_OUT_OF_ORDER` is deliberately absent: it is the expected answer when
166
+ * the local offset is stale, and the re-read on the retry path is exactly the
167
+ * cure for it.
168
+ */
169
+ function isFatalChunkError(error) {
170
+ if (!(error instanceof MeteorCloudError)) return false;
171
+ if (['RESOURCE_DISABLED', 'RESOURCE_DELETED', 'RESOURCE_RECREATION_REQUIRED', 'UPLOAD_GENERATION_STALE'].includes(error.action)) return true;
172
+ if (error.kind === 'cancelled' || error.kind === 'validation' || error.kind === 'io') return true;
173
+ return [
174
+ API_CODE.CHUNK_RANGE_INVALID,
175
+ API_CODE.REQUEST_TOO_LARGE,
176
+ API_CODE.DIRECT_PUT_DISABLED
177
+ ].includes(error.apiCode);
178
+ }
179
+
180
+ module.exports = {
181
+ DEFAULT_CHUNK_SIZE,
182
+ MAX_CHUNK_SIZE,
183
+ MIN_CHUNK_SIZE,
184
+ API_CODE,
185
+ normalizeChunkSize,
186
+ uploadArtifactChunks,
187
+ isFatalChunkError
188
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@thuzjq/meteorcloud-device-sdk-node",
3
+ "version": "0.5.1",
4
+ "description": "MeteorCloud device SDK for Node.js: account authorization (OAuth 2 + PKCE browser bind, private_key_jwt renewal, DPoP), account-scoped queries, and chunked direct-PUT uploads that declare their own camera",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "require": "./index.js",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts",
17
+ "lib/",
18
+ "README.md",
19
+ "docs/NODE_INTEGRATION_GUIDE.zh-CN.md",
20
+ "tools/migrate-key-to-dpapi.js"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test",
24
+ "pack:check": "npm pack --dry-run",
25
+ "test:browser:windows": "node tools/verify-windows-browser-launch.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.10"
29
+ },
30
+ "dependencies": {},
31
+ "peerDependencies": {
32
+ "@meteorlive/dpapi": "^0.1.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@meteorlive/dpapi": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "license": "UNLICENSED",
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ }
44
+ }