@onlineapps/service-wrapper 2.1.108 → 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 +17 -1
- package/config/runtime-defaults.json +1 -2
- package/package.json +3 -3
- package/src/ServiceWrapper.js +493 -18
- package/src/createTenantContextMiddleware.js +127 -0
- package/src/index.js +11 -12
- package/src/createAccountContextMiddleware.js +0 -103
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:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-wrapper",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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.
|
|
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",
|
package/src/ServiceWrapper.js
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1993
|
+
if (!result.success) {
|
|
1994
|
+
throw new Error(`Validation failed: ${result.errors.join(', ')}`);
|
|
1995
|
+
}
|
|
1544
1996
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
//
|
|
1552
|
-
|
|
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 {
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
65
|
+
tenantContext: config.wrapper?.tenantContext
|
|
67
66
|
});
|
|
68
|
-
app.use(
|
|
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
|
|
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][
|
|
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 ===
|
|
86
|
+
lastLayer.handle.name === tenantContextMw.name;
|
|
88
87
|
|
|
89
88
|
if (!isOurMiddleware) {
|
|
90
89
|
throw new Error(
|
|
91
|
-
`[service-wrapper][
|
|
92
|
-
`Fix: ensure bootstrap registers
|
|
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.
|
|
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
|
-
|