eip-cloud-services 1.4.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,11 @@
3
3
 
4
4
  All notable changes to this project will be documented in this file.
5
5
 
6
+ ## Unreleased
7
+
8
+ ### Added
9
+ - Added staging-safe OnDemand Content Ingest, Content Discovery, and Azure Blob upload clients with injectable fetch, timeouts, production-host guards, and signed-query redaction.
10
+
6
11
  ## [1.2.5] - 2026-03-02
7
12
 
8
13
  ### Fixed
package/README.md CHANGED
@@ -82,6 +82,51 @@ module.exports = {
82
82
  5. [AWS Lambda Module](#aws-lambda-module)
83
83
  6. [AWS SQS Module](#aws-sqs-module)
84
84
  7. [AWS Secrets Manager Module](#aws-secrets-manager-module)
85
+ 8. [OnDemand Module](#ondemand-module)
86
+
87
+ # OnDemand Module
88
+
89
+ ## Overview
90
+
91
+ The OnDemand module provides dependency-free clients for Axel Springer's Content Ingest and Content Discovery APIs, plus streaming uploads to their Azure Blob Storage target. Staging endpoints are the defaults. Production Content Discovery/Ingest hosts are rejected unless a future caller explicitly supplies `allowProduction: true`.
92
+
93
+ Secrets are caller-provided and are never stored by this package. Provider errors and returned metadata are sanitized so signed Azure query parameters are not included in thrown errors.
94
+
95
+ ## Usage
96
+
97
+ ```javascript
98
+ const { ondemand } = require ( 'eip-cloud-services' );
99
+
100
+ const ingest = ondemand.createIngestClient ( {
101
+ apiKey: process.env.ONDEMAND_INGEST_API_KEY
102
+ } );
103
+
104
+ const discovery = ondemand.createDiscoveryClient ( {
105
+ apiKey: process.env.ONDEMAND_DISCOVERY_API_KEY
106
+ } );
107
+
108
+ const assets = await discovery.search ( {
109
+ q: 'climate change',
110
+ assetType: 'video',
111
+ suppliers: [ 'https://cv.content-discovery.video/party/telegraph' ]
112
+ } );
113
+ ```
114
+
115
+ Available Ingest operations are `createAsset`, `getAsset`, `updateAsset`, `startIngest`, `getIngest`, `createRendition`, and `updateRendition`. Discovery operations are `getVersion`, `getAsset`, `getAssets`, `search`, `searchByExpression`, and `getMasterDownload`.
116
+
117
+ Use `uploadAzureBlob` with a clean blob URL and a separate SAS token. Pass the clean URL—not the signed upload URL—to `startIngest`:
118
+
119
+ ```javascript
120
+ await ondemand.uploadAzureBlob ( {
121
+ blobUrl: cleanBlobUrl,
122
+ sasToken: process.env.ONDEMAND_AZURE_SAS_TOKEN,
123
+ body: videoStream,
124
+ contentLength: videoSize,
125
+ contentType: 'video/mp4'
126
+ } );
127
+
128
+ await ingest.startIngest ( assetId, { url: cleanBlobUrl } );
129
+ ```
85
130
 
86
131
  # AWS Secrets Manager Module
87
132
 
package/index.js CHANGED
@@ -7,3 +7,4 @@ exports.sqs = require ( './src/sqs' );
7
7
  exports.secrets = require ( './src/secrets' );
8
8
  exports.mysql = require ( './src/mysql' );
9
9
  exports.mysql8 = require ( './src/mysql8' );
10
+ exports.ondemand = require ( './src/ondemand' );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eip-cloud-services",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Houses a collection of helpers for connecting with Cloud services.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,339 @@
1
+ const DEFAULT_INGEST_BASE_URL = 'https://ingest.staging.content-discovery.video/v1';
2
+ const DEFAULT_DISCOVERY_BASE_URL = 'https://api.staging.content-discovery.video/v1';
3
+ const PRODUCTION_HOSTS = new Set ( [
4
+ 'ingest.content-discovery.video',
5
+ 'api.content-discovery.video'
6
+ ] );
7
+ const DEFAULT_TIMEOUT_MS = 15000;
8
+
9
+ class OnDemandRequestError extends Error {
10
+ constructor ( message, { status = null, details = null, cause = null } = {} ) {
11
+ super ( message, cause ? { cause } : undefined );
12
+ this.name = 'OnDemandRequestError';
13
+ this.status = status;
14
+ this.details = details;
15
+ }
16
+ }
17
+
18
+ const normalizeText = value => typeof value === 'string' ? value.trim () : '';
19
+
20
+ const sanitizeUrl = value => {
21
+ try {
22
+ const url = new URL ( value );
23
+ url.search = '';
24
+ url.hash = '';
25
+ return url.toString ();
26
+ }
27
+ catch ( error ) {
28
+ return value;
29
+ }
30
+ };
31
+
32
+ const sanitizeText = value => normalizeText ( value )
33
+ .replace ( /https?:\/\/[^\s"'<>]+/gi, match => sanitizeUrl ( match ) )
34
+ .replace ( /[?&](?:sig|sv|se|sp|spr|srt|ss|st)=[^&\s"']*/gi, '' );
35
+
36
+ const sanitizeDetails = value => {
37
+ if ( typeof value === 'string' ) return sanitizeText ( value );
38
+ if ( Array.isArray ( value ) ) return value.map ( sanitizeDetails );
39
+ if ( !value || typeof value !== 'object' ) return value;
40
+
41
+ return Object.fromEntries ( Object.entries ( value ).map ( ( [ key, item ] ) => [ key, sanitizeDetails ( item ) ] ) );
42
+ };
43
+
44
+ const resolveFetch = fetchImpl => {
45
+ const resolved = fetchImpl || global.fetch;
46
+ if ( typeof resolved !== 'function' ) {
47
+ throw new Error ( 'OnDemand requires a fetch implementation' );
48
+ }
49
+
50
+ return resolved;
51
+ };
52
+
53
+ const normalizeBaseUrl = ( value, defaultValue, allowProduction ) => {
54
+ const normalized = normalizeText ( value ) || defaultValue;
55
+ const url = new URL ( normalized );
56
+
57
+ if ( url.protocol !== 'https:' ) {
58
+ throw new Error ( 'OnDemand endpoint must use HTTPS' );
59
+ }
60
+ if ( PRODUCTION_HOSTS.has ( url.hostname ) && !allowProduction ) {
61
+ throw new Error ( 'OnDemand production endpoint is disabled' );
62
+ }
63
+
64
+ url.search = '';
65
+ url.hash = '';
66
+ return url.toString ().replace ( /\/$/, '' );
67
+ };
68
+
69
+ const requireApiKey = value => {
70
+ const apiKey = normalizeText ( value );
71
+ if ( !apiKey ) throw new Error ( 'OnDemand API key is required' );
72
+ return apiKey;
73
+ };
74
+
75
+ const parseResponse = async response => {
76
+ if ( response.status === 204 ) return null;
77
+
78
+ const contentType = normalizeText ( response?.headers?.get?.( 'content-type' ) ).toLowerCase ();
79
+ if ( contentType.includes ( 'json' ) && typeof response.json === 'function' ) {
80
+ return response.json ();
81
+ }
82
+
83
+ const text = typeof response.text === 'function' ? await response.text () : '';
84
+ if ( !text ) return null;
85
+
86
+ try {
87
+ return JSON.parse ( text );
88
+ }
89
+ catch ( error ) {
90
+ return text;
91
+ }
92
+ };
93
+
94
+ const buildErrorMessage = ( status, details ) => {
95
+ const detail = typeof details === 'object' && details
96
+ ? details.detail || details.title || details.message
97
+ : details;
98
+ const suffix = sanitizeText ( detail );
99
+ return `OnDemand request failed (${status})${suffix ? `: ${suffix}` : ''}`;
100
+ };
101
+
102
+ const executeRequest = async ( {
103
+ fetchImpl,
104
+ url,
105
+ options,
106
+ timeoutMs = DEFAULT_TIMEOUT_MS,
107
+ includeResponse = false
108
+ } ) => {
109
+ const controller = new AbortController ();
110
+ const timer = setTimeout ( () => controller.abort (), timeoutMs );
111
+ timer.unref?.();
112
+
113
+ try {
114
+ const response = await fetchImpl ( url, {
115
+ ...options,
116
+ signal: controller.signal
117
+ } );
118
+ const payload = await parseResponse ( response );
119
+
120
+ if ( !response.ok ) {
121
+ const sanitizedDetails = sanitizeDetails ( payload );
122
+ throw new OnDemandRequestError ( buildErrorMessage ( response.status, sanitizedDetails ), {
123
+ status: response.status,
124
+ details: sanitizedDetails
125
+ } );
126
+ }
127
+
128
+ return includeResponse ? { payload, response } : payload;
129
+ }
130
+ catch ( error ) {
131
+ if ( error instanceof OnDemandRequestError ) throw error;
132
+
133
+ const timedOut = controller.signal.aborted || error?.name === 'AbortError';
134
+ throw new OnDemandRequestError ( timedOut ? 'OnDemand request timed out' : 'OnDemand request failed', {
135
+ cause: error
136
+ } );
137
+ }
138
+ finally {
139
+ clearTimeout ( timer );
140
+ }
141
+ };
142
+
143
+ const createApiClient = ( {
144
+ apiKey,
145
+ baseUrl,
146
+ defaultBaseUrl,
147
+ allowProduction = false,
148
+ fetch: fetchOption,
149
+ timeoutMs = DEFAULT_TIMEOUT_MS
150
+ } ) => {
151
+ const resolvedApiKey = requireApiKey ( apiKey );
152
+ const resolvedBaseUrl = normalizeBaseUrl ( baseUrl, defaultBaseUrl, allowProduction );
153
+ const fetchImpl = resolveFetch ( fetchOption );
154
+
155
+ return async ( path, { method = 'GET', body, headers = {} } = {} ) => executeRequest ( {
156
+ fetchImpl,
157
+ url: `${resolvedBaseUrl}${path}`,
158
+ timeoutMs,
159
+ options: {
160
+ method,
161
+ headers: {
162
+ ...( body === undefined ? {} : { 'Content-Type': 'application/json' } ),
163
+ 'X-API-KEY': resolvedApiKey,
164
+ ...headers
165
+ },
166
+ ...( body === undefined ? {} : { body: JSON.stringify ( body ) } )
167
+ }
168
+ } );
169
+ };
170
+
171
+ const encodePathSegment = ( value, label ) => {
172
+ const normalized = normalizeText ( value );
173
+ if ( !normalized ) throw new Error ( `${label} is required` );
174
+ return encodeURIComponent ( normalized );
175
+ };
176
+
177
+ const assertCleanIngestUrl = value => {
178
+ const normalized = normalizeText ( value );
179
+ if ( !normalized ) throw new Error ( 'OnDemand ingest URL is required' );
180
+
181
+ const url = new URL ( normalized );
182
+ if ( url.search ) throw new Error ( 'OnDemand ingest URL must not contain query parameters' );
183
+ return url.toString ();
184
+ };
185
+
186
+ /**
187
+ * Create a Content Ingest API client. Staging is the default and production hosts
188
+ * are rejected unless a future caller explicitly opts in with allowProduction.
189
+ *
190
+ * @param {object} options
191
+ * @param {string} options.apiKey Content Ingest X-API-KEY value.
192
+ * @param {string} [options.baseUrl] Content Ingest base URL.
193
+ * @param {boolean} [options.allowProduction=false] Explicit production safety override.
194
+ * @param {Function} [options.fetch] Injectable fetch implementation.
195
+ * @param {number} [options.timeoutMs=15000] Per-request timeout.
196
+ * @returns {object} Content Ingest operations.
197
+ */
198
+ exports.createIngestClient = options => {
199
+ const request = createApiClient ( {
200
+ ...options,
201
+ defaultBaseUrl: DEFAULT_INGEST_BASE_URL
202
+ } );
203
+
204
+ return {
205
+ createAsset: input => request ( '/assets', { method: 'POST', body: input } ),
206
+ getAsset: assetId => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}` ),
207
+ updateAsset: ( assetId, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}`, {
208
+ method: 'PATCH',
209
+ body: input
210
+ } ),
211
+ startIngest: ( assetId, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/ingest`, {
212
+ method: 'POST',
213
+ body: {
214
+ ...input,
215
+ url: assertCleanIngestUrl ( input?.url )
216
+ }
217
+ } ),
218
+ getIngest: ingestId => request ( `/ingests/${encodePathSegment ( ingestId, 'OnDemand ingest id' )}` ),
219
+ createRendition: ( assetId, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/rendition`, {
220
+ method: 'POST',
221
+ body: input
222
+ } ),
223
+ updateRendition: ( assetId, rendition, input ) => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/rendition/${encodePathSegment ( rendition, 'OnDemand rendition' )}`, {
224
+ method: 'PUT',
225
+ body: input
226
+ } )
227
+ };
228
+ };
229
+
230
+ const buildQueryString = params => {
231
+ const searchParams = new URLSearchParams ();
232
+
233
+ Object.entries ( params || {} ).forEach ( ( [ key, value ] ) => {
234
+ if ( value === undefined || value === null || value === '' ) return;
235
+ searchParams.set ( key, Array.isArray ( value ) ? value.join ( ',' ) : String ( value ) );
236
+ } );
237
+
238
+ const query = searchParams.toString ();
239
+ return query ? `?${query}` : '';
240
+ };
241
+
242
+ /**
243
+ * Create a Content Discovery API client. Staging is the default and production
244
+ * hosts are rejected unless a future caller explicitly opts in.
245
+ *
246
+ * @param {object} options
247
+ * @param {string} options.apiKey Content Discovery X-API-KEY value.
248
+ * @param {string} [options.baseUrl] Content Discovery base URL.
249
+ * @param {boolean} [options.allowProduction=false] Explicit production safety override.
250
+ * @param {Function} [options.fetch] Injectable fetch implementation.
251
+ * @param {number} [options.timeoutMs=15000] Per-request timeout.
252
+ * @returns {object} Content Discovery operations.
253
+ */
254
+ exports.createDiscoveryClient = options => {
255
+ const request = createApiClient ( {
256
+ ...options,
257
+ defaultBaseUrl: DEFAULT_DISCOVERY_BASE_URL
258
+ } );
259
+
260
+ return {
261
+ getVersion: () => request ( '/' ),
262
+ getAsset: assetId => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}` ),
263
+ getAssets: assetIds => request ( `/assets${buildQueryString ( {
264
+ id: ( Array.isArray ( assetIds ) ? assetIds : [ assetIds ] ).join ( ',' )
265
+ } )}` ),
266
+ search: params => request ( `/search${buildQueryString ( params )}` ),
267
+ searchByExpression: expression => request ( '/search', { method: 'POST', body: expression } ),
268
+ getMasterDownload: assetId => request ( `/assets/${encodePathSegment ( assetId, 'OnDemand asset id' )}/renditions/master/download` )
269
+ };
270
+ };
271
+
272
+ const buildAzureUploadUrl = ( blobUrl, sasToken ) => {
273
+ const url = new URL ( normalizeText ( blobUrl ) );
274
+ if ( url.protocol !== 'https:' || !url.hostname.endsWith ( '.blob.core.windows.net' ) ) {
275
+ throw new Error ( 'OnDemand Azure blob URL must be an HTTPS Azure Blob Storage URL' );
276
+ }
277
+ if ( url.search ) {
278
+ throw new Error ( 'OnDemand Azure blob URL must not already contain query parameters' );
279
+ }
280
+
281
+ const normalizedSas = normalizeText ( sasToken ).replace ( /^\?/, '' );
282
+ if ( !normalizedSas ) throw new Error ( 'OnDemand Azure SAS token is required' );
283
+ url.search = normalizedSas;
284
+ return url.toString ();
285
+ };
286
+
287
+ /**
288
+ * Stream a source video to an Azure block blob using a SAS token. The returned
289
+ * result never includes the signed request URL.
290
+ *
291
+ * @param {object} options
292
+ * @param {string} options.blobUrl Clean Azure blob URL without a query string.
293
+ * @param {string} options.sasToken Azure SAS query string.
294
+ * @param {import('stream').Readable|Buffer|Uint8Array|Blob} options.body Upload body.
295
+ * @param {number} options.contentLength Exact byte length.
296
+ * @param {string} [options.contentType='video/mp4'] MIME type.
297
+ * @param {Function} [options.fetch] Injectable fetch implementation.
298
+ * @param {number} [options.timeoutMs=300000] Upload timeout.
299
+ * @returns {Promise<{status: number, etag: string|null}>} Sanitized upload result.
300
+ */
301
+ exports.uploadAzureBlob = async ( {
302
+ blobUrl,
303
+ sasToken,
304
+ body,
305
+ contentLength,
306
+ contentType = 'video/mp4',
307
+ fetch: fetchOption,
308
+ timeoutMs = 300000
309
+ } ) => {
310
+ if ( !body ) throw new Error ( 'OnDemand Azure upload body is required' );
311
+ if ( !Number.isFinite ( Number ( contentLength ) ) || Number ( contentLength ) < 0 ) {
312
+ throw new Error ( 'OnDemand Azure upload content length is required' );
313
+ }
314
+
315
+ const fetchImpl = resolveFetch ( fetchOption );
316
+ const { response } = await executeRequest ( {
317
+ fetchImpl,
318
+ url: buildAzureUploadUrl ( blobUrl, sasToken ),
319
+ timeoutMs,
320
+ includeResponse: true,
321
+ options: {
322
+ method: 'PUT',
323
+ headers: {
324
+ 'Content-Type': contentType,
325
+ 'Content-Length': String ( contentLength ),
326
+ 'x-ms-blob-type': 'BlockBlob'
327
+ },
328
+ body,
329
+ ...( typeof body?.pipe === 'function' ? { duplex: 'half' } : {} )
330
+ }
331
+ } );
332
+
333
+ return {
334
+ status: response.status,
335
+ etag: response.headers?.get?.( 'etag' ) || null
336
+ };
337
+ };
338
+
339
+ exports.OnDemandRequestError = OnDemandRequestError;