@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.
@@ -1257,14 +1257,52 @@ function _requestSingle(opts) {
1257
1257
  }
1258
1258
  }
1259
1259
 
1260
- // SSRF gate refuse private / loopback / link-local / cloud-metadata
1261
- // / reserved IP destinations by default. The returned `ips` are
1262
- // threaded into transport creation so the actual TCP connect pins
1263
- // to those exact addresses, closing the DNS-rebinding TOCTOU window.
1264
- return ssrfGuard.checkUrl(u, {
1265
- allowInternal: opts.allowInternal,
1266
- errorClass: opts.errorClass,
1267
- }).then(function (ssrfResult) {
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
+ };