@thuzjq/meteorcloud-device-sdk-node 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/docs/NODE_INTEGRATION_GUIDE.zh-CN.md +414 -0
- package/index.d.ts +930 -0
- package/index.js +945 -0
- package/lib/artifacts.js +279 -0
- package/lib/camera.js +316 -0
- package/lib/config.js +257 -0
- package/lib/connect.js +718 -0
- package/lib/durable.js +85 -0
- package/lib/errors.js +70 -0
- package/lib/http.js +188 -0
- package/lib/jobs.js +54 -0
- package/lib/jose.js +146 -0
- package/lib/journal.js +116 -0
- package/lib/keystore.js +585 -0
- package/lib/resources.js +408 -0
- package/lib/tokens.js +311 -0
- package/lib/upload.js +188 -0
- package/package.json +44 -0
- package/tools/migrate-key-to-dpapi.js +386 -0
package/lib/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 };
|