@onlineapps/conn-orch-registry 1.2.2 → 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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  > A lightweight client for microservice registration and heartbeat over RabbitMQ.
8
8
  > Sends the full service specification (including `operations`) in the `register`
9
- > message — see [operations-registry-contract.md §2](../../../docs/standards/operations-registry-contract.md).
9
+ > message — see [operations-registry-contract.md §2](../../../docs/biz/30-operations/registration-wire.md).
10
10
 
11
11
  ## 🚀 Features
12
12
 
@@ -120,8 +120,8 @@ See [docs/api.md](https://github.com/onlineapps/conn-orch-registry/blob/main/doc
120
120
 
121
121
  ## 🔗 Related Documentation
122
122
 
123
- - **[⭐ Complete Two-Tier Validation System](/docs/modules/validator.md)** - Full validation architecture and flow
124
- - [Registry Module Overview](/docs/modules/registry.md) - High-level registry system architecture
123
+ - **[Two-Tier Validation Architecture](/docs/architecture/validator.md)** - Full validation architecture and flow
124
+ - [Registry Architecture](/docs/architecture/registry.md) - High-level registry system architecture
125
125
  - [api_services_registry](/api/api_services_registry/README.md) - Registry service implementation
126
126
 
127
127
  ## ✅ Testing
@@ -149,6 +149,6 @@ This project is licensed under the MIT License. See [LICENSE](https://github.com
149
149
 
150
150
  ## 📚 Documentation
151
151
 
152
- - [Complete Connectors Documentation](../../../docs/modules/connector.md)
153
- - [Testing Standards](../../../docs/modules/connector.md#testing-standards)
152
+ - [Complete Connectors Documentation](/docs/architecture/connector.md)
153
+ - [Testing Standard](/docs/standards/TESTING.md)
154
154
 
@@ -31,7 +31,7 @@ Microservice Registry Backend
31
31
  > The legacy `apiDescriptionRequest` / `sendApiDescription` round-trip has
32
32
  > been removed — the full service specification (including the `operations`
33
33
  > map) travels as part of the `register` message. See
34
- > [operations-registry-contract.md §2](../../../../docs/standards/operations-registry-contract.md).
34
+ > [operations-registry-contract.md §2](../../../../docs/biz/30-operations/registration-wire.md).
35
35
 
36
36
  ## Usage
37
37
 
@@ -129,7 +129,7 @@ against the Operations Registry contract and either confirms or rejects.
129
129
 
130
130
  **Parameters:**
131
131
  - `serviceInfo.endpoints` - Array of API endpoints
132
- - `serviceInfo.operations` - Operations map (see [operations-registry-contract.md §3](../../../../docs/standards/operations-registry-contract.md))
132
+ - `serviceInfo.operations` - Operations map (see [operations-registry-contract.md §3](../../../../docs/biz/30-operations/registration-wire.md))
133
133
  - `serviceInfo.metadata` - Service metadata object
134
134
  - `serviceInfo.health` - Health check endpoint
135
135
 
@@ -9,7 +9,7 @@
9
9
  * 5. Start heartbeat loop
10
10
  * 6. Graceful shutdown on process signals
11
11
  *
12
- * See: api/docs/standards/operations-registry-contract.md §2 MQ wire format
12
+ * See: api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
13
13
  */
14
14
 
15
15
  function requireEnv(name, description) {
@@ -81,7 +81,7 @@ registryClient.on(EVENTS.ERROR, (err) => {
81
81
  * In a real service this is typically loaded from config/service/operations.json
82
82
  * plus the endpoint manifest.
83
83
  *
84
- * @see api/docs/standards/operations-registry-contract.md §3 Operation schema
84
+ * @see api/docs/biz/30-operations/registration-wire.md §3 Operation schema
85
85
  */
86
86
  async function loadServiceSpec() {
87
87
  return {
@@ -34,7 +34,7 @@ async function main() {
34
34
 
35
35
  // Register ourselves with the registry. The full spec (endpoints +
36
36
  // operations) is published as part of the register message — see
37
- // api/docs/standards/operations-registry-contract.md §2.
37
+ // api/docs/biz/30-operations/registration-wire.md §2.
38
38
  await client.register({
39
39
  endpoints: [{ path: '/hello', method: 'GET' }],
40
40
  operations: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-registry",
3
- "version": "1.2.2",
3
+ "version": "2.0.0",
4
4
  "license": "MIT",
5
5
  "description": "Connector-registry-client provides the core communication mechanism for microservices in this environment. It enables them to interact with a services_registry to receive and fulfill tasks by submitting heartbeats or their API descriptions.",
6
6
  "keywords": [
@@ -27,22 +27,19 @@
27
27
  "test:unit": "jest --testPathPattern=unit --coverage",
28
28
  "test:component": "jest --testPathPattern=component --coverage",
29
29
  "test:integration": "jest --config=jest.integration.config.js",
30
- "test:integration:required": "REQUIRE_SERVICES=true jest --config=jest.integration.config.js",
31
30
  "test:watch": "jest --watch",
32
31
  "test:coverage": "jest --coverage --coverageReporters=text-lcov html",
33
32
  "semantic-release": "semantic-release",
34
- "lint": "eslint src test examples --ext .js --cache --cache-location .eslintcache",
33
+ "lint": "eslint src tests examples --ext .js --cache --cache-location .eslintcache",
35
34
  "docs": "jsdoc2md --files src/**/*.js > API.md"
36
35
  },
37
36
  "publishConfig": {
38
37
  "access": "public"
39
38
  },
40
39
  "dependencies": {
41
- "@onlineapps/conn-base-storage": "1.0.9",
42
- "@onlineapps/mq-client-core": "1.0.83",
43
- "@onlineapps/runtime-config": "1.0.2",
40
+ "@onlineapps/mq-client-core": "2.0.0",
41
+ "@onlineapps/runtime-config": "1.0.3",
44
42
  "amqplib": "^0.10.9",
45
- "ioredis": "^5.8.0",
46
43
  "uuid": "^9.0.1"
47
44
  },
48
45
  "devDependencies": {
@@ -56,6 +53,7 @@
56
53
  "eslint-config-prettier": "^10.1.5",
57
54
  "eslint-plugin-import": "^2.30.1",
58
55
  "eslint-plugin-prettier": "^5.4.0",
56
+ "ioredis": "^5.8.0",
59
57
  "jest": "^29.6.1",
60
58
  "semantic-release": "^22.0.12"
61
59
  },
package/src/defaults.js CHANGED
@@ -13,6 +13,21 @@ module.exports = {
13
13
 
14
14
  specProtocol: 'http',
15
15
  specHost: '127.0.0.1',
16
+
17
+ /**
18
+ * How long register() waits for the registry's `registerResponse` /
19
+ * `register.confirmed` message before it fails.
20
+ *
21
+ * Owned here (module default), injectable per client via the
22
+ * `registrationTimeoutMs` constructor option and per call via
23
+ * `serviceInfo.timeout`. Never read from the environment inside the client.
24
+ *
25
+ * 30 s matches the registry's own validation timeout
26
+ * (`api/infra/api_services_registry/src/listeners/registry.listener.js` —
27
+ * `}, 30000); // 30 seconds timeout for Registry`), so a service gives the
28
+ * registry the full window it needs before declaring the round-trip dead.
29
+ */
30
+ registrationTimeoutMs: 30000,
16
31
  };
17
32
 
18
33
 
package/src/events.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Legacy `apiDescriptionRequest` / `apiDescriptionSent` events were removed —
9
9
  * the full service specification travels as part of the `register` message.
10
10
  *
11
- * @see api/docs/standards/operations-registry-contract.md §2 MQ wire format
11
+ * @see api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
12
12
  * @module @onlineapps/connector-registry-client/src/events
13
13
  */
14
14
 
@@ -19,8 +19,8 @@
19
19
  * - 'registerRejected'
20
20
  * - 'error'
21
21
  *
22
- * @see api/docs/standards/operations-registry-contract.md §2 MQ wire format
23
- * @see api/docs/standards/OPERATIONS.md
22
+ * @see api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
23
+ * @see api/docs/biz/30-operations/schema-v3.md
24
24
  *
25
25
  * @module @onlineapps/connector-registry-client/src/registryClient
26
26
  */
@@ -30,7 +30,7 @@ const QueueManager = require('./queueManager');
30
30
  const RegistryEventConsumer = require('./registryEventConsumer');
31
31
  const { v4: uuidv4 } = require('uuid');
32
32
  const queueConfig = require('@onlineapps/mq-client-core/src/config/queueConfig');
33
- const runtimeCfg = require('./config');
33
+ const DEFAULTS = require('./defaults');
34
34
 
35
35
 
36
36
  class ServiceRegistryClient extends EventEmitter {
@@ -44,14 +44,25 @@ class ServiceRegistryClient extends EventEmitter {
44
44
  * @param {string} [opts.apiQueue='api_services_queuer'] - Queue name for heartbeat and API traffic
45
45
  * @param {string} [opts.registryQueue='registry.register'] - Queue name for registry messages
46
46
  * @param {Object} [opts.validationProof=null] - Validation proof object {hash, data} (injected from outside)
47
+ * @param {number} [opts.registrationTimeoutMs] - How long register() waits for the registry response
48
+ * before rejecting. Default comes from ./defaults.js (30000 ms); never read from the environment here.
47
49
  */
48
50
  constructor({ amqpUrl, serviceName, version, specificationEndpoint = '/api/v1/specification',
49
51
  heartbeatInterval = 10000, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
50
- registryUrl = null, redis = null, storageConfig = {}, validationProof = null }) {
52
+ registryUrl = null, redis = null, storageConfig = {}, validationProof = null,
53
+ registrationTimeoutMs = DEFAULTS.registrationTimeoutMs }) {
51
54
  super();
52
55
  if (!amqpUrl || !serviceName || !version) {
53
56
  throw new Error('amqpUrl, serviceName, and version are required');
54
57
  }
58
+ if (typeof registrationTimeoutMs !== 'number' || !Number.isFinite(registrationTimeoutMs) || registrationTimeoutMs <= 0) {
59
+ throw new Error(
60
+ `[RegistryClient] ${serviceName}: Invalid registrationTimeoutMs - expected a positive number of milliseconds, `
61
+ + `got ${JSON.stringify(registrationTimeoutMs)}. Fix: pass a positive registrationTimeoutMs `
62
+ + `(module default ${DEFAULTS.registrationTimeoutMs}).`
63
+ );
64
+ }
65
+ this.registrationTimeoutMs = registrationTimeoutMs;
55
66
  this.serviceName = serviceName;
56
67
  this.version = version;
57
68
  // Spec endpoint is still configurable, but defaults are module-owned and can be overridden via runtime config.
@@ -385,8 +396,21 @@ class ServiceRegistryClient extends EventEmitter {
385
396
  throw new Error('endpoints must be an array');
386
397
  }
387
398
 
399
+ // FAIL-FAST: a client that was never initialized, or that has been closed, is not
400
+ // usable. It used to silently call queueManager.init() here, which re-opened a
401
+ // channel WITHOUT restarting the response consumer started by init() - so the
402
+ // registry answered into a queue nobody was reading and register() waited out the
403
+ // full timeout. No fallbacks (architecture-principles §3), fail fast (§4).
404
+ if (!this.queueManager.channel) {
405
+ throw new Error(
406
+ `[RegistryClient] ${this.serviceName}: Cannot register - the MQ channel is not open `
407
+ + '(client not initialized or already closed). Expected: init() completed before register(). '
408
+ + 'Fix: call init() on a fresh ServiceRegistryClient; a closed client is not reusable.'
409
+ );
410
+ }
411
+
388
412
  const msgId = uuidv4();
389
- // @see api/docs/standards/operations-registry-contract.md §2 MQ wire format
413
+ // @see api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
390
414
  const msg = {
391
415
  id: msgId,
392
416
  type: 'register',
@@ -422,8 +446,9 @@ class ServiceRegistryClient extends EventEmitter {
422
446
  console.log(`[RegistryClient] ${this.serviceName}: ⚠️ NO validation proof available - Registry will perform Tier 2 validation`);
423
447
  }
424
448
 
425
- // Create promise to wait for registration response
426
- const timeout = serviceInfo.timeout || 30000; // 30 seconds default
449
+ // Create promise to wait for registration response.
450
+ // Timeout is injected (constructor) or overridden per call - never read from env here.
451
+ const timeout = serviceInfo.timeout || this.registrationTimeoutMs;
427
452
  const responsePromise = new Promise((resolve, reject) => {
428
453
  // Store resolver for this registration request
429
454
  this.pendingRegistrations = this.pendingRegistrations || new Map();
@@ -434,7 +459,12 @@ class ServiceRegistryClient extends EventEmitter {
434
459
  if (this.pendingRegistrations.has(msgId)) {
435
460
  this.pendingRegistrations.delete(msgId);
436
461
  this.pendingTimeouts.delete(msgId);
437
- reject(new Error(`Registration timeout after ${timeout}ms - no response from registry`));
462
+ reject(new Error(
463
+ `[RegistryClient] ${this.serviceName}: Registration timed out - no response from the registry `
464
+ + `within ${timeout}ms. Expected: api_services_registry consumes '${this.registryQueue}' and answers `
465
+ + `on '${this.serviceRegistryQueue}'. Fix: start api_services_registry (docker compose up -d `
466
+ + 'api_services_registry) or raise registrationTimeoutMs.'
467
+ ));
438
468
  }
439
469
  }, timeout);
440
470
 
@@ -442,10 +472,10 @@ class ServiceRegistryClient extends EventEmitter {
442
472
  this.pendingTimeouts.set(msgId, timeoutId);
443
473
  });
444
474
 
445
- // Initialize queueManager if not already initialized
446
- if (!this.queueManager.channel) {
447
- await this.queueManager.init();
448
- }
475
+ // The publish path below can throw before anyone awaits responsePromise. This no-op
476
+ // handler marks the promise as handled so an abort never surfaces as an unhandled
477
+ // rejection; the real result is still awaited at the end of this method.
478
+ responsePromise.catch(() => {});
449
479
 
450
480
  // Send registration message to registry
451
481
  // CRITICAL: Use queueConfig.js to get correct parameters (TTL, max-length, etc.)
@@ -457,20 +487,21 @@ class ServiceRegistryClient extends EventEmitter {
457
487
  } catch (queueError) {
458
488
  const errorMsg = `[RegistryClient] ${this.serviceName}: Cannot register - infrastructure queue verification failed: ${queueError.message}`;
459
489
  console.error(errorMsg);
460
- throw new Error(errorMsg);
490
+ throw this._abortPendingRegistration(msgId, new Error(errorMsg));
461
491
  }
462
492
 
463
493
  console.log(`[RegistryClient] ${this.serviceName}: Sending registration message to queue: ${this.registryQueue}`);
464
494
  console.log(`[RegistryClient] ${this.serviceName}: Message type: ${msg.type}, serviceName: ${msg.serviceName}, version: ${msg.version}`);
465
-
466
- // Send message synchronously (sendToQueue is synchronous, returns boolean)
467
- // But check channel state first to fail fast
468
- if (!this.queueManager.channel) {
469
- throw new Error(`[RegistryClient] ${this.serviceName}: Cannot publish - channel not initialized. Ensure MQ connection is established.`);
470
- }
471
-
472
- if (this.queueManager.channel.closed) {
473
- throw new Error(`[RegistryClient] ${this.serviceName}: Cannot publish - channel is closed. MQ connection may be lost.`);
495
+
496
+ // Send message synchronously (sendToQueue is synchronous, returns boolean).
497
+ // The channel existed when register() started; it can still have been closed while
498
+ // the infrastructure-queue check awaited above.
499
+ if (!this.queueManager.channel || this.queueManager.channel.closed) {
500
+ throw this._abortPendingRegistration(msgId, new Error(
501
+ `[RegistryClient] ${this.serviceName}: Cannot publish - the MQ channel closed before the registration `
502
+ + 'message was sent. Expected: an open channel for the whole register() call. '
503
+ + 'Fix: re-create the client (init() + register()) once the MQ connection is back.'
504
+ ));
474
505
  }
475
506
 
476
507
  try {
@@ -486,31 +517,49 @@ class ServiceRegistryClient extends EventEmitter {
486
517
  } catch (sendError) {
487
518
  const errorMsg = `[RegistryClient] ${this.serviceName}: Failed to send registration message: ${sendError.message}`;
488
519
  console.error(errorMsg);
489
- throw new Error(errorMsg);
520
+ throw this._abortPendingRegistration(msgId, new Error(errorMsg));
490
521
  }
491
522
 
492
523
  this.emit('registerSent', msg);
493
524
 
494
- // Wait for registration response from registry
495
- try {
496
- console.log(`[RegistryClient] ${this.serviceName}: [AWAIT] Waiting for registration response promise...`);
497
- const response = await responsePromise;
498
- console.log(`[RegistryClient] ${this.serviceName}: [AWAIT] Promise resolved, response received:`, {
499
- hasResponse: !!response,
500
- responseType: typeof response,
501
- responseKeys: response ? Object.keys(response) : [],
502
- hasSuccess: !!response?.success,
503
- hasCertificate: !!response?.certificate
504
- });
505
- return response;
506
- } catch (error) {
507
- return {
508
- success: false,
509
- message: error.message,
510
- serviceName: this.serviceName,
511
- version: this.version
512
- };
525
+ // Wait for registration response from registry.
526
+ // A timeout or an abort REJECTS - it is not downgraded to `{ success: false }`,
527
+ // which is what a legitimate registry rejection looks like (e.g. missing
528
+ // validation proof). The caller must be able to tell "the registry said no"
529
+ // from "the registry never answered" (architecture-principles §3, §4).
530
+ console.log(`[RegistryClient] ${this.serviceName}: [AWAIT] Waiting for registration response promise...`);
531
+ const response = await responsePromise;
532
+ console.log(`[RegistryClient] ${this.serviceName}: [AWAIT] ✓ Promise resolved, response received:`, {
533
+ hasResponse: !!response,
534
+ responseType: typeof response,
535
+ responseKeys: response ? Object.keys(response) : [],
536
+ hasSuccess: !!response?.success,
537
+ hasCertificate: !!response?.certificate
538
+ });
539
+ return response;
540
+ }
541
+
542
+ /**
543
+ * Cancels one in-flight registration: clears its timeout, drops it from the pending
544
+ * maps and rejects its promise with `error`.
545
+ * @param {string} msgId - Registration request id
546
+ * @param {Error} error - Error the pending promise is rejected with
547
+ * @returns {Error} the same error, so callers can `throw this._abortPendingRegistration(...)`
548
+ * @private
549
+ */
550
+ _abortPendingRegistration(msgId, error) {
551
+ const pending = this.pendingRegistrations && this.pendingRegistrations.get(msgId);
552
+ if (this.pendingTimeouts && this.pendingTimeouts.has(msgId)) {
553
+ clearTimeout(this.pendingTimeouts.get(msgId));
554
+ this.pendingTimeouts.delete(msgId);
555
+ }
556
+ if (this.pendingRegistrations) {
557
+ this.pendingRegistrations.delete(msgId);
513
558
  }
559
+ if (pending) {
560
+ pending.reject(error);
561
+ }
562
+ return error;
514
563
  }
515
564
 
516
565
  /**
@@ -737,17 +786,20 @@ class ServiceRegistryClient extends EventEmitter {
737
786
  * @returns {Promise<void>}
738
787
  */
739
788
  async close() {
740
- // Clear all pending registration timeouts
741
- if (this.pendingTimeouts) {
742
- for (const timeoutId of this.pendingTimeouts.values()) {
743
- clearTimeout(timeoutId);
789
+ // Abort every in-flight registration. Clearing the maps without rejecting (the
790
+ // behaviour until 2026-08-27) cleared the timeout too, so the register() promise
791
+ // never settled and its caller waited forever.
792
+ if (this.pendingRegistrations) {
793
+ for (const msgId of Array.from(this.pendingRegistrations.keys())) {
794
+ this._abortPendingRegistration(msgId, new Error(
795
+ `[RegistryClient] ${this.serviceName}: Registration aborted - the client was closed while waiting `
796
+ + 'for the registry response. Expected: register() settles before close(). '
797
+ + 'Fix: await register() before close(), or init() and register() again on a new client.'
798
+ ));
744
799
  }
745
- this.pendingTimeouts.clear();
746
800
  }
747
-
748
- // Clear pending registrations (don't reject - client is closing)
749
- if (this.pendingRegistrations) {
750
- this.pendingRegistrations.clear();
801
+ if (this.pendingTimeouts) {
802
+ this.pendingTimeouts.clear();
751
803
  }
752
804
 
753
805
  this.stopHeartbeat();
@@ -10,14 +10,14 @@
10
10
 
11
11
  const EventEmitter = require('events');
12
12
 
13
- // Default StorageConnector - can be overridden for testing
14
- let DefaultStorageConnector;
15
- try {
16
- DefaultStorageConnector = require('@onlineapps/connector-storage');
17
- } catch (e) {
18
- // StorageConnector not available, must be injected
19
- DefaultStorageConnector = null;
20
- }
13
+ // The storage connector arrives by INJECTION. There used to be an implicit
14
+ // default here — `require('@onlineapps/connector-storage')` inside a try/catch
15
+ // that turned the miss into `null`. Three defects in five lines: the package
16
+ // does not exist and nothing declared it (§20), the catch is a silent fallback
17
+ // (architecture-principles.md §3), and the consequence surfaced layers away
18
+ // from the cause, as "Storage connector not available for downloading specs" at
19
+ // download time. The constructor now fail-fasts at the moment the caller asks
20
+ // for storage without supplying one.
21
21
 
22
22
  class RegistryEventConsumer extends EventEmitter {
23
23
  /**
@@ -37,12 +37,12 @@ class RegistryEventConsumer extends EventEmitter {
37
37
  this.serviceName = serviceName;
38
38
  this.redis = redis;
39
39
 
40
- // Use injected StorageConnector or default one
41
- this.StorageConnectorClass = StorageConnector || DefaultStorageConnector;
40
+ this.StorageConnectorClass = StorageConnector;
42
41
 
43
- // Initialize storage connector for MinIO access ONLY when config is explicitly provided.
44
- // Service discovery (active/inactive status) does NOT require storage.
45
- // Without explicit storageConfig, we run in "index-only" mode (spec downloading disabled).
42
+ // Storage is initialised for MinIO access ONLY when config is explicitly
43
+ // provided. Service discovery (active/inactive status) does NOT require
44
+ // storage, so without a complete storageConfig this runs in "index-only"
45
+ // mode and spec downloading is off.
46
46
  const hasStorageConfig =
47
47
  storageConfig &&
48
48
  typeof storageConfig === 'object' &&
@@ -51,7 +51,17 @@ class RegistryEventConsumer extends EventEmitter {
51
51
  storageConfig.accessKey &&
52
52
  storageConfig.secretKey;
53
53
 
54
- this.storage = (this.StorageConnectorClass && hasStorageConfig)
54
+ if (hasStorageConfig && !this.StorageConnectorClass) {
55
+ throw new Error(
56
+ '[RegistryEventConsumer] storageConfig was given without a StorageConnector - '
57
+ + 'Expected: the connector class injected as options.StorageConnector, because this package '
58
+ + 'declares no storage implementation of its own. '
59
+ + 'Fix: pass StorageConnector (e.g. require("@onlineapps/conn-base-storage")), or drop '
60
+ + 'storageConfig to run in index-only mode.'
61
+ );
62
+ }
63
+
64
+ this.storage = hasStorageConfig
55
65
  ? new this.StorageConnectorClass({
56
66
  ...storageConfig,
57
67
  cacheEnabled,
@@ -317,9 +327,16 @@ class RegistryEventConsumer extends EventEmitter {
317
327
  return this.specCache.get(fingerprint);
318
328
  }
319
329
 
320
- // Check if storage is available
330
+ // Index-only mode is a legitimate construction, so this is not a bug — but
331
+ // the caller asked for something this consumer was never built to do, and
332
+ // has to learn which of the two to change (architecture-principles.md §5).
321
333
  if (!this.storage) {
322
- throw new Error('Storage connector not available for downloading specs');
334
+ throw new Error(
335
+ '[RegistryEventConsumer] Spec download requested in index-only mode - '
336
+ + 'Expected: a storage connector, which this consumer was constructed without. '
337
+ + 'Fix: construct it with storageConfig and StorageConnector, or read the service '
338
+ + 'index only (isServiceActive, getActiveServices, getServiceInfo).'
339
+ );
323
340
  }
324
341
 
325
342
  // Download from MinIO with verification