@blamejs/core 0.10.15 → 0.11.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/CHANGELOG.md +5 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/http-client.js +46 -10
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/ssrf-guard.js +71 -10
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/http-client.js
CHANGED
|
@@ -1257,14 +1257,52 @@ function _requestSingle(opts) {
|
|
|
1257
1257
|
}
|
|
1258
1258
|
}
|
|
1259
1259
|
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1260
|
+
// Proxy detection runs BEFORE the SSRF DNS lookup. When a proxy is
|
|
1261
|
+
// configured AND the operator explicitly opts into `allowInternal:
|
|
1262
|
+
// true`, the SSRF DNS lookup is skipped — the proxy resolves the
|
|
1263
|
+
// target hostname in its own network context (the proxy is the trust
|
|
1264
|
+
// boundary). Without this short-circuit, hostnames that only resolve
|
|
1265
|
+
// inside the proxy's network (e.g. corporate intranets, docker
|
|
1266
|
+
// service names) would fail DNS locally before the proxy ever sees
|
|
1267
|
+
// them.
|
|
1268
|
+
//
|
|
1269
|
+
// The `allowInternal: true` opt is the operator's affirmative waiver
|
|
1270
|
+
// of local SSRF defense; combined with a configured proxy, it
|
|
1271
|
+
// signals "trust the proxy's resolution + classification". When
|
|
1272
|
+
// `allowInternal` is false / array-form, the SSRF check still runs
|
|
1273
|
+
// even with a proxy — the proxy's freedom to reach internal IPs is
|
|
1274
|
+
// not a license for operator code to do so without the explicit opt-in.
|
|
1275
|
+
var proxyAgent = null;
|
|
1276
|
+
try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
|
|
1277
|
+
|
|
1278
|
+
var ssrfPromise;
|
|
1279
|
+
if (proxyAgent && opts.allowInternal === true) {
|
|
1280
|
+
// Proxy short-circuit — skip DNS resolution of the destination
|
|
1281
|
+
// hostname (the proxy resolves it in its own network context).
|
|
1282
|
+
// BUT still apply the textual cloud-metadata-IP block: addresses
|
|
1283
|
+
// like 169.254.169.254 (AWS / GCP / Azure / OpenStack / DO IMDS)
|
|
1284
|
+
// leak instance credentials and are NEVER overridable, even with
|
|
1285
|
+
// `allowInternal: true` AND a proxy configured. ssrfGuard's
|
|
1286
|
+
// textual check refuses metadata-IP literals at the hostname-text
|
|
1287
|
+
// layer so the proxy never receives the request.
|
|
1288
|
+
try {
|
|
1289
|
+
ssrfGuard.checkUrlTextual(u, { errorClass: opts.errorClass });
|
|
1290
|
+
} catch (eMeta) {
|
|
1291
|
+
return Promise.reject(eMeta);
|
|
1292
|
+
}
|
|
1293
|
+
ssrfPromise = Promise.resolve({ ips: null });
|
|
1294
|
+
} else {
|
|
1295
|
+
// SSRF gate — refuse private / loopback / link-local / cloud-metadata
|
|
1296
|
+
// / reserved IP destinations by default. The returned `ips` are
|
|
1297
|
+
// threaded into transport creation so the actual TCP connect pins
|
|
1298
|
+
// to those exact addresses, closing the DNS-rebinding TOCTOU window.
|
|
1299
|
+
ssrfPromise = ssrfGuard.checkUrl(u, {
|
|
1300
|
+
allowInternal: opts.allowInternal,
|
|
1301
|
+
errorClass: opts.errorClass,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
return ssrfPromise.then(function (ssrfResult) {
|
|
1268
1306
|
var ips = ssrfResult && ssrfResult.ips;
|
|
1269
1307
|
// Caller-supplied agent bypasses transport cache (h1 only). The
|
|
1270
1308
|
// operator owns the agent's connection pool — we still pass the
|
|
@@ -1278,8 +1316,6 @@ function _requestSingle(opts) {
|
|
|
1278
1316
|
}, u, opts);
|
|
1279
1317
|
}
|
|
1280
1318
|
|
|
1281
|
-
var proxyAgent = null;
|
|
1282
|
-
try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
|
|
1283
1319
|
if (proxyAgent) {
|
|
1284
1320
|
return _requestH1({
|
|
1285
1321
|
kind: "h1",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.importmapIntegrity
|
|
4
|
+
* @nav HTTP
|
|
5
|
+
* @title Import-Map Integrity
|
|
6
|
+
* @order 175
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* WICG Import Maps + Subresource Integrity (SRI) extension. When
|
|
10
|
+
* a page declares `<script type="importmap">`, each mapped module
|
|
11
|
+
* SHOULD carry an `integrity` hash so the browser refuses to
|
|
12
|
+
* execute the module if the bytes don't match.
|
|
13
|
+
*
|
|
14
|
+
* `b.importmapIntegrity.build({ specifiers, sha256, sha384,
|
|
15
|
+
* sha512 })` hashes each operator-supplied module body and emits
|
|
16
|
+
* the `<script type="importmap">` JSON with an `integrity` map
|
|
17
|
+
* alongside the `imports` map. Composes existing `b.crypto.sri`.
|
|
18
|
+
*
|
|
19
|
+
* @card
|
|
20
|
+
* WICG Import Maps + SRI integrity builder. Emits importmap JSON with module-body SHA-384 hashes so browsers refuse unauthenticated module bytes.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
var validateOpts = require("./validate-opts");
|
|
24
|
+
var bCrypto = require("./crypto");
|
|
25
|
+
var { defineClass } = require("./framework-error");
|
|
26
|
+
|
|
27
|
+
var ImportmapError = defineClass("ImportmapError", { alwaysPermanent: true });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @primitive b.importmapIntegrity.build
|
|
31
|
+
* @signature b.importmapIntegrity.build(opts)
|
|
32
|
+
* @since 0.10.16
|
|
33
|
+
* @status stable
|
|
34
|
+
*
|
|
35
|
+
* Build an import-map JSON shape `{ imports, integrity }` per WICG
|
|
36
|
+
* Import-Maps-SRI. Each module body is hashed with `opts.hash`
|
|
37
|
+
* (default sha384 per current SRI convention).
|
|
38
|
+
*
|
|
39
|
+
* @opts
|
|
40
|
+
* modules: { "<specifier>": { url, body: Buffer|string } },
|
|
41
|
+
* hash: "sha256"|"sha384"|"sha512", // default sha384
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* var im = b.importmapIntegrity.build({
|
|
45
|
+
* modules: {
|
|
46
|
+
* "@org/lib": { url: "/static/lib.js", body: fileBytes },
|
|
47
|
+
* },
|
|
48
|
+
* });
|
|
49
|
+
* res.end("<script type=\"importmap\">" + JSON.stringify(im) + "</script>");
|
|
50
|
+
*/
|
|
51
|
+
function build(opts) {
|
|
52
|
+
opts = validateOpts.requireObject(opts, "importmapIntegrity.build",
|
|
53
|
+
ImportmapError, "importmap/bad-opts");
|
|
54
|
+
validateOpts(opts, ["modules", "hash"], "importmapIntegrity.build");
|
|
55
|
+
if (!opts.modules || typeof opts.modules !== "object" || Array.isArray(opts.modules)) {
|
|
56
|
+
throw new ImportmapError("importmap/no-modules",
|
|
57
|
+
"build: opts.modules must be a non-array object");
|
|
58
|
+
}
|
|
59
|
+
var hash = opts.hash || "sha384";
|
|
60
|
+
if (hash !== "sha256" && hash !== "sha384" && hash !== "sha512") {
|
|
61
|
+
throw new ImportmapError("importmap/bad-hash",
|
|
62
|
+
"build: hash must be sha256 / sha384 / sha512");
|
|
63
|
+
}
|
|
64
|
+
var imports = {};
|
|
65
|
+
var integrity = {};
|
|
66
|
+
var keys = Object.keys(opts.modules);
|
|
67
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
68
|
+
var spec = keys[i];
|
|
69
|
+
var mod = opts.modules[spec];
|
|
70
|
+
if (!mod || typeof mod.url !== "string") {
|
|
71
|
+
throw new ImportmapError("importmap/bad-module",
|
|
72
|
+
"build: modules['" + spec + "'].url must be a string");
|
|
73
|
+
}
|
|
74
|
+
if (!Buffer.isBuffer(mod.body) && typeof mod.body !== "string") {
|
|
75
|
+
throw new ImportmapError("importmap/bad-module",
|
|
76
|
+
"build: modules['" + spec + "'].body must be a Buffer or string");
|
|
77
|
+
}
|
|
78
|
+
imports[spec] = mod.url;
|
|
79
|
+
// Compose b.crypto.sri — returns the canonical SRI string
|
|
80
|
+
// (e.g. `sha384-<base64>`). b.crypto.sri takes its hash from
|
|
81
|
+
// `opts.algorithm`, not a positional arg.
|
|
82
|
+
integrity[mod.url] = bCrypto.sri(mod.body, { algorithm: hash });
|
|
83
|
+
}
|
|
84
|
+
return { imports: imports, integrity: integrity };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
build: build,
|
|
89
|
+
ImportmapError: ImportmapError,
|
|
90
|
+
};
|
package/lib/jsonapi.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.jsonApi
|
|
4
|
+
* @nav HTTP
|
|
5
|
+
* @title JSON:API
|
|
6
|
+
* @order 172
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* JSON:API v1.1 (jsonapi.org/format/1.1/) response-shape helpers.
|
|
10
|
+
* The framework's wire-format primitives compose this so operators
|
|
11
|
+
* building JSON:API services get the right top-level shape + the
|
|
12
|
+
* right Content-Type without re-implementing the spec each time.
|
|
13
|
+
*
|
|
14
|
+
* Content-Type: `application/vnd.api+json`
|
|
15
|
+
*
|
|
16
|
+
* Top-level shapes:
|
|
17
|
+
* - `dataResponse(data, opts?)` — `{ data: [...] | {...}, included?, links?, meta? }`
|
|
18
|
+
* - `errorResponse(errors)` — `{ errors: [...] }`
|
|
19
|
+
* - `linkObject(url, opts?)` — string href OR `{ href, rel, meta }`
|
|
20
|
+
*
|
|
21
|
+
* @card
|
|
22
|
+
* JSON:API v1.1 response shape builders. Content-Type negotiation, top-level data/errors/included/links/meta wrappers, error-object shape per §7.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var validateOpts = require("./validate-opts");
|
|
26
|
+
var numericBounds = require("./numeric-bounds");
|
|
27
|
+
var { defineClass } = require("./framework-error");
|
|
28
|
+
|
|
29
|
+
var JsonApiError = defineClass("JsonApiError", { alwaysPermanent: true });
|
|
30
|
+
|
|
31
|
+
var CONTENT_TYPE = "application/vnd.api+json";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @primitive b.jsonApi.dataResponse
|
|
35
|
+
* @signature b.jsonApi.dataResponse(data, opts?)
|
|
36
|
+
* @since 0.10.16
|
|
37
|
+
* @status stable
|
|
38
|
+
*
|
|
39
|
+
* Build a JSON:API v1.1 success response. `data` can be a Resource
|
|
40
|
+
* Object, an array of Resource Objects, or null (for single-resource
|
|
41
|
+
* 404 / empty-collection responses). Each Resource Object must carry
|
|
42
|
+
* `type` + `id` (§7.2).
|
|
43
|
+
*
|
|
44
|
+
* @opts
|
|
45
|
+
* included: ResourceObject[], // compound documents §7.7
|
|
46
|
+
* links: object, // top-level links §7.5
|
|
47
|
+
* meta: object, // non-standard top-level meta §7.4
|
|
48
|
+
* jsonapi: object, // jsonapi-object §7.3 (version etc.)
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* res.setHeader("Content-Type", "application/vnd.api+json");
|
|
52
|
+
* res.end(JSON.stringify(b.jsonApi.dataResponse(
|
|
53
|
+
* { type: "articles", id: "1", attributes: { title: "Hello" } },
|
|
54
|
+
* { links: { self: "/articles/1" } }
|
|
55
|
+
* )));
|
|
56
|
+
*/
|
|
57
|
+
function dataResponse(data, opts) {
|
|
58
|
+
opts = opts || {};
|
|
59
|
+
validateOpts(opts, ["included", "links", "meta", "jsonapi"], "jsonApi.dataResponse");
|
|
60
|
+
if (data !== null && data !== undefined) {
|
|
61
|
+
if (Array.isArray(data)) {
|
|
62
|
+
for (var i = 0; i < data.length; i += 1) _assertResource(data[i], i);
|
|
63
|
+
} else {
|
|
64
|
+
_assertResource(data, null);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
var out = { data: data === undefined ? null : data };
|
|
68
|
+
if (opts.included) {
|
|
69
|
+
if (!Array.isArray(opts.included)) {
|
|
70
|
+
throw new JsonApiError("json-api/bad-included",
|
|
71
|
+
"dataResponse: opts.included must be an array");
|
|
72
|
+
}
|
|
73
|
+
for (var j = 0; j < opts.included.length; j += 1) _assertResource(opts.included[j], j);
|
|
74
|
+
out.included = opts.included;
|
|
75
|
+
}
|
|
76
|
+
if (opts.links) out.links = opts.links;
|
|
77
|
+
if (opts.meta) out.meta = opts.meta;
|
|
78
|
+
if (opts.jsonapi) out.jsonapi = opts.jsonapi;
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @primitive b.jsonApi.errorResponse
|
|
84
|
+
* @signature b.jsonApi.errorResponse(errors, opts?)
|
|
85
|
+
* @since 0.10.16
|
|
86
|
+
* @status stable
|
|
87
|
+
*
|
|
88
|
+
* Build a JSON:API v1.1 error response per §7.6. Each error object
|
|
89
|
+
* can carry `id` / `status` / `code` / `title` / `detail` / `source` /
|
|
90
|
+
* `links` / `meta`. The framework refuses errors lacking BOTH
|
|
91
|
+
* `status` and `title` (most JSON:API consumers need at least one).
|
|
92
|
+
*
|
|
93
|
+
* @opts
|
|
94
|
+
* meta: object, // optional top-level `meta` block (JSON:API §5.3)
|
|
95
|
+
* jsonapi: object, // optional top-level `jsonapi` member (JSON:API §5.2 — version / ext / profile)
|
|
96
|
+
* links: object, // optional top-level `links` (JSON:API §5.4 — self / related / pagination)
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* res.statusCode = 422;
|
|
100
|
+
* res.end(JSON.stringify(b.jsonApi.errorResponse([
|
|
101
|
+
* { status: "422", code: "INVALID", title: "Invalid email",
|
|
102
|
+
* source: { pointer: "/data/attributes/email" } },
|
|
103
|
+
* ])));
|
|
104
|
+
*/
|
|
105
|
+
function errorResponse(errors, opts) {
|
|
106
|
+
opts = opts || {};
|
|
107
|
+
if (!Array.isArray(errors) || errors.length === 0) {
|
|
108
|
+
throw new JsonApiError("json-api/no-errors",
|
|
109
|
+
"errorResponse: errors must be a non-empty array");
|
|
110
|
+
}
|
|
111
|
+
var checked = errors.map(function (e, idx) {
|
|
112
|
+
if (!e || typeof e !== "object") {
|
|
113
|
+
throw new JsonApiError("json-api/bad-error",
|
|
114
|
+
"errorResponse: errors[" + idx + "] must be an object");
|
|
115
|
+
}
|
|
116
|
+
if (typeof e.status !== "string" && typeof e.title !== "string") {
|
|
117
|
+
throw new JsonApiError("json-api/empty-error",
|
|
118
|
+
"errorResponse: errors[" + idx + "] must have at least 'status' or 'title' (string)");
|
|
119
|
+
}
|
|
120
|
+
return e;
|
|
121
|
+
});
|
|
122
|
+
var out = { errors: checked };
|
|
123
|
+
if (opts.meta) out.meta = opts.meta;
|
|
124
|
+
if (opts.jsonapi) out.jsonapi = opts.jsonapi;
|
|
125
|
+
if (opts.links) out.links = opts.links;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _assertResource(r, idx) {
|
|
130
|
+
if (!r || typeof r !== "object") {
|
|
131
|
+
throw new JsonApiError("json-api/bad-resource",
|
|
132
|
+
"Resource at " + (idx === null ? "<root>" : "index " + idx) + " must be an object");
|
|
133
|
+
}
|
|
134
|
+
if (typeof r.type !== "string" || r.type.length === 0) {
|
|
135
|
+
throw new JsonApiError("json-api/missing-type",
|
|
136
|
+
"Resource at " + (idx === null ? "<root>" : "index " + idx) + " missing 'type'");
|
|
137
|
+
}
|
|
138
|
+
// id is OPTIONAL only on client-side create requests; we don't have
|
|
139
|
+
// a way to distinguish, so we accept missing id (the operator's
|
|
140
|
+
// responsibility to set it for non-create paths).
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @primitive b.jsonApi.parseQuery
|
|
145
|
+
* @signature b.jsonApi.parseQuery(queryString, opts?)
|
|
146
|
+
* @since 0.10.16
|
|
147
|
+
* @status stable
|
|
148
|
+
*
|
|
149
|
+
* Parse a JSON:API v1.1 query string per §5 (Fetching Data). Returns
|
|
150
|
+
* `{ include, fields, filter, sort, page }`:
|
|
151
|
+
* - `include` — array of relationship paths from `include=` (comma-split)
|
|
152
|
+
* - `fields[type]` — array of sparse-fieldset selectors per type
|
|
153
|
+
* - `filter` — pass-through object (spec defers filter shape to operators)
|
|
154
|
+
* - `sort` — array of `{ field, asc }` per RFC 7159-style direction
|
|
155
|
+
* - `page` — pass-through object (operator picks page-strategy)
|
|
156
|
+
*
|
|
157
|
+
* Refuses missing required `include` paths when opts.includeAllowlist is
|
|
158
|
+
* supplied and an unrecognized path appears.
|
|
159
|
+
*
|
|
160
|
+
* @opts
|
|
161
|
+
* includeAllowlist: string[],
|
|
162
|
+
* sortAllowlist: string[],
|
|
163
|
+
* maxIncludeDepth: number, // default 5
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* var q = b.jsonApi.parseQuery(req.url.split("?")[1]);
|
|
167
|
+
* q.include; // → ["author", "comments.author"]
|
|
168
|
+
* q.fields.articles; // → ["title", "body"]
|
|
169
|
+
* q.sort; // → [{ field: "createdAt", asc: false }]
|
|
170
|
+
*/
|
|
171
|
+
function parseQuery(queryString, opts) {
|
|
172
|
+
opts = opts || {};
|
|
173
|
+
if (typeof queryString !== "string") {
|
|
174
|
+
throw new JsonApiError("json-api/bad-query",
|
|
175
|
+
"parseQuery: queryString must be a string");
|
|
176
|
+
}
|
|
177
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxIncludeDepth, "maxIncludeDepth",
|
|
178
|
+
JsonApiError, "json-api/bad-max-include-depth");
|
|
179
|
+
var maxDepth = typeof opts.maxIncludeDepth === "number" ? opts.maxIncludeDepth : 5;
|
|
180
|
+
var out = { include: [], fields: {}, filter: {}, sort: [], page: {} };
|
|
181
|
+
if (queryString.length === 0) return out;
|
|
182
|
+
var pairs = queryString.split("&");
|
|
183
|
+
for (var i = 0; i < pairs.length; i += 1) {
|
|
184
|
+
var eq = pairs[i].indexOf("=");
|
|
185
|
+
if (eq === -1) continue;
|
|
186
|
+
var rawKey = decodeURIComponent(pairs[i].slice(0, eq));
|
|
187
|
+
var rawVal = decodeURIComponent(pairs[i].slice(eq + 1));
|
|
188
|
+
if (rawKey === "include") {
|
|
189
|
+
out.include = rawVal.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
190
|
+
for (var ii = 0; ii < out.include.length; ii += 1) {
|
|
191
|
+
var depth = out.include[ii].split(".").length;
|
|
192
|
+
if (depth > maxDepth) {
|
|
193
|
+
throw new JsonApiError("json-api/include-too-deep",
|
|
194
|
+
"parseQuery: include path '" + out.include[ii] + "' exceeds maxIncludeDepth=" + maxDepth);
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(opts.includeAllowlist) &&
|
|
197
|
+
opts.includeAllowlist.indexOf(out.include[ii]) === -1) {
|
|
198
|
+
throw new JsonApiError("json-api/include-not-allowed",
|
|
199
|
+
"parseQuery: include path '" + out.include[ii] + "' not in allowlist");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} else if (rawKey === "sort") {
|
|
203
|
+
out.sort = rawVal.split(",").map(function (s) { return s.trim(); }).filter(Boolean).map(function (s) {
|
|
204
|
+
var asc = true;
|
|
205
|
+
if (s.charAt(0) === "-") { asc = false; s = s.slice(1); }
|
|
206
|
+
if (Array.isArray(opts.sortAllowlist) && opts.sortAllowlist.indexOf(s) === -1) {
|
|
207
|
+
throw new JsonApiError("json-api/sort-not-allowed",
|
|
208
|
+
"parseQuery: sort field '" + s + "' not in allowlist");
|
|
209
|
+
}
|
|
210
|
+
return { field: s, asc: asc };
|
|
211
|
+
});
|
|
212
|
+
} else if (rawKey.indexOf("fields[") === 0 && rawKey.charAt(rawKey.length - 1) === "]") {
|
|
213
|
+
var type = rawKey.slice(7, -1); // allow:raw-byte-literal — `fields[` length
|
|
214
|
+
out.fields[type] = rawVal.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
215
|
+
} else if (rawKey.indexOf("filter[") === 0 && rawKey.charAt(rawKey.length - 1) === "]") {
|
|
216
|
+
out.filter[rawKey.slice(7, -1)] = rawVal; // allow:raw-byte-literal — `filter[` length
|
|
217
|
+
} else if (rawKey.indexOf("page[") === 0 && rawKey.charAt(rawKey.length - 1) === "]") {
|
|
218
|
+
out.page[rawKey.slice(5, -1)] = rawVal; // allow:raw-byte-literal — `page[` length
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
dataResponse: dataResponse,
|
|
226
|
+
errorResponse: errorResponse,
|
|
227
|
+
parseQuery: parseQuery,
|
|
228
|
+
CONTENT_TYPE: CONTENT_TYPE,
|
|
229
|
+
JsonApiError: JsonApiError,
|
|
230
|
+
};
|
package/lib/lro.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.lro
|
|
4
|
+
* @nav HTTP
|
|
5
|
+
* @title Long-Running Operations
|
|
6
|
+
* @order 178
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Google API Improvement Proposals AIP-151 (Long-Running Operations)
|
|
10
|
+
* — a uniform shape for async APIs where the response can't be
|
|
11
|
+
* computed within the request lifetime. The operator's POST
|
|
12
|
+
* endpoint returns an Operation resource (`name` / `done` /
|
|
13
|
+
* `metadata` / `response` | `error`) which the client polls at
|
|
14
|
+
* `/operations/<name>` until `done: true`. Composes existing
|
|
15
|
+
* `b.queue` / `b.jobs` for the actual work; this module just
|
|
16
|
+
* ships the wire-shape + status-poll endpoint helpers.
|
|
17
|
+
*
|
|
18
|
+
* `b.lro.create(opts)` returns `{ submit, status, list, cancel }`
|
|
19
|
+
* wired to operator-supplied storage (in-memory by default for
|
|
20
|
+
* single-process; ops with multiple workers wire `b.cache` /
|
|
21
|
+
* `b.db` storage via `opts.store`).
|
|
22
|
+
*
|
|
23
|
+
* @card
|
|
24
|
+
* AIP-151 Long-Running Operations — uniform Operation resource shape + submit / status / cancel endpoints. Composes b.queue / b.jobs for the actual work.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
var validateOpts = require("./validate-opts");
|
|
28
|
+
var numericBounds = require("./numeric-bounds");
|
|
29
|
+
var bCrypto = require("./crypto");
|
|
30
|
+
var C = require("./constants");
|
|
31
|
+
var { defineClass } = require("./framework-error");
|
|
32
|
+
|
|
33
|
+
var LroError = defineClass("LroError", { alwaysPermanent: true });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @primitive b.lro.create
|
|
37
|
+
* @signature b.lro.create(opts?)
|
|
38
|
+
* @since 0.10.16
|
|
39
|
+
* @status stable
|
|
40
|
+
*
|
|
41
|
+
* Create an LRO registry. Returns `{ submit, status, list, cancel }`.
|
|
42
|
+
* Operations are tracked in `opts.store` (a Map-shaped object) or an
|
|
43
|
+
* in-memory Map when omitted. `submit({ work, metadata?, name? })`
|
|
44
|
+
* runs `work` async + returns the initial Operation resource;
|
|
45
|
+
* `status(name)` returns the current Operation (with `done: true`
|
|
46
|
+
* + `response` or `error` set when finished); `cancel(name)`
|
|
47
|
+
* surfaces cancellation back to the work function via the supplied
|
|
48
|
+
* AbortSignal.
|
|
49
|
+
*
|
|
50
|
+
* @opts
|
|
51
|
+
* store: Map-like { get, set, delete, keys },
|
|
52
|
+
* namePrefix: string, // default "operations/"
|
|
53
|
+
* maxConcurrent: number, // soft cap; overflow refuses with lro/too-many
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* var lro = b.lro.create();
|
|
57
|
+
* var op = await lro.submit({
|
|
58
|
+
* work: async function (signal) { return await heavyJob(signal); },
|
|
59
|
+
* });
|
|
60
|
+
* res.statusCode = 202;
|
|
61
|
+
* res.end(JSON.stringify(op));
|
|
62
|
+
*
|
|
63
|
+
* // Later, on GET /operations/<name>:
|
|
64
|
+
* res.end(JSON.stringify(lro.status(op.name)));
|
|
65
|
+
*/
|
|
66
|
+
function create(opts) {
|
|
67
|
+
opts = opts || {};
|
|
68
|
+
validateOpts(opts, ["store", "namePrefix", "maxConcurrent"],
|
|
69
|
+
"lro.create");
|
|
70
|
+
var store = opts.store || new Map();
|
|
71
|
+
var prefix = opts.namePrefix || "operations/";
|
|
72
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxConcurrent, "maxConcurrent",
|
|
73
|
+
LroError, "lro/bad-max-concurrent");
|
|
74
|
+
var maxConcurrent = typeof opts.maxConcurrent === "number" ? opts.maxConcurrent : 1024; // allow:raw-byte-literal — default in-flight cap
|
|
75
|
+
|
|
76
|
+
function _newName() { return prefix + bCrypto.generateToken(32); } // allow:raw-byte-literal — 32-char name token
|
|
77
|
+
|
|
78
|
+
function submit(submitOpts) {
|
|
79
|
+
submitOpts = validateOpts.requireObject(submitOpts, "lro.submit",
|
|
80
|
+
LroError, "lro/bad-opts");
|
|
81
|
+
if (typeof submitOpts.work !== "function") {
|
|
82
|
+
throw new LroError("lro/no-work",
|
|
83
|
+
"submit: opts.work must be a function (signal) => Promise<result>");
|
|
84
|
+
}
|
|
85
|
+
var inFlight = 0;
|
|
86
|
+
var keys = store.keys ? Array.from(store.keys()) : [];
|
|
87
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
88
|
+
var op = store.get(keys[i]);
|
|
89
|
+
if (op && !op.done) inFlight += 1;
|
|
90
|
+
}
|
|
91
|
+
if (inFlight >= maxConcurrent) {
|
|
92
|
+
throw new LroError("lro/too-many",
|
|
93
|
+
"submit: " + inFlight + " operations in flight (cap " + maxConcurrent + ")");
|
|
94
|
+
}
|
|
95
|
+
var name = submitOpts.name || _newName();
|
|
96
|
+
var controller = (typeof AbortController === "function") ? new AbortController() : null;
|
|
97
|
+
var operation = {
|
|
98
|
+
name: name,
|
|
99
|
+
done: false,
|
|
100
|
+
metadata: submitOpts.metadata || {},
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
store.set(name, operation);
|
|
104
|
+
// Kick off the work async. Operator-supplied function MUST accept
|
|
105
|
+
// an AbortSignal (or ignore it). Errors land on operation.error;
|
|
106
|
+
// successful results land on operation.response per AIP-151 shape.
|
|
107
|
+
Promise.resolve()
|
|
108
|
+
.then(function () { return submitOpts.work(controller ? controller.signal : null); })
|
|
109
|
+
.then(function (response) {
|
|
110
|
+
var stored = store.get(name);
|
|
111
|
+
if (!stored) return;
|
|
112
|
+
// Cancellation precedes resolve — if `cancel()` flipped the
|
|
113
|
+
// operation to CANCELLED first AND the work function ignored
|
|
114
|
+
// the AbortSignal, do NOT overwrite the cancelled terminal
|
|
115
|
+
// state. AIP-151 §6.x: once an operation is `done: true`, it
|
|
116
|
+
// stays done with the first terminal state it landed in.
|
|
117
|
+
if (stored.done) return;
|
|
118
|
+
stored.done = true;
|
|
119
|
+
stored.response = (response === undefined) ? null : response;
|
|
120
|
+
stored.completedAt = new Date().toISOString();
|
|
121
|
+
}, function (err) {
|
|
122
|
+
var stored = store.get(name);
|
|
123
|
+
if (!stored) return;
|
|
124
|
+
// Same guard as the resolve path — once terminal, stay terminal.
|
|
125
|
+
if (stored.done) return;
|
|
126
|
+
stored.done = true;
|
|
127
|
+
// AIP-151 error: { code, message, details? } shape.
|
|
128
|
+
var msg = (err && err.message) || String(err);
|
|
129
|
+
stored.error = { code: 13, message: msg }; // allow:raw-byte-literal — google.rpc.Code.INTERNAL = 13
|
|
130
|
+
if (err && err.code) stored.error.errorCode = err.code;
|
|
131
|
+
stored.completedAt = new Date().toISOString();
|
|
132
|
+
});
|
|
133
|
+
// Store the controller against the operation (off-resource, so
|
|
134
|
+
// serialisation doesn't accidentally export it).
|
|
135
|
+
operation._controller = controller;
|
|
136
|
+
return _stripPrivate(operation);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function status(name) {
|
|
140
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
141
|
+
throw new LroError("lro/bad-name", "status: name must be a non-empty string");
|
|
142
|
+
}
|
|
143
|
+
var op = store.get(name);
|
|
144
|
+
if (!op) {
|
|
145
|
+
throw new LroError("lro/not-found", "status: no operation named '" + name + "'");
|
|
146
|
+
}
|
|
147
|
+
return _stripPrivate(op);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function list(filter) {
|
|
151
|
+
filter = filter || {};
|
|
152
|
+
var out = [];
|
|
153
|
+
var keys = store.keys ? Array.from(store.keys()) : [];
|
|
154
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
155
|
+
var op = store.get(keys[i]);
|
|
156
|
+
if (!op) continue;
|
|
157
|
+
if (filter.doneOnly && !op.done) continue;
|
|
158
|
+
if (filter.pendingOnly && op.done) continue;
|
|
159
|
+
out.push(_stripPrivate(op));
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cancel(name) {
|
|
165
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
166
|
+
throw new LroError("lro/bad-name", "cancel: name must be a non-empty string");
|
|
167
|
+
}
|
|
168
|
+
var op = store.get(name);
|
|
169
|
+
if (!op) {
|
|
170
|
+
throw new LroError("lro/not-found", "cancel: no operation named '" + name + "'");
|
|
171
|
+
}
|
|
172
|
+
if (op.done) return _stripPrivate(op);
|
|
173
|
+
if (op._controller) {
|
|
174
|
+
try { op._controller.abort(); } catch (_e) { /* best-effort */ }
|
|
175
|
+
}
|
|
176
|
+
// Mark cancelled per AIP-151 — error.code 1 = CANCELLED.
|
|
177
|
+
op.done = true;
|
|
178
|
+
op.error = { code: 1, message: "operation cancelled" }; // allow:raw-byte-literal — google.rpc.Code.CANCELLED = 1
|
|
179
|
+
op.completedAt = new Date().toISOString();
|
|
180
|
+
return _stripPrivate(op);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
void C;
|
|
184
|
+
return { submit: submit, status: status, list: list, cancel: cancel };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _stripPrivate(op) {
|
|
188
|
+
var out = {};
|
|
189
|
+
var keys = Object.keys(op);
|
|
190
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
191
|
+
if (keys[i].charAt(0) === "_") continue;
|
|
192
|
+
out[keys[i]] = op[keys[i]];
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
create: create,
|
|
199
|
+
LroError: LroError,
|
|
200
|
+
};
|