@onlineapps/content-resolver 1.1.16 → 2.0.0

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.
Files changed (3) hide show
  1. package/README.md +36 -8
  2. package/package.json +9 -8
  3. package/src/index.js +173 -31
package/README.md CHANGED
@@ -31,7 +31,8 @@ const resolver = new ContentResolver({
31
31
  port: 9000,
32
32
  accessKey: 'minioadmin',
33
33
  secretKey: 'minioadmin'
34
- }
34
+ },
35
+ logger // required - the constructor throws without a logger exposing warn()
35
36
  });
36
37
 
37
38
  // Resolve reference to content
@@ -57,7 +58,7 @@ const descriptor = await resolver.store(largeText, { workflow_id: 'wf-123' }, 'd
57
58
  const ContentResolver = require('@onlineapps/content-resolver');
58
59
 
59
60
  exports.processDocument = async (input, context = {}) => {
60
- const resolver = new ContentResolver();
61
+ const resolver = new ContentResolver({ logger }); // logger is required
61
62
 
62
63
  // Input can be either text or reference - resolve transparently
63
64
  const resolvedInput = await resolver.resolveInput(input, ['content', 'markdown']);
@@ -80,17 +81,37 @@ exports.processDocument = async (input, context = {}) => {
80
81
  |--------|------|---------|-------------|
81
82
  | `threshold` | number | 16384 | Size threshold in bytes |
82
83
  | `storage` | Object | env-based | Storage connector config |
83
- | `logger` | Object | console | Logger instance |
84
+ | `logger` | Object | **required** | Logger instance — must expose `warn()`. The constructor throws without it (`src/index.js`); there is no `console` default. |
85
+
86
+ ### Input contract
87
+
88
+ Every method below takes one of two input sets, and **throws on anything else** —
89
+ there is no `String(value)` coercion anywhere in the API. An object used to be
90
+ stored and shipped as the literal bytes `[object Object]`; that path is gone.
91
+
92
+ | Set | Accepted | Methods |
93
+ |---|---|---|
94
+ | **Content value** | a string (inline content **or** a `minio://<bucket>/<path>` / `internal://storage/<path>` reference), or a Content Descriptor (`{ _descriptor: true, type: 'inline' \| 'file' }`, or type-only) | `getAsBuffer`, `getAsString`, `getMetadata`, `normalizeToDescriptor` |
95
+ | **Raw content** | a string or a `Buffer` | `createDescriptor`, `store` |
96
+
97
+ `resolve()` is narrower still: a string only, because it promises to return one.
98
+
99
+ Rejection message format: `[ContentResolver] Unsupported content value - <method>() accepts <shapes>, got <kind>. Fix: …`
84
100
 
85
101
  ### Methods
86
102
 
87
103
  #### `resolve(value): Promise<string>`
88
104
  If value is a reference (`minio://...`), downloads and returns content.
89
- Otherwise returns value unchanged.
105
+ A non-reference string is returned unchanged. **Anything that is not a string throws** —
106
+ use `getAsString()` to read a Descriptor.
90
107
 
91
108
  #### `store(content, context, filename?, content_type?): Promise<Object>`
92
109
  Stores content and returns **Content Descriptor**. If size > threshold, stores in MinIO.
93
110
  Returns Descriptor with `type: 'inline'` or `type: 'file'`.
111
+ Content must be a string or a `Buffer`. The **empty string** `''` is content: it returns
112
+ the documented empty inline descriptor (`filename: 'empty.txt'`, `size: 0`, no
113
+ fingerprint). Every other falsy value — `null`, `undefined`, `0`, `false` — is a MISSING
114
+ value and **throws** the same contract error `createDescriptor()` throws.
94
115
 
95
116
  #### `getAsBuffer(value): Promise<Buffer>`
96
117
  Unified API to get content as Buffer. Accepts:
@@ -98,17 +119,24 @@ Unified API to get content as Buffer. Accepts:
98
119
  - Storage reference (`minio://...`) → downloads and returns Buffer
99
120
  - Content Descriptor → extracts content as Buffer
100
121
 
122
+ A raw `Buffer` is **not** accepted here — this method reads content, it does not wrap it.
123
+
101
124
  #### `getAsString(value): Promise<string>`
102
125
  Unified API to get content as string. Works with string, reference, or Descriptor.
103
126
 
104
127
  #### `getMetadata(value): Object`
105
- Get metadata (filename, content_type, size, fingerprint) from any value type.
128
+ Get metadata (filename, content_type, size, fingerprint) from a string or a Descriptor —
129
+ **exactly** the values `getAsBuffer()` accepts, so the two never disagree about what a
130
+ value is. Throws on anything else.
106
131
 
107
132
  #### `createDescriptor(content, options): Promise<Object>`
108
- Create Content Descriptor from raw content. Automatically decides inline vs file storage.
133
+ Create Content Descriptor from raw content a **string or a `Buffer`**.
134
+ Automatically decides inline vs file storage; a Buffer is always stored as a file.
109
135
 
110
136
  #### `normalizeToDescriptor(value, options): Promise<Object>`
111
- Normalize any value (string, reference, Buffer) to Content Descriptor.
137
+ Normalize a string, a reference or a Descriptor to a Content Descriptor.
138
+ A raw `Buffer` is **not** accepted (it carries no filename/content-type context) —
139
+ pass it to `createDescriptor()` or `store()` instead.
112
140
 
113
141
  #### `createDescriptorFromFile(tempPath, options): Promise<Object>`
114
142
  Create Content Descriptor from a temp file. **Used by ApiMapper for file outputs.**
@@ -176,7 +204,7 @@ Stores large content fields as Descriptors (returns Descriptors, not plain strin
176
204
  ### Usage Example
177
205
 
178
206
  ```javascript
179
- const resolver = new ContentResolver();
207
+ const resolver = new ContentResolver({ logger }); // logger is required
180
208
 
181
209
  // Work with attachments - unified API
182
210
  async function processAttachment(attachment) {
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@onlineapps/content-resolver",
3
- "version": "1.1.16",
3
+ "version": "2.0.0",
4
4
  "description": "Automatic conversion between text content and storage references with Content Descriptor pattern",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
- "test": "jest --passWithNoTests"
7
+ "test": "npm run test:unit && npm run test:integration",
8
+ "test:unit": "jest",
9
+ "test:integration": "jest --config=jest.integration.config.js"
8
10
  },
9
11
  "keywords": [
10
12
  "content",
@@ -16,11 +18,10 @@
16
18
  "author": "OnlineApps",
17
19
  "license": "ISC",
18
20
  "dependencies": {
19
- "@onlineapps/conn-base-storage": "1.0.9",
20
- "@onlineapps/runtime-config": "1.0.2"
21
+ "@onlineapps/conn-base-storage": "2.0.0",
22
+ "@onlineapps/runtime-config": "1.0.3"
21
23
  },
22
- "peerDependencies": {
23
- "@onlineapps/conn-base-storage": "^1.0.0"
24
- },
25
- "devDependencies": {}
24
+ "devDependencies": {
25
+ "jest": "^29.7.0"
26
+ }
26
27
  }
package/src/index.js CHANGED
@@ -23,6 +23,16 @@ const INTERNAL_REF_PATTERN = /^internal:\/\/storage\/(.+)$/;
23
23
 
24
24
  /**
25
25
  * Check if a value is a Content Descriptor
26
+ *
27
+ * A descriptor is recognised by the explicit `_descriptor` flag OR by its `type`
28
+ * alone. The second form is part of the contract, not a backward-compatibility
29
+ * shim: `emailer` treats an externally supplied `{ type: 'inline' | 'file' }`
30
+ * payload as canonical and forwards it to the resolver WITHOUT stamping
31
+ * `_descriptor` (api_biz/emailer/src/services/email.service.js:332 → :431), and
32
+ * its operation schema declares `attachments[].value` as an untyped object
33
+ * (api_biz/emailer/config/service/operations.json), so such payloads arrive from
34
+ * outside this codebase. Dropping type-only recognition breaks that consumer.
35
+ *
26
36
  * @param {*} value - Value to check
27
37
  * @returns {boolean} True if value is a Descriptor object
28
38
  */
@@ -30,10 +40,73 @@ function isDescriptor(value) {
30
40
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
31
41
  return false;
32
42
  }
33
- // Check explicit identifier first, then fallback to type check for backward compatibility
34
43
  return value._descriptor === true || (value.type === 'inline' || value.type === 'file');
35
44
  }
36
45
 
46
+ /**
47
+ * Name a value's kind for an error message — `typeof` alone cannot tell null,
48
+ * an array and an object apart, and those are the three shapes that actually
49
+ * reach the resolver by mistake.
50
+ * @param {*} value
51
+ * @returns {string}
52
+ */
53
+ function describeValue(value) {
54
+ if (value === null) return 'null';
55
+ if (Array.isArray(value)) return 'array';
56
+ return typeof value;
57
+ }
58
+
59
+ /**
60
+ * The input contract, written down ONCE.
61
+ *
62
+ * Every public method that takes a content value takes one of these sets and
63
+ * throws on anything else. Before 2026-08-28 four of them silently coerced with
64
+ * `String(value)` instead, so the same object was a hard error in
65
+ * `getAsBuffer()` and the literal text `[object Object]` two lines later in
66
+ * `getMetadata()` (api_biz/emailer/src/services/email.service.js:431 → :434).
67
+ *
68
+ * @see ../../../../.claude/rules/architecture-principles.md §3 (No Fallbacks), §4 (Fail-Fast)
69
+ */
70
+ const ACCEPTS = {
71
+ /** A string (inline content or a reference). Used where the return type is a string. */
72
+ reference:
73
+ `a string (inline content or a minio://<bucket>/<path> / internal://storage/<path> reference)`,
74
+
75
+ /** A string or a Content Descriptor — the set `getAsBuffer()` established. */
76
+ contentValue:
77
+ `a string (inline content or a minio://<bucket>/<path> / internal://storage/<path> reference) ` +
78
+ `or a Content Descriptor ({ _descriptor: true, type: 'inline' | 'file' })`,
79
+
80
+ /** Raw bytes on the way IN to storage: a string or a Buffer. */
81
+ rawContent: 'a string or a Buffer'
82
+ };
83
+
84
+ /** The actionable half of the message, per `architecture-principles.md` §5. */
85
+ const FIXES = {
86
+ wrap: 'Fix: wrap raw data with store() or createDescriptor() before passing it.',
87
+ serialize: 'Fix: serialize the value yourself (e.g. JSON.stringify) or pass a Buffer.',
88
+ readDescriptor:
89
+ 'Fix: read a Content Descriptor with getAsString() instead, or pass the string itself.'
90
+ };
91
+
92
+ /**
93
+ * Build the one rejection message this package uses — one owner, not one copy
94
+ * per method.
95
+ *
96
+ * @param {string} method - The public method that received the value.
97
+ * @param {string} accepted - One of `ACCEPTS`.
98
+ * @param {*} value - The rejected value.
99
+ * @param {string} fix - One of `FIXES`.
100
+ * @returns {Error}
101
+ */
102
+ function unsupportedValueError(method, accepted, value, fix) {
103
+ return new Error(
104
+ `[ContentResolver] Unsupported content value - ${method}() accepts ${accepted}, got ` +
105
+ `${describeValue(value)}. ` +
106
+ `${fix}`
107
+ );
108
+ }
109
+
37
110
  /**
38
111
  * Get content type from filename or content
39
112
  * @param {string} filename - Filename with extension
@@ -155,12 +228,20 @@ class ContentResolver {
155
228
 
156
229
  /**
157
230
  * Resolve a value - if it's a reference, download content; if it's text, return as-is
231
+ *
232
+ * Accepts a string and nothing else. The method promises to RETURN a string,
233
+ * so a non-string input has no correct answer; handing it back unchanged made
234
+ * that promise false and pushed the type error into whichever caller did
235
+ * string work on the result next. Both real call sites already reject a
236
+ * non-string themselves (api_biz/pdfgen/src/handlers/pdf.js:221 and :297).
237
+ *
158
238
  * @param {string} value - Text content or storage reference
159
239
  * @returns {Promise<string>} Resolved text content
240
+ * @throws {Error} If the value is not a string
160
241
  */
161
242
  async resolve(value) {
162
- if (!value || typeof value !== 'string') {
163
- return value;
243
+ if (typeof value !== 'string') {
244
+ throw unsupportedValueError('resolve', ACCEPTS.reference, value, FIXES.readDescriptor);
164
245
  }
165
246
 
166
247
  // Check if it's a reference
@@ -168,12 +249,17 @@ class ContentResolver {
168
249
  return value; // Already text content
169
250
  }
170
251
 
171
- // Parse reference
252
+ // Parse reference.
253
+ //
254
+ // No null check: `isReference()` above and `parseReference()` read the SAME
255
+ // two patterns, neither with the `g` flag, so a value that passed the first
256
+ // cannot fail the second. The `if (!parsed)` branch that used to sit here
257
+ // warned and returned the raw reference as if it were content — a fallback
258
+ // that could never fire, and would have handed back a URI instead of a
259
+ // document if it had (`architecture-principles.md` §3). The invariant is
260
+ // asserted in tests/unit/content-resolver.test.js
261
+ // ('invariant — everything isReference() accepts, parseReference() parses').
172
262
  const parsed = parseReference(value);
173
- if (!parsed) {
174
- this.logger.warn(`[ContentResolver] Invalid reference format: ${value}`);
175
- return value;
176
- }
177
263
 
178
264
  // Download content
179
265
  try {
@@ -206,7 +292,14 @@ class ContentResolver {
206
292
  * @returns {Promise<Object>} Content Descriptor
207
293
  */
208
294
  async store(content, context = {}, filename = null, content_type = null) {
209
- if (!content) {
295
+ // The EMPTY STRING is content: the caller said "store nothing", and gets the
296
+ // documented empty inline descriptor for it. Everything else falsy — `null`,
297
+ // `undefined`, `0`, `false`, `NaN` — is a MISSING value, i.e. a defect at the
298
+ // call site, and falls through to the one input contract this package owns
299
+ // (`createDescriptor()` / `ACCEPTS.rawContent`). The old `if (!content)` test
300
+ // could not tell the two apart and answered a mistake with a valid-looking
301
+ // descriptor (`architecture-principles.md` §3, §4).
302
+ if (content === '') {
210
303
  return {
211
304
  _descriptor: true,
212
305
  type: 'inline',
@@ -314,30 +407,63 @@ class ContentResolver {
314
407
 
315
408
  /**
316
409
  * Get content as Buffer - unified API for string, reference, or Descriptor
410
+ *
411
+ * Accepts exactly two input types, and nothing else:
412
+ * - a string — either inline content or a storage reference. This is an
413
+ * input type, not a leniency path: `api_monitoring` passes a bare
414
+ * `minio://…` string (api/infra/api_monitoring/src/consumer/index.js:2815).
415
+ * - a Content Descriptor — stamped or type-only (see `isDescriptor`).
416
+ *
417
+ * Anything else throws. It used to be coerced with `Buffer.from(String(value))`,
418
+ * which turned an object into the literal bytes `[object Object]` and shipped
419
+ * them as an email attachment or an HTTP download body — a silent fallback
420
+ * banned by `architecture-principles.md` §3.
421
+ *
317
422
  * @param {string|Object} value - String, reference, or Content Descriptor
318
423
  * @returns {Promise<Buffer>} Content as Buffer
424
+ * @throws {Error} If the value is not a string or a well-formed Descriptor
319
425
  */
320
426
  async getAsBuffer(value) {
321
- // Plain string (backward compatibility)
322
427
  if (typeof value === 'string') {
323
428
  if (isReference(value)) {
324
429
  return await this.downloadAsBuffer(value);
325
430
  }
326
431
  return Buffer.from(value, 'utf-8');
327
432
  }
328
-
329
- // Content Descriptor
433
+
330
434
  if (isDescriptor(value)) {
331
435
  if (value.type === 'file') {
436
+ if (typeof value.storage_ref !== 'string' || value.storage_ref.length === 0) {
437
+ throw new Error(
438
+ `[ContentResolver] File descriptor without storage_ref - a descriptor of type 'file' requires ` +
439
+ `a non-empty storage_ref string (minio://<bucket>/<path> or internal://storage/<path>), got ` +
440
+ `${describeValue(value.storage_ref)}. ` +
441
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
442
+ );
443
+ }
332
444
  return await this.downloadAsBuffer(value.storage_ref);
333
445
  }
334
- // type === 'inline'
335
- const encoding = value.encoding || 'utf-8';
336
- return Buffer.from(value.content, encoding);
446
+
447
+ if (value.type === 'inline') {
448
+ if (typeof value.content !== 'string') {
449
+ throw new Error(
450
+ `[ContentResolver] Inline descriptor without content - a descriptor of type 'inline' requires ` +
451
+ `a content string, got ${describeValue(value.content)}. ` +
452
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
453
+ );
454
+ }
455
+ const encoding = value.encoding || 'utf-8';
456
+ return Buffer.from(value.content, encoding);
457
+ }
458
+
459
+ throw new Error(
460
+ `[ContentResolver] Descriptor with unsupported type - expected type 'inline' or 'file', got ` +
461
+ `${describeValue(value.type)}. ` +
462
+ `Fix: build the descriptor with store() or createDescriptor() instead of by hand.`
463
+ );
337
464
  }
338
-
339
- // Fallback: try to convert to string
340
- return Buffer.from(String(value), 'utf-8');
465
+
466
+ throw unsupportedValueError('getAsBuffer', ACCEPTS.contentValue, value, FIXES.wrap);
341
467
  }
342
468
 
343
469
  /**
@@ -352,8 +478,16 @@ class ContentResolver {
352
478
 
353
479
  /**
354
480
  * Get metadata from value - unified API
481
+ *
482
+ * Accepts exactly what `getAsBuffer()` accepts, because it is called on the
483
+ * SAME value one line later (api_biz/emailer/src/services/email.service.js:431
484
+ * → :434). While this method coerced with `String(value)`, the pair disagreed:
485
+ * a value `getAsBuffer()` refused was still described here as a 15-byte
486
+ * `content.txt` — the metadata of the text `[object Object]`.
487
+ *
355
488
  * @param {string|Object} value - String, reference, or Content Descriptor
356
489
  * @returns {Object} Metadata object with filename, content_type, size, fingerprint
490
+ * @throws {Error} If the value is not a string or a Descriptor
357
491
  */
358
492
  getMetadata(value) {
359
493
  // Plain string
@@ -375,13 +509,7 @@ class ContentResolver {
375
509
  };
376
510
  }
377
511
 
378
- // Fallback
379
- const str = String(value);
380
- return {
381
- filename: 'content.txt',
382
- content_type: 'text/plain',
383
- size: Buffer.byteLength(str, 'utf-8')
384
- };
512
+ throw unsupportedValueError('getMetadata', ACCEPTS.contentValue, value, FIXES.wrap);
385
513
  }
386
514
 
387
515
  /**
@@ -393,11 +521,17 @@ class ContentResolver {
393
521
  * @param {Object} [options.context] - Workflow context
394
522
  * @param {boolean} [options.forceFile=false] - Force storage as file even if small
395
523
  * @returns {Promise<Object>} Content Descriptor
524
+ * @throws {Error} If the content is neither a string nor a Buffer
396
525
  */
397
526
  async createDescriptor(content, options = {}) {
398
527
  const { filename, content_type, context = {}, forceFile = false } = options;
399
-
400
- // Convert Buffer to string if needed
528
+
529
+ // A string or a Buffer, and nothing else. Buffer is a MEASURED input type,
530
+ // not a leniency path: `storeOutput()` forwards Buffers by name (:289) and
531
+ // `normalizeToDescriptor()` hands over the bytes it just downloaded (:577).
532
+ // Anything else used to be coerced with `String(content)`, which stored the
533
+ // eight bytes of `[object Object]` under a sha256 of themselves and reported
534
+ // it back as a valid descriptor.
401
535
  let contentString;
402
536
  let isBinary = false;
403
537
  if (Buffer.isBuffer(content)) {
@@ -406,7 +540,7 @@ class ContentResolver {
406
540
  } else if (typeof content === 'string') {
407
541
  contentString = content;
408
542
  } else {
409
- contentString = String(content);
543
+ throw unsupportedValueError('createDescriptor', ACCEPTS.rawContent, content, FIXES.serialize);
410
544
  }
411
545
 
412
546
  // For binary content, always store as file
@@ -492,9 +626,18 @@ class ContentResolver {
492
626
 
493
627
  /**
494
628
  * Normalize value to Content Descriptor if needed
629
+ *
630
+ * Accepts the same set as `getAsBuffer()`: a string or a Descriptor. A Buffer
631
+ * is NOT in that set even though `createDescriptor()` takes one — this method
632
+ * only RECOGNISES a value, and a bare Buffer carries none of the
633
+ * filename/content-type context `createDescriptor()` takes as arguments. It
634
+ * used to be swallowed by `String(value)`, which quietly decoded the bytes
635
+ * into an inline text descriptor via `Buffer.prototype.toString`.
636
+ *
495
637
  * @param {string|Object} value - String, reference, or Descriptor
496
638
  * @param {Object} options - Options for descriptor creation
497
639
  * @returns {Promise<Object>} Content Descriptor
640
+ * @throws {Error} If the value is not a string or a Descriptor
498
641
  */
499
642
  async normalizeToDescriptor(value, options = {}) {
500
643
  // Already a Descriptor - ensure it has _descriptor flag
@@ -518,9 +661,8 @@ class ContentResolver {
518
661
  // Plain string - create descriptor
519
662
  return await this.createDescriptor(value, options);
520
663
  }
521
-
522
- // Fallback
523
- return await this.createDescriptor(String(value), options);
664
+
665
+ throw unsupportedValueError('normalizeToDescriptor', ACCEPTS.contentValue, value, FIXES.wrap);
524
666
  }
525
667
 
526
668
  /**