@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/durable.js ADDED
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const fsp = require('node:fs/promises');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Writes that survive a power cut.
8
+ *
9
+ * `fs.writeFile` returns once the bytes are in the page cache. On a capture
10
+ * station — a machine that loses mains power as a matter of routine — that is
11
+ * not the same as "the file exists". Both facts a bind depends on, the private
12
+ * key and the config that points at it, must be on stable storage before
13
+ * anything is allowed to destroy their predecessor.
14
+ *
15
+ * The failure this exists to prevent is specific and unrecoverable. The
16
+ * migration tool used to be the wrong way round: the only `fsync` in the whole
17
+ * SDK forced the *zero-fill of the plaintext PEM*, while the DPAPI blob that
18
+ * replaced it and the config that pointed at it were both left to the lazy
19
+ * writer. A power cut seconds after a successful migration could therefore make
20
+ * the destruction durable and the creation not — losing the one secret in the
21
+ * system that cannot be regenerated, and leaving the user no repair but a
22
+ * manual re-bind in the web console.
23
+ *
24
+ * Directory entries need their own flush: on most filesystems the metadata that
25
+ * makes a name point at an inode is journalled on a different schedule than the
26
+ * data, so a file can be durable while the directory still does not list it.
27
+ * That flush is best-effort — on Windows a directory cannot be opened for
28
+ * fsync at all, and there the rename is already ordered by the NTFS log.
29
+ */
30
+
31
+ /** Creates a file, refusing to overwrite, and returns only once it is durable. */
32
+ async function writeFileDurable(filePath, contents, { mode = 0o600, flag = 'wx' } = {}) {
33
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
34
+ let handle;
35
+ try {
36
+ handle = await fsp.open(filePath, flag, mode);
37
+ await handle.write(contents, 0, contents.length, 0);
38
+ await handle.sync();
39
+ } finally {
40
+ if (handle) await handle.close();
41
+ }
42
+ await syncDirectory(path.dirname(filePath));
43
+ }
44
+
45
+ /**
46
+ * Atomically replaces `targetPath`, durably.
47
+ *
48
+ * The temp file is flushed *before* the rename, not after: a rename that lands
49
+ * before its own payload does is exactly how an atomic replace turns into a
50
+ * zero-length file.
51
+ */
52
+ async function replaceFileDurable(targetPath, contents, temporaryPath, { mode = 0o600 } = {}) {
53
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
54
+ let handle;
55
+ try {
56
+ handle = await fsp.open(temporaryPath, 'wx', mode);
57
+ await handle.write(contents, 0, contents.length, 0);
58
+ await handle.sync();
59
+ } finally {
60
+ if (handle) await handle.close();
61
+ }
62
+ await fsp.rename(temporaryPath, targetPath);
63
+ await syncDirectory(path.dirname(targetPath));
64
+ }
65
+
66
+ /** Best-effort; see the module comment for why it cannot be required. */
67
+ async function syncDirectory(directoryPath) {
68
+ let handle;
69
+ try {
70
+ handle = await fsp.open(directoryPath, 'r');
71
+ await handle.sync();
72
+ } catch (_) {
73
+ /* not supported on this platform or filesystem */
74
+ } finally {
75
+ if (handle) {
76
+ try {
77
+ await handle.close();
78
+ } catch (_) {
79
+ /* best effort */
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ module.exports = { writeFileDurable, replaceFileDurable, syncDirectory };
package/lib/errors.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The only error type this SDK throws.
5
+ *
6
+ * `kind` is the stable programmatic discriminator; `message` is for humans and
7
+ * is never assembled from credential material. Anything that could carry a
8
+ * secret (assertion JWT, access token, private key bytes, DPoP proof) is
9
+ * deliberately absent from every field here — callers routinely log this object
10
+ * wholesale, and a single interpolated token would turn a log file into a
11
+ * credential store.
12
+ */
13
+ class MeteorCloudError extends Error {
14
+ constructor(message, options = {}) {
15
+ super(message);
16
+ this.name = 'MeteorCloudError';
17
+ /**
18
+ * validation | io | transport | device_api | auth | keystore | cancelled
19
+ */
20
+ this.kind = options.kind || 'validation';
21
+ this.httpStatus = options.httpStatus;
22
+ /** Device API stable business code (`code` in the CommonResult envelope). */
23
+ this.apiCode = options.apiCode;
24
+ /** RFC 6749 `error` value when the failure came from the token endpoint. */
25
+ this.oauthError = options.oauthError;
26
+ this.retryable = Boolean(options.retryable);
27
+ this.retryAfterSeconds = options.retryAfterSeconds;
28
+ this.action = options.action;
29
+ /**
30
+ * Where a completed-but-unenriched bind left its config, so a caller can
31
+ * recover programmatically.
32
+ *
33
+ * Deliberately absent from both `message` and `toSafeObject()`: an
34
+ * absolute path carries the local account name, `message` is the string
35
+ * callers log most reliably, and `toSafeObject()` is documented as safe
36
+ * to write into evidence files. Read it off the error instead.
37
+ */
38
+ this.configPath = options.configPath;
39
+ if (options.cause !== undefined) this.cause = options.cause;
40
+ }
41
+
42
+ /**
43
+ * A plain object safe to log or serialise into evidence files. `cause` is
44
+ * intentionally dropped: it is frequently a transport error whose `message`
45
+ * embeds the full request URL, and query strings are outside our control.
46
+ */
47
+ toSafeObject() {
48
+ return {
49
+ name: this.name,
50
+ kind: this.kind,
51
+ httpStatus: this.httpStatus,
52
+ apiCode: this.apiCode,
53
+ oauthError: this.oauthError,
54
+ retryable: this.retryable,
55
+ retryAfterSeconds: this.retryAfterSeconds,
56
+ action: this.action,
57
+ message: this.message
58
+ };
59
+ }
60
+ }
61
+
62
+ function fail(message, options) {
63
+ throw new MeteorCloudError(message, options);
64
+ }
65
+
66
+ function isObject(value) {
67
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
68
+ }
69
+
70
+ module.exports = { MeteorCloudError, fail, isObject };
package/lib/http.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const { Readable } = require('node:stream');
4
+
5
+ const { MeteorCloudError, fail, isObject } = require('./errors');
6
+
7
+ const MAX_CONTROL_BODY = 2 * 1024 * 1024;
8
+ const RETRYABLE_HTTP = new Set([408, 429, 500, 502, 503, 504]);
9
+
10
+ function headerValue(headers, name) {
11
+ if (!headers) return undefined;
12
+ if (typeof headers.get === 'function') return headers.get(name) || undefined;
13
+ const wanted = name.toLowerCase();
14
+ for (const [key, value] of Object.entries(headers)) {
15
+ if (key.toLowerCase() === wanted && value !== undefined && value !== null) return String(value);
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ function retryAfterSeconds(headers) {
21
+ const raw = headerValue(headers, 'retry-after');
22
+ if (!raw) return undefined;
23
+ if (/^[0-9]+$/.test(raw.trim())) return Math.min(Number(raw.trim()), 3600);
24
+ const date = Date.parse(raw);
25
+ if (!Number.isFinite(date)) return undefined;
26
+ return Math.max(0, Math.min(Math.ceil((date - Date.now()) / 1000), 3600));
27
+ }
28
+
29
+ async function readLimitedResponse(response, limit) {
30
+ if (!response.body || typeof response.body.getReader !== 'function') {
31
+ const body = Buffer.from(await response.arrayBuffer());
32
+ if (body.length > limit) fail('response body exceeds the accepted limit', { kind: 'transport' });
33
+ return body;
34
+ }
35
+ const reader = response.body.getReader();
36
+ const chunks = [];
37
+ let total = 0;
38
+ for (;;) {
39
+ const item = await reader.read();
40
+ if (item.done) break;
41
+ total += item.value.byteLength;
42
+ if (total > limit) {
43
+ await reader.cancel();
44
+ fail('response body exceeds the accepted limit', { kind: 'transport' });
45
+ }
46
+ chunks.push(Buffer.from(item.value));
47
+ }
48
+ return Buffer.concat(chunks, total);
49
+ }
50
+
51
+ /**
52
+ * The one place this SDK touches the network.
53
+ *
54
+ * `request.bodyStream` is a *factory*, not a stream: a chunk PUT that has to be
55
+ * retried needs fresh bytes from the same file offset, and a consumed stream
56
+ * cannot supply them. `Content-Length` is always set by the caller because the
57
+ * chunked data plane rejects a body whose length it cannot pin down in advance.
58
+ */
59
+ async function defaultTransport(request) {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), request.timeoutMs);
62
+ // Held so the file handle can be closed when `fetch` never consumes it.
63
+ // `Readable.toWeb` does not adopt the fd's lifetime: if the request fails
64
+ // before the body is read — connection refused, TLS failure, or the abort
65
+ // above firing, which a 32 MiB chunk on a slow link can hit — the
66
+ // ReadStream is simply dropped, and an fs.ReadStream has no finalizer. Five
67
+ // chunk attempts per artifact per event turns that into EMFILE on an
68
+ // unattended station within a day, and every leaked handle pins the media
69
+ // file's inode, so rotating the recording never reclaims the disk either.
70
+ let bodySource = null;
71
+ try {
72
+ const init = {
73
+ method: request.method,
74
+ headers: request.headers,
75
+ redirect: 'manual',
76
+ signal: controller.signal
77
+ };
78
+ if (typeof request.bodyStream === 'function') {
79
+ bodySource = request.bodyStream();
80
+ init.body = Readable.toWeb(bodySource);
81
+ init.duplex = 'half';
82
+ } else if (request.method !== 'GET' && request.body !== undefined) {
83
+ init.body = request.body;
84
+ }
85
+ const response = await fetch(request.url, init);
86
+ const body = await readLimitedResponse(response, request.maxResponseBytes || MAX_CONTROL_BODY);
87
+ bodySource = null;
88
+ return { status: response.status, headers: response.headers, body };
89
+ } catch (cause) {
90
+ if (cause instanceof MeteorCloudError) throw cause;
91
+ throw new MeteorCloudError('HTTP transport failed', {
92
+ kind: 'transport',
93
+ retryable: true,
94
+ cause
95
+ });
96
+ } finally {
97
+ clearTimeout(timer);
98
+ // Idempotent and safe on an already-consumed stream; only the failure paths
99
+ // above leave `bodySource` set.
100
+ if (bodySource) bodySource.destroy();
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Full jitter exponential backoff, capped at 8s, with `Retry-After` taking
106
+ * precedence when the server bothered to state a figure.
107
+ */
108
+ function backoffSeconds(attempt, retryAfter, random) {
109
+ if (Number.isFinite(retryAfter)) return Math.min(Math.max(retryAfter, 0), 3600);
110
+ return Math.min(0.5 * 2 ** (attempt - 1) + random() * 0.25, 8);
111
+ }
112
+
113
+ /**
114
+ * Decodes the yudao `CommonResult` envelope shared by every Device API endpoint.
115
+ * A 2xx with a non-zero `code` is still a failure — the envelope, not the status
116
+ * line, is the authority.
117
+ */
118
+ function decodeEnvelope(response, expectedStatuses) {
119
+ const responseBody = Buffer.isBuffer(response.body) ? response.body : Buffer.from(response.body || '');
120
+ if (responseBody.length > MAX_CONTROL_BODY) {
121
+ fail('Device API response exceeds 2 MiB', { kind: 'transport' });
122
+ }
123
+ let decoded;
124
+ try {
125
+ decoded = JSON.parse(responseBody.toString('utf8'));
126
+ } catch (cause) {
127
+ throw new MeteorCloudError('Device API returned invalid JSON', {
128
+ kind: 'device_api',
129
+ httpStatus: response.status,
130
+ retryable: RETRYABLE_HTTP.has(response.status),
131
+ retryAfterSeconds: retryAfterSeconds(response.headers),
132
+ cause
133
+ });
134
+ }
135
+ // Valid JSON is not necessarily an envelope. A literal `null`, a bare string
136
+ // or a number all parse, and reaching into them for `.msg` throws a raw
137
+ // TypeError — which escapes the contract that MeteorCloudError is the only
138
+ // error this SDK throws, and which `isFatalChunkError` then treats as
139
+ // retryable because it is not a MeteorCloudError.
140
+ if (!isObject(decoded)) {
141
+ throw new MeteorCloudError('Device API returned a non-envelope body', {
142
+ kind: 'device_api',
143
+ httpStatus: response.status,
144
+ retryable: RETRYABLE_HTTP.has(response.status)
145
+ });
146
+ }
147
+ const apiCode = Number.isInteger(decoded.code) ? decoded.code : undefined;
148
+ if (!expectedStatuses.has(response.status) || apiCode !== 0) {
149
+ const deletionAction = decoded.action || (isObject(decoded.data) && decoded.data.action);
150
+ const deletionStop = new Set(['capability_unavailable', 'RESOURCE_DISABLED', 'RESOURCE_DELETED', 'RESOURCE_RECREATION_REQUIRED',
151
+ 'UPLOAD_GENERATION_STALE', 'DELETION_SCOPE_REQUIRED', 'DELETION_PREVIEW_STALE', 'IDEMPOTENCY_CONFLICT']);
152
+ const retryable = RETRYABLE_HTTP.has(response.status) && !deletionStop.has(deletionAction);
153
+ throw new MeteorCloudError(
154
+ typeof decoded.msg === 'string' && decoded.msg ? decoded.msg : 'Device API rejected the request',
155
+ {
156
+ kind: 'device_api',
157
+ httpStatus: response.status,
158
+ apiCode,
159
+ retryable,
160
+ retryAfterSeconds: retryAfterSeconds(response.headers),
161
+ action:
162
+ typeof decoded.action === 'string'
163
+ ? decoded.action
164
+ : isObject(decoded.data) && typeof decoded.data.action === 'string'
165
+ ? decoded.data.action
166
+ : undefined
167
+ }
168
+ );
169
+ }
170
+ if (!isObject(decoded.data) && typeof decoded.data !== 'boolean') {
171
+ fail('Device API success envelope omitted data', {
172
+ kind: 'device_api',
173
+ httpStatus: response.status
174
+ });
175
+ }
176
+ return decoded.data;
177
+ }
178
+
179
+ module.exports = {
180
+ MAX_CONTROL_BODY,
181
+ RETRYABLE_HTTP,
182
+ headerValue,
183
+ retryAfterSeconds,
184
+ readLimitedResponse,
185
+ defaultTransport,
186
+ backoffSeconds,
187
+ decodeEnvelope
188
+ };
package/lib/jobs.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+
3
+ const { fail, isObject } = require('./errors');
4
+
5
+ const MIN_POLL = 10;
6
+ const MAX_POLL = 300;
7
+ const compatible = (version) => typeof version === 'string' && /^mlc\.device-job-status\/2(?:\.[0-9]+)*$/.test(version);
8
+
9
+ // Capability failure stops polling, never an otherwise valid upload.
10
+ function jobCapability(data) {
11
+ const source = data?.capabilities?.jobQuery;
12
+ const minimum = source?.minimumPollSeconds;
13
+ const supported = isObject(source) && source.supported === true;
14
+ return Object.freeze({
15
+ observed: true, supported,
16
+ compatible: supported && compatible(source.contractVersion) &&
17
+ Number.isInteger(minimum) && minimum >= MIN_POLL && minimum <= MAX_POLL,
18
+ contractVersion: typeof source?.contractVersion === 'string' ? source.contractVersion : '',
19
+ minimumPollSeconds: Number.isInteger(minimum) ? minimum : MIN_POLL
20
+ });
21
+ }
22
+
23
+ function parseJob(data, jobUid, pollFloor = MIN_POLL) {
24
+ const invalid = () => fail('job response violates mlc.device-job-status/2', {
25
+ kind: 'device_api', httpStatus: 200, action: 'contract_invalid'
26
+ });
27
+ if (!isObject(data) || !compatible(data.contractVersion) || data.jobUid !== jobUid) invalid();
28
+ for (const key of ['uploadUid', 'submissionStatus', 'jobStatus', 'resultStatus', 'updatedAt']) {
29
+ if (typeof data[key] !== 'string' || !data[key]) invalid();
30
+ }
31
+ if (typeof data.terminal !== 'boolean' || typeof data.resultFinal !== 'boolean' ||
32
+ data.terminal !== data.resultFinal) invalid();
33
+ const next = data.nextPollAfterSeconds;
34
+ if (data.resultFinal ? next != null : !Number.isInteger(next) || next < pollFloor || next > MAX_POLL) invalid();
35
+ for (const key of ['attemptCount', 'requeueCount']) {
36
+ if (data[key] != null && (!Number.isSafeInteger(data[key]) || data[key] < 0)) invalid();
37
+ }
38
+ for (const key of ['reasonCode', 'reasonMessage', 'pipelineEventId', 'solutionKind', 'solverName']) {
39
+ if (data[key] != null && typeof data[key] !== 'string') invalid();
40
+ }
41
+ if (data.accepted != null && typeof data.accepted !== 'boolean') invalid();
42
+ if (data.result != null && !isObject(data.result)) invalid();
43
+ return data;
44
+ }
45
+
46
+ function jobErrorAction(error) {
47
+ return ({
48
+ 1030002006: 'rate_limited', 1030003018: 'job_query_not_enabled',
49
+ 1030004000: 'job_unavailable', 1030004002: 'job_expired',
50
+ 1030004003: 'job_request_invalid'
51
+ })[error.apiCode] || (error.retryable ? 'retry' : error.httpStatus === 200 ? 'contract_invalid' : undefined);
52
+ }
53
+
54
+ module.exports = { jobCapability, parseJob, jobErrorAction };
package/lib/jose.js ADDED
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ const { fail, isObject } = require('./errors');
6
+
7
+ const ES256 = 'ES256';
8
+ const P256_CRV = 'P-256';
9
+
10
+ function base64UrlEncode(input) {
11
+ return Buffer.from(input).toString('base64url');
12
+ }
13
+
14
+ function base64UrlEncodeJson(value) {
15
+ return base64UrlEncode(Buffer.from(JSON.stringify(value), 'utf8'));
16
+ }
17
+
18
+ function base64UrlDecode(value) {
19
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value)) {
20
+ fail('value is not base64url');
21
+ }
22
+ return Buffer.from(value, 'base64url');
23
+ }
24
+
25
+ /**
26
+ * A cryptographically random base64url token. 32 bytes is the floor for PKCE
27
+ * verifiers (RFC 7636 §7.1) and is reused for `state` and every `jti`.
28
+ */
29
+ function randomToken(bytes = 32) {
30
+ return crypto.randomBytes(bytes).toString('base64url');
31
+ }
32
+
33
+ /**
34
+ * RFC 7636 S256: challenge = base64url(SHA-256(ASCII(verifier))).
35
+ */
36
+ function pkceChallengeS256(verifier) {
37
+ if (typeof verifier !== 'string' || !/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)) {
38
+ fail('PKCE verifier must be 43-128 unreserved characters');
39
+ }
40
+ return crypto.createHash('sha256').update(verifier, 'ascii').digest('base64url');
41
+ }
42
+
43
+ function createPkcePair(randomBytes = crypto.randomBytes) {
44
+ const verifier = Buffer.from(randomBytes(32)).toString('base64url');
45
+ return { verifier, challenge: pkceChallengeS256(verifier), method: 'S256' };
46
+ }
47
+
48
+ /**
49
+ * The public half of a P-256 key as a JWK whose members are in the exact
50
+ * lexicographic order RFC 7638 requires. Node's own `export({format:'jwk'})`
51
+ * emits `{kty,x,y,crv}`, which hashes to the wrong thumbprint, so the object is
52
+ * rebuilt here rather than reordered downstream.
53
+ */
54
+ function p256PublicJwk(key) {
55
+ const publicKey = key.type === 'public' ? key : crypto.createPublicKey(key);
56
+ const jwk = publicKey.export({ format: 'jwk' });
57
+ if (jwk.kty !== 'EC' || jwk.crv !== P256_CRV || !jwk.x || !jwk.y) {
58
+ fail('key is not a P-256 public key');
59
+ }
60
+ return { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
61
+ }
62
+
63
+ /**
64
+ * RFC 7638 JWK thumbprint, base64url SHA-256 — the same value Nimbus computes
65
+ * for `cnf.jkt` on the server, so DPoP binding compares equal byte for byte.
66
+ */
67
+ function jwkThumbprint(jwk) {
68
+ if (!isObject(jwk) || jwk.kty !== 'EC') fail('thumbprint requires an EC JWK');
69
+ const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
70
+ return crypto.createHash('sha256').update(canonical, 'utf8').digest('base64url');
71
+ }
72
+
73
+ /**
74
+ * Assembles a compact JWS. `sign` receives the exact signing input bytes and
75
+ * must return the 64-byte raw (IEEE P1363) ECDSA signature — never a key.
76
+ * Keeping the signature callback abstract is what lets a future CNG/TPM backend
77
+ * drop in without the private key ever entering the JS heap.
78
+ */
79
+ async function signCompactJws(header, claims, sign) {
80
+ const signingInput = `${base64UrlEncodeJson(header)}.${base64UrlEncodeJson(claims)}`;
81
+ const signature = await sign(Buffer.from(signingInput, 'ascii'));
82
+ if (!Buffer.isBuffer(signature) || signature.length !== 64) {
83
+ fail('ES256 signer must return a 64-byte IEEE P1363 signature', { kind: 'keystore' });
84
+ }
85
+ return `${signingInput}.${signature.toString('base64url')}`;
86
+ }
87
+
88
+ /**
89
+ * RFC 7523 client assertion for `private_key_jwt`.
90
+ *
91
+ * `aud` is the absolute token endpoint URL; the server chains SAS's own
92
+ * validator (iss/sub/aud/exp) with a max-age check of `exp - iat`, so the 60
93
+ * second window below is a contract value, not a preference.
94
+ */
95
+ function buildAssertionClaims({ installationUid, tokenEndpoint, now, lifetimeSeconds, jti }) {
96
+ return {
97
+ iss: installationUid,
98
+ sub: installationUid,
99
+ aud: tokenEndpoint,
100
+ exp: now + lifetimeSeconds,
101
+ iat: now,
102
+ jti
103
+ };
104
+ }
105
+
106
+ /**
107
+ * RFC 9449 DPoP proof. `ath` is present only for resource requests: the token
108
+ * endpoint has no access token to hash yet, and sending a bogus one there would
109
+ * be a spec violation rather than harmless noise.
110
+ */
111
+ function buildDpopClaims({ method, url, now, jti, accessToken }) {
112
+ const claims = { htm: method.toUpperCase(), htu: dpopHtu(url), iat: now, jti };
113
+ if (accessToken) {
114
+ claims.ath = crypto.createHash('sha256').update(accessToken, 'ascii').digest('base64url');
115
+ }
116
+ return claims;
117
+ }
118
+
119
+ /**
120
+ * RFC 9449 §4.2: `htu` is the request URI with query and fragment removed.
121
+ * `URL` also normalises the default port away, which matters because the server
122
+ * compares against its own reconstruction of the request URI.
123
+ */
124
+ function dpopHtu(url) {
125
+ const parsed = new URL(url);
126
+ parsed.search = '';
127
+ parsed.hash = '';
128
+ return parsed.toString();
129
+ }
130
+
131
+ module.exports = {
132
+ ES256,
133
+ P256_CRV,
134
+ base64UrlEncode,
135
+ base64UrlEncodeJson,
136
+ base64UrlDecode,
137
+ randomToken,
138
+ pkceChallengeS256,
139
+ createPkcePair,
140
+ p256PublicJwk,
141
+ jwkThumbprint,
142
+ signCompactJws,
143
+ buildAssertionClaims,
144
+ buildDpopClaims,
145
+ dpopHtu
146
+ };
package/lib/journal.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fsp = require('node:fs/promises');
5
+ const path = require('node:path');
6
+
7
+ const { MeteorCloudError, fail, isObject } = require('./errors');
8
+ const { assertNoSecrets } = require('./config');
9
+
10
+ const JOURNAL_VERSION = 2;
11
+ const MAX_JOURNAL_SIZE = 1024 * 1024;
12
+
13
+ /**
14
+ * Crash-resume bookkeeping for one upload session.
15
+ *
16
+ * v2 drops the COS multipart model (UploadId + per-part ETags) for a single
17
+ * `offset` per artifact, because the local plane's resume unit is a byte count
18
+ * rather than a part list.
19
+ *
20
+ * The offset here is a **hint, not an authority**. The server's `receivedBytes`
21
+ * is the only value allowed to decide where the next chunk starts; this file
22
+ * exists so a restart can skip the status round trip for artifacts that were
23
+ * plainly finished, and so a human debugging a stuck upload can see what the
24
+ * client believed. Where the two disagree, the server wins, always.
25
+ *
26
+ * Nothing credential-shaped may be written here — `assertNoSecrets` runs on
27
+ * every save, so a future field carrying a token fails the write rather than
28
+ * quietly persisting one next to the data it protects.
29
+ */
30
+ class UploadJournal {
31
+ constructor(journalPath, sessionUid) {
32
+ this.path = journalPath;
33
+ this.sessionUid = sessionUid;
34
+ this.state = { version: JOURNAL_VERSION, sessionUid, files: {} };
35
+ }
36
+
37
+ async load() {
38
+ try {
39
+ const stat = await fsp.lstat(this.path);
40
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_JOURNAL_SIZE) {
41
+ fail('upload journal is unsafe');
42
+ }
43
+ const loaded = JSON.parse(await fsp.readFile(this.path, 'utf8'));
44
+ if (
45
+ !isObject(loaded) ||
46
+ loaded.version !== JOURNAL_VERSION ||
47
+ loaded.sessionUid !== this.sessionUid ||
48
+ !isObject(loaded.files)
49
+ ) {
50
+ // A journal from a different session (or a v1 COS journal) is not an
51
+ // error worth failing the upload over: discard it and re-derive
52
+ // everything from the server. Reusing it would be the dangerous choice.
53
+ this.state = { version: JOURNAL_VERSION, sessionUid: this.sessionUid, files: {} };
54
+ return this;
55
+ }
56
+ this.state = loaded;
57
+ } catch (error) {
58
+ if (error && error.code === 'ENOENT') return this;
59
+ if (error instanceof MeteorCloudError) throw error;
60
+ throw new MeteorCloudError('upload journal is invalid', { kind: 'io', cause: error });
61
+ }
62
+ return this;
63
+ }
64
+
65
+ /**
66
+ * Per-artifact state keyed by role — the addressing unit of the chunked PUT
67
+ * plane. `key` is the relative upload target path (the local plane has no COS
68
+ * object key), `size`/`sha256` pin the identity of the bytes so a changed file
69
+ * invalidates the offset instead of resuming into a different artifact.
70
+ */
71
+ fileState(role) {
72
+ if (!isObject(this.state.files[role])) {
73
+ this.state.files[role] = { key: '', size: 0, sha256: '', offset: 0 };
74
+ }
75
+ return this.state.files[role];
76
+ }
77
+
78
+ /** Resets the recorded offset when the artifact identity no longer matches. */
79
+ reconcile(role, identity) {
80
+ const state = this.fileState(role);
81
+ if (state.key !== identity.key || state.size !== identity.size || state.sha256 !== identity.sha256) {
82
+ state.key = identity.key;
83
+ state.size = identity.size;
84
+ state.sha256 = identity.sha256;
85
+ state.offset = 0;
86
+ }
87
+ return state;
88
+ }
89
+
90
+ async save() {
91
+ const encoded = `${JSON.stringify(this.state)}\n`;
92
+ if (Buffer.byteLength(encoded) > MAX_JOURNAL_SIZE) fail('upload journal exceeds 1 MiB');
93
+ assertNoSecrets(encoded, 'upload journal');
94
+ const temporary = `${this.path}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
95
+ try {
96
+ // Inside the try: mkdir fails for exactly the same reasons the write does
97
+ // (ENOSPC, EACCES, EROFS), and leaving it outside let a raw fs error
98
+ // escape as itself — breaking the promise that MeteorCloudError is the
99
+ // only error this SDK throws, and defeating every `kind === 'io'` check
100
+ // downstream, including the one that keeps a full disk from killing an
101
+ // upload that needs no disk.
102
+ await fsp.mkdir(path.dirname(this.path), { recursive: true });
103
+ await fsp.writeFile(temporary, encoded, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
104
+ await fsp.rename(temporary, this.path);
105
+ } catch (cause) {
106
+ try {
107
+ await fsp.rm(temporary, { force: true });
108
+ } catch (_) {
109
+ /* best effort */
110
+ }
111
+ throw new MeteorCloudError('cannot save upload journal', { kind: 'io', cause });
112
+ }
113
+ }
114
+ }
115
+
116
+ module.exports = { UploadJournal, JOURNAL_VERSION, MAX_JOURNAL_SIZE };