@onlineapps/service-wrapper 2.1.109 → 2.1.110

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
@@ -29,7 +29,8 @@ This package provides the infrastructure layer that sits between:
29
29
  - **Service registration** - Auto-registers with service registry
30
30
  - **API-to-Cookbook mapping** - Converts workflow steps to API calls
31
31
  - **Error handling** - Retry logic, DLQ routing
32
- - **Health checks** - Automatic health endpoint
32
+ - **Health checks** - Automatic health endpoint (`GET /health`)
33
+ - **Info endpoint** - Runtime version, validation, and standard level info (`GET /info`)
33
34
 
34
35
  ## Architecture
35
36
 
@@ -234,6 +235,21 @@ describe('My Service', () => {
234
235
  });
235
236
  ```
236
237
 
238
+ ## Info Endpoint
239
+
240
+ ServiceWrapper automatically registers `GET /info` on every business service. No implementation needed.
241
+
242
+ Returns runtime information:
243
+ - **Service & wrapper versions** + all `@onlineapps/*` dependency versions
244
+ - **Validation status** — contract fingerprint, proof age, test results
245
+ - **Infrastructure fingerprint** — library manifest hash from Redis
246
+ - **Implementation standard level** — highest cumulative level satisfied (v1.0, v1.1, v1.2, ...)
247
+ - **Revalidation state** — current backoff cycle status
248
+
249
+ Standard levels are determined by `ServiceStructureValidator` from `@onlineapps/conn-orch-validator`. See [Implementation Standard Levels](/docs/standards/SERVICE_ENDPOINTS.md#implementation-standard-levels) and [Validator README](/shared/connector/conn-orch-validator/README.md#implementation-standard-levels).
250
+
251
+ ---
252
+
237
253
  ## Migration from Embedded Infrastructure
238
254
 
239
255
  If your service currently has workflow code:
@@ -43,9 +43,8 @@
43
43
  "validation": {
44
44
  "enabled": true
45
45
  },
46
- "accountContext": {
46
+ "tenantContext": {
47
47
  "enabled": true,
48
- "headerName": "account-id",
49
48
  "requirePathPrefixes": ["/api/"],
50
49
  "excludePathPrefixes": ["/api/v1/specification", "/api/health"]
51
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/service-wrapper",
3
- "version": "2.1.109",
3
+ "version": "2.1.110",
4
4
  "description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -28,9 +28,9 @@
28
28
  "@onlineapps/conn-base-monitoring": "1.0.9",
29
29
  "@onlineapps/conn-infra-error-handler": "1.0.8",
30
30
  "@onlineapps/conn-infra-mq": "1.1.67",
31
- "@onlineapps/conn-orch-api-mapper": "1.0.29",
31
+ "@onlineapps/conn-orch-api-mapper": "1.0.30",
32
32
  "@onlineapps/conn-orch-cookbook": "2.0.35",
33
- "@onlineapps/conn-orch-orchestrator": "1.0.101",
33
+ "@onlineapps/conn-orch-orchestrator": "1.0.102",
34
34
  "@onlineapps/conn-orch-registry": "1.1.52",
35
35
  "@onlineapps/conn-orch-validator": "2.0.28",
36
36
  "@onlineapps/monitoring-core": "1.0.20",
@@ -24,6 +24,10 @@ const ErrorHandlerConnector = require('@onlineapps/conn-infra-error-handler');
24
24
  const { ValidationOrchestrator } = require('@onlineapps/conn-orch-validator');
25
25
  const runtimeCfg = require('./config');
26
26
 
27
+ const REVALIDATION_FAST_BACKOFF_MS = [30000, 60000, 120000, 300000, 600000, 1800000];
28
+ const REVALIDATION_DAILY_MS = 86400000;
29
+ const REVALIDATION_STATE = { IDLE: 'idle', FAST_BACKOFF: 'fast_backoff', DAILY_BACKOFF: 'daily_backoff', SUCCEEDED: 'succeeded' };
30
+
27
31
  const INFRA_QUEUE_OWNERS = {
28
32
  'workflow.init': 'Gateway (api_gateway)',
29
33
  'workflow.control': 'Gateway (api_gateway)',
@@ -72,6 +76,12 @@ class ServiceWrapper {
72
76
 
73
77
  // State
74
78
  this.isInitialized = false;
79
+
80
+ // Revalidation state (Phases 3-6: infra invalidation + auto re-validation)
81
+ this._revalidationState = REVALIDATION_STATE.IDLE;
82
+ this._revalidationAttempt = 0;
83
+ this._revalidationTimer = null;
84
+ this._infraInvalidationConsumerTag = null;
75
85
  }
76
86
 
77
87
  /**
@@ -91,9 +101,14 @@ class ServiceWrapper {
91
101
  async _startWorkflowListenersIfReady(registrationResult, serviceName) {
92
102
  // Condition 1: Service must be validated and registered (certificate required)
93
103
  if (!(registrationResult?.success && registrationResult.certificate)) {
94
- this.logger?.warn('⚠ Registration succeeded but no certificate received - workflow listeners NOT started');
95
- this.logger?.warn('⚠ Business queues will NOT be created until certificate is received');
96
- return;
104
+ const reason = !registrationResult?.success
105
+ ? `success=${registrationResult?.success}, message=${registrationResult?.message || 'none'}`
106
+ : 'certificate missing from registration result';
107
+ throw new Error(
108
+ `[ServiceWrapper][Registration] Service ${serviceName} did not receive certificate - ${reason}. ` +
109
+ 'Service cannot start workflow listeners without valid certificate. ' +
110
+ 'Docker will restart the service to retry registration.'
111
+ );
97
112
  }
98
113
 
99
114
  this.logger?.info('✓ Certificate validated (service is validated and registered), verifying infrastructure readiness...');
@@ -146,6 +161,15 @@ class ServiceWrapper {
146
161
  });
147
162
  throw new Error(`Failed to start workflow listeners for ${serviceName}: ${listenerError.message}`);
148
163
  }
164
+
165
+ // Phase 0.10: Subscribe to infrastructure invalidation events
166
+ try {
167
+ await this._subscribeToInfraInvalidation(serviceName);
168
+ } catch (subErr) {
169
+ this.logger?.warn('[ServiceWrapper][infra.invalidation] Failed to subscribe to invalidation events (non-critical)', {
170
+ error: subErr.message
171
+ });
172
+ }
149
173
  }
150
174
 
151
175
  /**
@@ -405,6 +429,11 @@ class ServiceWrapper {
405
429
  this.workflowInitConsumerTag = null;
406
430
  this.workflowControlConsumerTag = null;
407
431
  this.serviceWorkflowConsumerTag = null;
432
+ this._infraInvalidationConsumerTag = null;
433
+ if (this._revalidationTimer) {
434
+ clearTimeout(this._revalidationTimer);
435
+ this._revalidationTimer = null;
436
+ }
408
437
  this.isInitialized = false;
409
438
 
410
439
  console.log(`[CLEANUP] ✓ Cleanup completed`);
@@ -1056,6 +1085,150 @@ class ServiceWrapper {
1056
1085
  });
1057
1086
 
1058
1087
  this.logger?.info(`Health check endpoint registered at ${healthEndpoint}`);
1088
+
1089
+ this._setupInfoEndpoint();
1090
+ }
1091
+
1092
+ /**
1093
+ * Setup /info endpoint exposing versions, fingerprint, and validation status.
1094
+ * Registered alongside /health — always available once wrapper is initialized.
1095
+ * @private
1096
+ */
1097
+ _setupInfoEndpoint() {
1098
+ const fs = require('fs');
1099
+ const path = require('path');
1100
+ const wrapperPkg = require('../package.json');
1101
+
1102
+ this.app.get('/info', async (req, res) => {
1103
+ const serviceName = this.config.service?.name || 'unnamed-service';
1104
+ const serviceVersion = this.config.service?.version || 'unknown';
1105
+
1106
+ const info = {
1107
+ service: serviceName,
1108
+ serviceVersion,
1109
+ serviceWrapper: wrapperPkg.version,
1110
+ timestamp: new Date().toISOString()
1111
+ };
1112
+
1113
+ // Collect @onlineapps/* dependency versions from service package.json
1114
+ info.dependencies = {};
1115
+ if (this.serviceRoot) {
1116
+ try {
1117
+ const svcPkgPath = path.join(this.serviceRoot, 'package.json');
1118
+ const svcPkg = JSON.parse(fs.readFileSync(svcPkgPath, 'utf8'));
1119
+ const deps = svcPkg.dependencies || {};
1120
+ for (const [name, version] of Object.entries(deps)) {
1121
+ if (name.startsWith('@onlineapps/')) {
1122
+ info.dependencies[name] = version;
1123
+ }
1124
+ }
1125
+ } catch (e) {
1126
+ info.dependencies._error = e.message;
1127
+ }
1128
+ }
1129
+
1130
+ // Validation proof and contract fingerprint
1131
+ info.validation = this._getValidationInfo();
1132
+
1133
+ // Infrastructure fingerprint from Redis (if cache available)
1134
+ info.infraFingerprint = await this._getInfraFingerprint();
1135
+
1136
+ // Standard level (determined from filesystem, not from validation proof)
1137
+ info.standardLevel = this._getStandardLevel();
1138
+
1139
+ // Revalidation state
1140
+ info.revalidation = {
1141
+ state: this._revalidationState,
1142
+ attempt: this._revalidationAttempt
1143
+ };
1144
+
1145
+ res.json(info);
1146
+ });
1147
+
1148
+ this.logger?.info('Info endpoint registered at /info');
1149
+ }
1150
+
1151
+ /**
1152
+ * Collect validation proof info for /info endpoint.
1153
+ * @private
1154
+ * @returns {Object} Validation status summary
1155
+ */
1156
+ _getValidationInfo() {
1157
+ const fs = require('fs');
1158
+ const path = require('path');
1159
+
1160
+ if (!this.serviceRoot) {
1161
+ return { status: 'no_service_root' };
1162
+ }
1163
+
1164
+ const proofPath = path.join(this.serviceRoot, 'conn-runtime', 'validation-proof.json');
1165
+
1166
+ if (!fs.existsSync(proofPath)) {
1167
+ return { status: 'no_proof' };
1168
+ }
1169
+
1170
+ try {
1171
+ const raw = JSON.parse(fs.readFileSync(proofPath, 'utf8'));
1172
+ const data = raw.validationData || {};
1173
+
1174
+ const validatedAt = data.validatedAt ? new Date(data.validatedAt) : null;
1175
+ const ageMs = validatedAt ? Date.now() - validatedAt.getTime() : null;
1176
+
1177
+ return {
1178
+ status: 'valid',
1179
+ contractFingerprint: data.contractFingerprint || null,
1180
+ validatedAt: data.validatedAt || null,
1181
+ proofAge: ageMs !== null ? `${Math.round(ageMs / 60000)}m` : null,
1182
+ validatorVersion: data.validatorVersion || null,
1183
+ testsRun: data.testsRun ?? null,
1184
+ testsPassed: data.testsPassed ?? null
1185
+ };
1186
+ } catch (e) {
1187
+ return { status: 'error', error: e.message };
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Determine the implementation standard level from the filesystem.
1193
+ * Uses ServiceStructureValidator's standard level checks.
1194
+ * @private
1195
+ * @returns {{ level: string|null, details: Array }}
1196
+ */
1197
+ _getStandardLevel() {
1198
+ if (!this.serviceRoot) {
1199
+ return { level: null, details: [] };
1200
+ }
1201
+
1202
+ try {
1203
+ const { ServiceStructureValidator } = require('@onlineapps/conn-orch-validator/src/validators/ServiceStructureValidator');
1204
+ const validator = new ServiceStructureValidator(this.serviceRoot);
1205
+ return validator.determineStandardLevel();
1206
+ } catch (e) {
1207
+ return { level: null, error: e.message };
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * Read infra:fingerprint from Redis (if cache connector is available).
1213
+ * @private
1214
+ * @returns {Promise<Object|null>}
1215
+ */
1216
+ async _getInfraFingerprint() {
1217
+ if (!this.cacheConnector) {
1218
+ return null;
1219
+ }
1220
+
1221
+ try {
1222
+ const raw = await this.cacheConnector.get('infra:fingerprint');
1223
+ if (!raw) return null;
1224
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
1225
+ return {
1226
+ hash: parsed.hash || null,
1227
+ timestamp: parsed.timestamp || null
1228
+ };
1229
+ } catch (e) {
1230
+ return { error: e.message };
1231
+ }
1059
1232
  }
1060
1233
 
1061
1234
  /**
@@ -1424,6 +1597,262 @@ class ServiceWrapper {
1424
1597
  });
1425
1598
  }
1426
1599
 
1600
+ /**
1601
+ * Subscribe to infra.invalidation events on the infrastructure.health.events fanout exchange.
1602
+ * When received, deletes local validation proof and triggers re-validation with backoff.
1603
+ * @private
1604
+ */
1605
+ async _subscribeToInfraInvalidation(serviceName) {
1606
+ if (!this.mqClient?._transport?.channel) {
1607
+ this.logger?.debug('[ServiceWrapper][infra.invalidation] No MQ channel available — skipping subscription');
1608
+ return;
1609
+ }
1610
+
1611
+ const channel = this.mqClient._transport.channel;
1612
+ const exchangeName = 'infrastructure.health.events';
1613
+
1614
+ await channel.assertExchange(exchangeName, 'fanout', { durable: true });
1615
+
1616
+ const q = await channel.assertQueue('', { exclusive: true, autoDelete: true });
1617
+
1618
+ await channel.bindQueue(q.queue, exchangeName, '');
1619
+
1620
+ const { consumerTag } = await channel.consume(q.queue, async (msg) => {
1621
+ if (!msg) return;
1622
+
1623
+ try {
1624
+ const event = JSON.parse(msg.content.toString());
1625
+
1626
+ if (event.type !== 'infra.invalidation') {
1627
+ channel.ack(msg);
1628
+ return;
1629
+ }
1630
+
1631
+ this.logger?.info('[ServiceWrapper][infra.invalidation] Infrastructure change detected, scheduling re-validation', {
1632
+ fingerprint: event.fingerprint?.substring(0, 16) + '...',
1633
+ timestamp: event.timestamp
1634
+ });
1635
+
1636
+ // Delete local validation proof
1637
+ const fs = require('fs');
1638
+ const path = require('path');
1639
+ if (this.serviceRoot) {
1640
+ const proofPath = path.join(this.serviceRoot, 'conn-runtime', 'validation-proof.json');
1641
+ if (fs.existsSync(proofPath)) {
1642
+ fs.unlinkSync(proofPath);
1643
+ this.logger?.info('[ServiceWrapper][infra.invalidation] Deleted local validation proof', { proofPath });
1644
+ }
1645
+ }
1646
+
1647
+ this._revalidateWithBackoff();
1648
+
1649
+ } catch (parseErr) {
1650
+ this.logger?.warn('[ServiceWrapper][infra.invalidation] Failed to parse event', { error: parseErr.message });
1651
+ }
1652
+
1653
+ channel.ack(msg);
1654
+ });
1655
+
1656
+ this._infraInvalidationConsumerTag = consumerTag;
1657
+ this.logger?.info('[ServiceWrapper][infra.invalidation] Subscribed to infrastructure invalidation events', {
1658
+ exchange: exchangeName,
1659
+ queue: q.queue,
1660
+ consumerTag
1661
+ });
1662
+ }
1663
+
1664
+ /**
1665
+ * Trigger re-validation with exponential backoff.
1666
+ * Only one cycle is active at a time — duplicate calls while a cycle is running are ignored.
1667
+ * @private
1668
+ */
1669
+ _revalidateWithBackoff() {
1670
+ if (this._revalidationState === REVALIDATION_STATE.FAST_BACKOFF ||
1671
+ this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF) {
1672
+ this.logger?.info('[ServiceWrapper][revalidation] Ignoring duplicate invalidation — re-validation cycle already active', {
1673
+ state: this._revalidationState,
1674
+ attempt: this._revalidationAttempt
1675
+ });
1676
+ return;
1677
+ }
1678
+
1679
+ this._revalidationState = REVALIDATION_STATE.FAST_BACKOFF;
1680
+ this._revalidationAttempt = 0;
1681
+ this._scheduleRevalidationAttempt();
1682
+ }
1683
+
1684
+ /**
1685
+ * Schedule the next re-validation attempt based on current state and attempt count.
1686
+ * @private
1687
+ */
1688
+ _scheduleRevalidationAttempt() {
1689
+ let delayMs;
1690
+
1691
+ if (this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF) {
1692
+ delayMs = REVALIDATION_DAILY_MS;
1693
+ } else if (this._revalidationAttempt < REVALIDATION_FAST_BACKOFF_MS.length) {
1694
+ delayMs = REVALIDATION_FAST_BACKOFF_MS[this._revalidationAttempt];
1695
+ } else {
1696
+ this._revalidationState = REVALIDATION_STATE.DAILY_BACKOFF;
1697
+ delayMs = REVALIDATION_DAILY_MS;
1698
+ this.logger?.warn(`[ServiceWrapper][revalidation] Fast retries exhausted (${this._revalidationAttempt} attempts) - switching to daily retry`);
1699
+ }
1700
+
1701
+ this.logger?.info(`[ServiceWrapper][revalidation] Next attempt in ${delayMs}ms`, {
1702
+ attempt: this._revalidationAttempt + 1,
1703
+ state: this._revalidationState,
1704
+ delayMs
1705
+ });
1706
+
1707
+ this._revalidationTimer = setTimeout(() => this._executeRevalidation(), delayMs);
1708
+ }
1709
+
1710
+ /**
1711
+ * Execute a single re-validation attempt.
1712
+ * @private
1713
+ */
1714
+ async _executeRevalidation() {
1715
+ this._revalidationAttempt++;
1716
+ const maxFast = REVALIDATION_FAST_BACKOFF_MS.length;
1717
+ const startTime = Date.now();
1718
+
1719
+ this.logger?.info(`[ServiceWrapper][revalidation] Attempt ${this._revalidationAttempt}/${maxFast} starting`);
1720
+
1721
+ try {
1722
+ const serviceName = this.config.service?.name;
1723
+ const serviceVersion = this.config.service?.version;
1724
+ const serviceUrl = this.config.service?.url;
1725
+
1726
+ const orchestrator = this._injectedValidationOrchestrator || new ValidationOrchestrator({
1727
+ serviceRoot: this.serviceRoot,
1728
+ serviceName,
1729
+ serviceVersion,
1730
+ serviceUrl,
1731
+ logger: this.logger
1732
+ });
1733
+
1734
+ const result = await orchestrator.validate();
1735
+
1736
+ if (!result.success) {
1737
+ throw new Error(`Validation failed: ${result.errors.join(', ')}`);
1738
+ }
1739
+
1740
+ this.validationProof = result.proof;
1741
+
1742
+ // Re-register with fresh proof
1743
+ if (this.registryClient) {
1744
+ const serviceInfo = {
1745
+ name: serviceName,
1746
+ url: serviceUrl,
1747
+ operations: this.operations?.operations || {},
1748
+ metadata: {
1749
+ version: serviceVersion,
1750
+ description: this.config.service?.description || ''
1751
+ }
1752
+ };
1753
+ await this.registryClient.register(serviceInfo);
1754
+ }
1755
+
1756
+ const elapsed = Date.now() - startTime;
1757
+ this._revalidationState = REVALIDATION_STATE.SUCCEEDED;
1758
+ this._revalidationTimer = null;
1759
+ this.logger?.info(`[ServiceWrapper][revalidation] Success after ${this._revalidationAttempt} attempts (${elapsed}ms)`);
1760
+
1761
+ // Clean up failure tracking file
1762
+ this._clearValidationFailureFile();
1763
+
1764
+ } catch (error) {
1765
+ const elapsed = Date.now() - startTime;
1766
+ const nextDelay = this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF
1767
+ ? REVALIDATION_DAILY_MS
1768
+ : (this._revalidationAttempt < maxFast
1769
+ ? REVALIDATION_FAST_BACKOFF_MS[this._revalidationAttempt]
1770
+ : REVALIDATION_DAILY_MS);
1771
+
1772
+ if (this._revalidationState === REVALIDATION_STATE.DAILY_BACKOFF) {
1773
+ this.logger?.warn(`[ServiceWrapper][revalidation] Daily retry attempt ${this._revalidationAttempt} - ${error.message}`);
1774
+ } else {
1775
+ this.logger?.warn(`[ServiceWrapper][revalidation] Attempt ${this._revalidationAttempt} failed: ${error.message} - next retry in ${nextDelay}ms`, {
1776
+ attempt: this._revalidationAttempt,
1777
+ elapsed,
1778
+ error: error.message
1779
+ });
1780
+ }
1781
+
1782
+ this._scheduleRevalidationAttempt();
1783
+ }
1784
+ }
1785
+
1786
+ /**
1787
+ * Read restart-aware validation failure tracking file.
1788
+ * Used at startup to detect if the service has been failing validation across restarts.
1789
+ * @private
1790
+ * @returns {object|null} Failure data or null if no file exists
1791
+ */
1792
+ _readValidationFailureFile() {
1793
+ const fs = require('fs');
1794
+ const path = require('path');
1795
+
1796
+ if (!this.serviceRoot) return null;
1797
+
1798
+ const failurePath = path.join(this.serviceRoot, 'conn-runtime', 'validation-failure.json');
1799
+ try {
1800
+ if (!fs.existsSync(failurePath)) return null;
1801
+ return JSON.parse(fs.readFileSync(failurePath, 'utf8'));
1802
+ } catch (err) {
1803
+ return null;
1804
+ }
1805
+ }
1806
+
1807
+ /**
1808
+ * Write validation failure tracking file before process exit.
1809
+ * @private
1810
+ * @param {Error} error
1811
+ * @param {number} attemptCount
1812
+ * @param {string} firstFailure
1813
+ */
1814
+ _writeValidationFailureFile(error, attemptCount, firstFailure) {
1815
+ const fs = require('fs');
1816
+ const path = require('path');
1817
+
1818
+ if (!this.serviceRoot) return;
1819
+
1820
+ const runtimeDir = path.join(this.serviceRoot, 'conn-runtime');
1821
+ if (!fs.existsSync(runtimeDir)) {
1822
+ fs.mkdirSync(runtimeDir, { recursive: true });
1823
+ }
1824
+
1825
+ const failurePath = path.join(runtimeDir, 'validation-failure.json');
1826
+ const data = {
1827
+ lastAttempt: new Date().toISOString(),
1828
+ attemptCount,
1829
+ lastError: error.message,
1830
+ firstFailure: firstFailure || new Date().toISOString()
1831
+ };
1832
+
1833
+ fs.writeFileSync(failurePath, JSON.stringify(data, null, 2));
1834
+ }
1835
+
1836
+ /**
1837
+ * Delete the validation failure tracking file (on successful validation).
1838
+ * @private
1839
+ */
1840
+ _clearValidationFailureFile() {
1841
+ const fs = require('fs');
1842
+ const path = require('path');
1843
+
1844
+ if (!this.serviceRoot) return;
1845
+
1846
+ const failurePath = path.join(this.serviceRoot, 'conn-runtime', 'validation-failure.json');
1847
+ try {
1848
+ if (fs.existsSync(failurePath)) {
1849
+ fs.unlinkSync(failurePath);
1850
+ }
1851
+ } catch (err) {
1852
+ this.logger?.warn('[ServiceWrapper][startup] Failed to clear validation failure file', { error: err.message });
1853
+ }
1854
+ }
1855
+
1427
1856
  /**
1428
1857
  * Shutdown wrapper and cleanup resources
1429
1858
  * @async
@@ -1438,6 +1867,12 @@ class ServiceWrapper {
1438
1867
  clearInterval(this.heartbeatTimer);
1439
1868
  }
1440
1869
 
1870
+ // Clear revalidation timer
1871
+ if (this._revalidationTimer) {
1872
+ clearTimeout(this._revalidationTimer);
1873
+ this._revalidationTimer = null;
1874
+ }
1875
+
1441
1876
  // Unregister from registry
1442
1877
  if (this.registryClient) {
1443
1878
  await this.registryClient.unregister(serviceName);
@@ -1509,25 +1944,38 @@ class ServiceWrapper {
1509
1944
  if (!this.config.service?.version) {
1510
1945
  throw new Error('Service version is required for validation');
1511
1946
  }
1512
- // Logger není povinný - validace může běžet i bez něj (použije console)
1513
1947
 
1514
1948
  const { name: serviceName, version: serviceVersion, port: servicePort } = this.config.service;
1515
1949
 
1516
- // Validate service port is configured
1517
1950
  if (!servicePort) {
1518
1951
  throw new Error('Service port is required for validation (config.service.port)');
1519
1952
  }
1520
1953
 
1521
- // Build service URL for validation (HTTP calls to running server)
1522
- // Fail-fast: do not guess hostnames (no topology defaults). Service URL must be explicit in ServiceConfig.
1523
1954
  const serviceUrl = this.config.service?.url;
1524
1955
  if (!serviceUrl) {
1525
1956
  throw new Error('[ServiceWrapper] Missing configuration - config.service.url is required for validation (set SERVICE_URL in conn-config/config.json placeholder)');
1526
1957
  }
1527
1958
 
1959
+ // Phase 5: Check restart-aware failure tracking
1960
+ const failureData = this._readValidationFailureFile();
1961
+ if (failureData && failureData.attemptCount >= REVALIDATION_FAST_BACKOFF_MS.length) {
1962
+ const lastAttempt = new Date(failureData.lastAttempt).getTime();
1963
+ const sinceLastAttempt = Date.now() - lastAttempt;
1964
+
1965
+ if (sinceLastAttempt < REVALIDATION_DAILY_MS) {
1966
+ const remainingMs = REVALIDATION_DAILY_MS - sinceLastAttempt;
1967
+ const remainingMin = Math.round(remainingMs / 60000);
1968
+ this.logger?.info(
1969
+ `[ServiceWrapper][startup] Previous validation failures detected (${failureData.attemptCount} attempts since ${failureData.firstFailure}) - waiting ${remainingMin}min before retry`
1970
+ );
1971
+ await new Promise(resolve => setTimeout(resolve, Math.min(remainingMs, 300000)));
1972
+ } else {
1973
+ this.logger?.info('[ServiceWrapper][startup] Previous failures detected, but 24h elapsed - retrying fresh');
1974
+ }
1975
+ }
1976
+
1528
1977
  this.logger?.info('[ServiceWrapper] Checking validation proof...');
1529
1978
 
1530
- // Use injected orchestrator (for testing) or create new one (production)
1531
1979
  const orchestrator = this._injectedValidationOrchestrator || new ValidationOrchestrator({
1532
1980
  serviceRoot: this.serviceRoot,
1533
1981
  serviceName,
@@ -1536,20 +1984,47 @@ class ServiceWrapper {
1536
1984
  logger: this.logger
1537
1985
  });
1538
1986
 
1539
- const result = await orchestrator.validate();
1987
+ // Startup retry loop with fast backoff
1988
+ let lastError = null;
1989
+ for (let attempt = 0; attempt < REVALIDATION_FAST_BACKOFF_MS.length; attempt++) {
1990
+ try {
1991
+ const result = await orchestrator.validate();
1540
1992
 
1541
- if (!result.success) {
1542
- throw new Error(`Validation failed: ${result.errors.join(', ')}`);
1543
- }
1993
+ if (!result.success) {
1994
+ throw new Error(`Validation failed: ${result.errors.join(', ')}`);
1995
+ }
1544
1996
 
1545
- if (result.skipped) {
1546
- this.logger?.info('[ServiceWrapper] ✓ Using existing validation proof');
1547
- } else {
1548
- this.logger?.info('[ServiceWrapper] ✓ Validation completed successfully');
1997
+ if (result.skipped) {
1998
+ this.logger?.info('[ServiceWrapper] ✓ Using existing validation proof');
1999
+ } else {
2000
+ this.logger?.info('[ServiceWrapper] ✓ Validation completed successfully');
2001
+ }
2002
+
2003
+ this.validationProof = result.proof;
2004
+ this._clearValidationFailureFile();
2005
+ return;
2006
+ } catch (error) {
2007
+ lastError = error;
2008
+ const delayMs = REVALIDATION_FAST_BACKOFF_MS[attempt];
2009
+
2010
+ this.logger?.warn(`[ServiceWrapper][revalidation] Startup attempt ${attempt + 1}/${REVALIDATION_FAST_BACKOFF_MS.length} failed: ${error.message} - next retry in ${delayMs}ms`, {
2011
+ attempt: attempt + 1,
2012
+ delayMs,
2013
+ error: error.message
2014
+ });
2015
+
2016
+ if (attempt < REVALIDATION_FAST_BACKOFF_MS.length - 1) {
2017
+ await new Promise(resolve => setTimeout(resolve, delayMs));
2018
+ }
2019
+ }
1549
2020
  }
1550
2021
 
1551
- // Store proof for registration
1552
- this.validationProof = result.proof;
2022
+ // All startup retries exhausted — write failure file and exit
2023
+ const firstFailure = failureData?.firstFailure || new Date().toISOString();
2024
+ const totalAttempts = (failureData?.attemptCount || 0) + REVALIDATION_FAST_BACKOFF_MS.length;
2025
+ this._writeValidationFailureFile(lastError, totalAttempts, firstFailure);
2026
+
2027
+ throw lastError;
1553
2028
  }
1554
2029
 
1555
2030
  /**
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tenant context middleware — requires x-tenant-id + x-workspace-id headers.
5
+ *
6
+ * Sets on req: tenant_id, workspace_id, person_id
7
+ *
8
+ * @param {Object} options
9
+ * @param {string} options.serviceName
10
+ * @param {Object} options.tenantContext
11
+ * @param {boolean} options.tenantContext.enabled
12
+ * @param {string[]} options.tenantContext.requirePathPrefixes
13
+ * @param {string[]} options.tenantContext.excludePathPrefixes
14
+ * @returns {Function} Express middleware
15
+ */
16
+ function createTenantContextMiddleware(options = {}) {
17
+ const serviceName = options.serviceName;
18
+ const tenantContext = options.tenantContext || {};
19
+
20
+ if (!serviceName || typeof serviceName !== 'string') {
21
+ throw new Error('[service-wrapper][TenantContext] Missing dependency - Expected serviceName (string)');
22
+ }
23
+ if (typeof tenantContext !== 'object' || Array.isArray(tenantContext)) {
24
+ throw new Error('[service-wrapper][TenantContext] Invalid configuration - Expected tenantContext to be an object');
25
+ }
26
+
27
+ const {
28
+ enabled,
29
+ requirePathPrefixes,
30
+ excludePathPrefixes
31
+ } = tenantContext;
32
+
33
+ if (typeof enabled !== 'boolean') {
34
+ throw new Error(`[service-wrapper][TenantContext] Invalid configuration - tenantContext.enabled must be boolean, got: ${typeof enabled}`);
35
+ }
36
+ if (!Array.isArray(requirePathPrefixes) || requirePathPrefixes.some(p => typeof p !== 'string')) {
37
+ throw new Error('[service-wrapper][TenantContext] Invalid configuration - tenantContext.requirePathPrefixes must be string[]');
38
+ }
39
+ if (!Array.isArray(excludePathPrefixes) || excludePathPrefixes.some(p => typeof p !== 'string')) {
40
+ throw new Error('[service-wrapper][TenantContext] Invalid configuration - tenantContext.excludePathPrefixes must be string[]');
41
+ }
42
+
43
+ const requirePrefixes = requirePathPrefixes;
44
+ const excludePrefixes = excludePathPrefixes;
45
+
46
+ function parsePositiveInt(raw, label) {
47
+ if (raw === undefined || raw === null || raw === '') return null;
48
+ const num = Number.parseInt(String(raw), 10);
49
+ if (!Number.isInteger(num) || num <= 0) {
50
+ return { error: `[${serviceName}][TenantContext] Invalid ${label} - Expected positive integer, got: ${raw}` };
51
+ }
52
+ return num;
53
+ }
54
+
55
+ function sendJson(res, statusCode, payload) {
56
+ if (!res || typeof res.status !== 'function' || typeof res.json !== 'function') {
57
+ throw new Error('[service-wrapper][TenantContext] Invalid response object - Expected Express res with res.status().json()');
58
+ }
59
+ return res.status(statusCode).json(payload);
60
+ }
61
+
62
+ return function tenantContextMiddleware(req, res, next) {
63
+ if (!enabled) {
64
+ return next();
65
+ }
66
+
67
+ const rawUrl =
68
+ (req && typeof req.originalUrl === 'string' && req.originalUrl) ||
69
+ (req && typeof req.url === 'string' && req.url) ||
70
+ (req && typeof req.path === 'string' && req.path) ||
71
+ '';
72
+ const path = rawUrl.split('?')[0];
73
+
74
+ if (!requirePrefixes.some(prefix => path.startsWith(prefix))) {
75
+ return next();
76
+ }
77
+ if (excludePrefixes.some(prefix => path.startsWith(prefix))) {
78
+ return next();
79
+ }
80
+
81
+ const headers = req.headers || {};
82
+ const rawTenantId = headers['x-tenant-id'];
83
+ const rawWorkspaceId = headers['x-workspace-id'];
84
+
85
+ if (rawTenantId === undefined || rawWorkspaceId === undefined) {
86
+ return sendJson(res, 400, {
87
+ error: `[${serviceName}][TenantContext] Missing tenant context - Expected 'x-tenant-id' + 'x-workspace-id' headers`
88
+ });
89
+ }
90
+
91
+ const tenantId = parsePositiveInt(rawTenantId, 'x-tenant-id');
92
+ if (tenantId && tenantId.error) {
93
+ return sendJson(res, 400, { error: tenantId.error });
94
+ }
95
+ if (!tenantId) {
96
+ return sendJson(res, 400, {
97
+ error: `[${serviceName}][TenantContext] Missing tenant context - Expected header 'x-tenant-id' (positive INT)`
98
+ });
99
+ }
100
+
101
+ const workspaceId = parsePositiveInt(rawWorkspaceId, 'x-workspace-id');
102
+ if (workspaceId && workspaceId.error) {
103
+ return sendJson(res, 400, { error: workspaceId.error });
104
+ }
105
+ if (!workspaceId) {
106
+ return sendJson(res, 400, {
107
+ error: `[${serviceName}][TenantContext] Missing workspace context - Expected header 'x-workspace-id' (positive INT)`
108
+ });
109
+ }
110
+
111
+ const rawPersonId = headers['x-person-id'];
112
+ let personId = null;
113
+ if (rawPersonId !== undefined) {
114
+ personId = parsePositiveInt(rawPersonId, 'x-person-id');
115
+ if (personId && personId.error) {
116
+ return sendJson(res, 400, { error: personId.error });
117
+ }
118
+ }
119
+
120
+ req.tenant_id = tenantId;
121
+ req.workspace_id = workspaceId;
122
+ req.person_id = personId;
123
+ return next();
124
+ };
125
+ }
126
+
127
+ module.exports = { createTenantContextMiddleware };
package/src/index.js CHANGED
@@ -16,7 +16,7 @@ const ServiceWrapper = require('./ServiceWrapper');
16
16
  const { ConfigLoader } = require('./ConfigLoader');
17
17
  const runtimeCfg = require('./config');
18
18
  const pkg = require('../package.json');
19
- const { createAccountContextMiddleware } = require('./createAccountContextMiddleware');
19
+ const { createTenantContextMiddleware } = require('./createTenantContextMiddleware');
20
20
 
21
21
  // Note: WorkflowProcessor and ApiCaller functionality has been moved to connectors:
22
22
  // - WorkflowProcessor -> @onlineapps/conn-orch-orchestrator
@@ -59,21 +59,20 @@ async function bootstrap(serviceRoot, options = {}) {
59
59
 
60
60
  console.log(`Starting ${config.service.name} v${config.service.version}...`);
61
61
 
62
- // 0. Apply shared request context middleware (account context for multitenancy)
63
- // Enforced centrally for all business services: /api/** must have account-id (except /api/v1/specification).
64
- const accountContextMw = createAccountContextMiddleware({
62
+ // 0. Apply shared tenant context middleware (x-tenant-id + x-workspace-id + x-person-id)
63
+ const tenantContextMw = createTenantContextMiddleware({
65
64
  serviceName: config.service.name,
66
- accountContext: config.wrapper?.accountContext
65
+ tenantContext: config.wrapper?.tenantContext
67
66
  });
68
- app.use(accountContextMw);
67
+ app.use(tenantContextMw);
69
68
 
70
69
  // Express routes are processed in registration order.
71
70
  // Services usually register routes inside src/app BEFORE bootstrap runs, so app.use() here would be too late.
72
- // We must enforce account context BEFORE routes: move our middleware to the beginning of the router stack.
71
+ // We must enforce tenant context BEFORE routes: move our middleware to the beginning of the router stack.
73
72
  // Fail-fast if we cannot guarantee correct order.
74
73
  if (!app || !app._router || !Array.isArray(app._router.stack) || app._router.stack.length === 0) {
75
74
  throw new Error(
76
- `[service-wrapper][AccountContext] Cannot enforce account context - Express router stack not available. ` +
75
+ `[service-wrapper][TenantContext] Cannot enforce tenant context - Express router stack not available. ` +
77
76
  `Fix: ensure the service exports an Express app instance with registered routes before bootstrap.`
78
77
  );
79
78
  }
@@ -84,12 +83,12 @@ async function bootstrap(serviceRoot, options = {}) {
84
83
  lastLayer &&
85
84
  lastLayer.handle &&
86
85
  typeof lastLayer.handle === 'function' &&
87
- lastLayer.handle.name === accountContextMw.name;
86
+ lastLayer.handle.name === tenantContextMw.name;
88
87
 
89
88
  if (!isOurMiddleware) {
90
89
  throw new Error(
91
- `[service-wrapper][AccountContext] Cannot enforce account context - middleware placement is not deterministic. ` +
92
- `Fix: ensure bootstrap registers account context middleware before routes, or update service to export app factory.`
90
+ `[service-wrapper][TenantContext] Cannot enforce tenant context - middleware placement is not deterministic. ` +
91
+ `Fix: ensure bootstrap registers tenant context middleware before routes, or update service to export app factory.`
93
92
  );
94
93
  }
95
94
 
@@ -155,6 +154,6 @@ module.exports = ServiceWrapper;
155
154
  module.exports.ServiceWrapper = ServiceWrapper;
156
155
  module.exports.ConfigLoader = ConfigLoader;
157
156
  module.exports.bootstrap = bootstrap;
158
- module.exports.createAccountContextMiddleware = createAccountContextMiddleware;
157
+ module.exports.createTenantContextMiddleware = createTenantContextMiddleware;
159
158
  module.exports.default = ServiceWrapper;
160
159
  module.exports.VERSION = pkg.version;
@@ -1,103 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Create an Express middleware that enforces required account context for business APIs.
5
- *
6
- * Rules:
7
- * - Applies only for paths that match `requirePathPrefixes` and do NOT match `excludePathPrefixes`.
8
- * - Requires header `headerName` to be a positive integer.
9
- * - Sets `req.account_id` as integer.
10
- *
11
- * @param {Object} options
12
- * @param {string} options.serviceName
13
- * @param {Object} options.accountContext
14
- * @param {boolean} options.accountContext.enabled
15
- * @param {string} options.accountContext.headerName
16
- * @param {string[]} options.accountContext.requirePathPrefixes
17
- * @param {string[]} options.accountContext.excludePathPrefixes
18
- * @returns {Function} Express middleware
19
- */
20
- function createAccountContextMiddleware(options = {}) {
21
- const serviceName = options.serviceName;
22
- const accountContext = options.accountContext || {};
23
-
24
- if (!serviceName || typeof serviceName !== 'string') {
25
- throw new Error('[service-wrapper][AccountContext] Missing dependency - Expected serviceName (string)');
26
- }
27
- if (typeof accountContext !== 'object' || Array.isArray(accountContext)) {
28
- throw new Error('[service-wrapper][AccountContext] Invalid configuration - Expected accountContext to be an object');
29
- }
30
-
31
- const {
32
- enabled,
33
- headerName,
34
- requirePathPrefixes,
35
- excludePathPrefixes
36
- } = accountContext;
37
-
38
- if (typeof enabled !== 'boolean') {
39
- throw new Error(`[service-wrapper][AccountContext] Invalid configuration - wrapper.accountContext.enabled must be boolean, got: ${typeof enabled}`);
40
- }
41
- if (typeof headerName !== 'string' || headerName.trim() === '') {
42
- throw new Error(`[service-wrapper][AccountContext] Invalid configuration - wrapper.accountContext.headerName must be non-empty string, got: ${headerName}`);
43
- }
44
- if (!Array.isArray(requirePathPrefixes) || requirePathPrefixes.some(p => typeof p !== 'string')) {
45
- throw new Error('[service-wrapper][AccountContext] Invalid configuration - wrapper.accountContext.requirePathPrefixes must be string[]');
46
- }
47
- if (!Array.isArray(excludePathPrefixes) || excludePathPrefixes.some(p => typeof p !== 'string')) {
48
- throw new Error('[service-wrapper][AccountContext] Invalid configuration - wrapper.accountContext.excludePathPrefixes must be string[]');
49
- }
50
-
51
- const requirePrefixes = requirePathPrefixes;
52
- const excludePrefixes = excludePathPrefixes;
53
-
54
- function sendJson(res, statusCode, payload) {
55
- // Fail-fast: this middleware must run in Express.
56
- if (!res || typeof res.status !== 'function' || typeof res.json !== 'function') {
57
- throw new Error('[service-wrapper][AccountContext] Invalid response object - Expected Express res with res.status().json()');
58
- }
59
- return res.status(statusCode).json(payload);
60
- }
61
-
62
- return function accountContextMiddleware(req, res, next) {
63
- if (!enabled) {
64
- return next();
65
- }
66
-
67
- // Prefer originalUrl because req.path may be relative inside mounted routers (e.g. '/documents')
68
- // while originalUrl keeps the full path (e.g. '/api/v1/documents').
69
- const rawUrl =
70
- (req && typeof req.originalUrl === 'string' && req.originalUrl) ||
71
- (req && typeof req.url === 'string' && req.url) ||
72
- (req && typeof req.path === 'string' && req.path) ||
73
- '';
74
- const path = rawUrl.split('?')[0];
75
- if (!requirePrefixes.some(prefix => path.startsWith(prefix))) {
76
- return next();
77
- }
78
- if (excludePrefixes.some(prefix => path.startsWith(prefix))) {
79
- return next();
80
- }
81
-
82
- const rawAccountId = req.headers ? req.headers[headerName] : undefined;
83
- if (!rawAccountId) {
84
- return sendJson(res, 400, {
85
- error: `[${serviceName}][Multitenancy] Missing account context - Expected request header '${headerName}' (INT)`
86
- });
87
- }
88
-
89
- const accountId = Number.parseInt(String(rawAccountId), 10);
90
- if (!Number.isInteger(accountId) || accountId <= 0) {
91
- return sendJson(res, 400, {
92
- error: `[${serviceName}][Multitenancy] Invalid account context - Expected '${headerName}' to be a positive integer`
93
- });
94
- }
95
-
96
- req.account_id = accountId;
97
- return next();
98
- };
99
- }
100
-
101
- module.exports = { createAccountContextMiddleware };
102
-
103
-