retold-data-service 2.0.14 → 2.0.16
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/.claude/launch.json +11 -0
- package/bin/retold-data-service-clone.js +286 -0
- package/package.json +14 -9
- package/source/Retold-Data-Service.js +52 -2
- package/source/services/data-cloner/DataCloner-Command-Connection.js +138 -0
- package/source/services/data-cloner/DataCloner-Command-Headless.js +357 -0
- package/source/services/data-cloner/DataCloner-Command-Schema.js +367 -0
- package/source/services/data-cloner/DataCloner-Command-Session.js +229 -0
- package/source/services/data-cloner/DataCloner-Command-Sync.js +491 -0
- package/source/services/data-cloner/DataCloner-Command-WebUI.js +40 -0
- package/source/services/data-cloner/DataCloner-ProviderRegistry.js +20 -0
- package/source/services/data-cloner/Retold-Data-Service-DataCloner.js +751 -0
- package/source/services/data-cloner/data-cloner-web.html +2706 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-Command-Dashboard.js +60 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-Command-Integrations.js +132 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-Command-Runs.js +93 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-StorageProvider-Base.js +116 -0
- package/source/services/integration-telemetry/IntegrationTelemetry-StorageProvider-Bibliograph.js +495 -0
- package/source/services/integration-telemetry/Retold-Data-Service-IntegrationTelemetry.js +224 -0
- package/debug/data/books.csv +0 -10001
- package/example_applications/data-cloner/data/cloned.sqlite +0 -0
- package/example_applications/data-cloner/data/cloned.sqlite-shm +0 -0
- package/example_applications/data-cloner/data/cloned.sqlite-wal +0 -0
- package/example_applications/data-cloner/data-cloner-web.html +0 -935
- package/example_applications/data-cloner/data-cloner.js +0 -1047
- package/example_applications/data-cloner/package.json +0 -19
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataCloner Schema Management Routes
|
|
3
|
+
*
|
|
4
|
+
* Registers /clone/schema/* endpoints for fetching remote schemas,
|
|
5
|
+
* deploying tables to the local database, and resetting the database.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} pDataClonerService - The RetoldDataServiceDataCloner instance
|
|
8
|
+
* @param {Object} pOratorServiceServer - The Orator ServiceServer instance
|
|
9
|
+
*/
|
|
10
|
+
module.exports = (pDataClonerService, pOratorServiceServer) =>
|
|
11
|
+
{
|
|
12
|
+
let tmpFable = pDataClonerService.fable;
|
|
13
|
+
let tmpCloneState = pDataClonerService.cloneState;
|
|
14
|
+
let tmpPict = pDataClonerService.pict;
|
|
15
|
+
let tmpPrefix = pDataClonerService.routePrefix;
|
|
16
|
+
|
|
17
|
+
let libFs = require('fs');
|
|
18
|
+
|
|
19
|
+
// POST /clone/schema/fetch
|
|
20
|
+
// Accepts either:
|
|
21
|
+
// { SchemaURL: "..." } — fetch from a remote URL
|
|
22
|
+
// { Schema: { Tables: {...} } } — use a pre-loaded schema object directly
|
|
23
|
+
// {} — default to RemoteServerURL + 'Retold/Models'
|
|
24
|
+
pOratorServiceServer.post(`${tmpPrefix}/schema/fetch`,
|
|
25
|
+
(pRequest, pResponse, fNext) =>
|
|
26
|
+
{
|
|
27
|
+
let tmpBody = pRequest.body || {};
|
|
28
|
+
|
|
29
|
+
// If a raw Schema object was provided, use it directly (no remote fetch)
|
|
30
|
+
if (tmpBody.Schema && typeof tmpBody.Schema === 'object')
|
|
31
|
+
{
|
|
32
|
+
tmpFable.log.info('Data Cloner: Using pre-loaded schema object...');
|
|
33
|
+
|
|
34
|
+
tmpCloneState.RemoteSchema = tmpBody.Schema;
|
|
35
|
+
tmpCloneState.RemoteModelObject = tmpBody.Schema;
|
|
36
|
+
|
|
37
|
+
let tmpTableNames = [];
|
|
38
|
+
if (tmpBody.Schema.Tables)
|
|
39
|
+
{
|
|
40
|
+
tmpTableNames = Object.keys(tmpBody.Schema.Tables);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tmpFable.log.info(`Data Cloner: Loaded schema with ${tmpTableNames.length} tables: [${tmpTableNames.join(', ')}]`);
|
|
44
|
+
|
|
45
|
+
pResponse.send(200,
|
|
46
|
+
{
|
|
47
|
+
Success: true,
|
|
48
|
+
Source: 'local',
|
|
49
|
+
TableCount: tmpTableNames.length,
|
|
50
|
+
Tables: tmpTableNames
|
|
51
|
+
});
|
|
52
|
+
return fNext();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let tmpSchemaURL = tmpBody.SchemaURL;
|
|
56
|
+
|
|
57
|
+
if (!tmpSchemaURL)
|
|
58
|
+
{
|
|
59
|
+
// Default to the standard retold model endpoint
|
|
60
|
+
if (tmpCloneState.RemoteServerURL)
|
|
61
|
+
{
|
|
62
|
+
tmpSchemaURL = tmpCloneState.RemoteServerURL + 'Retold/Models';
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
{
|
|
66
|
+
pResponse.send(400, { Success: false, Error: 'SchemaURL is required (or configure a session first).' });
|
|
67
|
+
return fNext();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
tmpFable.log.info(`Data Cloner: Fetching remote schema from ${tmpSchemaURL}...`);
|
|
72
|
+
|
|
73
|
+
tmpPict.RestClient.getJSON(tmpSchemaURL,
|
|
74
|
+
(pError, pHTTPResponse, pData) =>
|
|
75
|
+
{
|
|
76
|
+
if (pError)
|
|
77
|
+
{
|
|
78
|
+
tmpFable.log.error(`Data Cloner: Schema fetch error: ${pError.message || pError}`);
|
|
79
|
+
pResponse.send(500, { Success: false, Error: `Schema fetch error: ${pError.message || pError}` });
|
|
80
|
+
return fNext();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!pHTTPResponse || pHTTPResponse.statusCode !== 200)
|
|
84
|
+
{
|
|
85
|
+
let tmpStatus = pHTTPResponse ? pHTTPResponse.statusCode : 'unknown';
|
|
86
|
+
tmpFable.log.error(`Data Cloner: Schema fetch returned HTTP ${tmpStatus} — body: ${JSON.stringify(pData)}`);
|
|
87
|
+
pResponse.send(500, { Success: false, Error: `Schema fetch returned HTTP ${tmpStatus}` });
|
|
88
|
+
return fNext();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tmpCloneState.RemoteSchema = pData;
|
|
92
|
+
tmpCloneState.RemoteModelObject = pData;
|
|
93
|
+
|
|
94
|
+
// Extract table names for the UI
|
|
95
|
+
let tmpTableNames = [];
|
|
96
|
+
if (pData && pData.Tables)
|
|
97
|
+
{
|
|
98
|
+
tmpTableNames = Object.keys(pData.Tables);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
tmpFable.log.info(`Data Cloner: Fetched schema with ${tmpTableNames.length} tables: [${tmpTableNames.join(', ')}]`);
|
|
102
|
+
|
|
103
|
+
pResponse.send(200,
|
|
104
|
+
{
|
|
105
|
+
Success: true,
|
|
106
|
+
SchemaURL: tmpSchemaURL,
|
|
107
|
+
TableCount: tmpTableNames.length,
|
|
108
|
+
Tables: tmpTableNames
|
|
109
|
+
});
|
|
110
|
+
return fNext();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// GET /clone/schema
|
|
115
|
+
pOratorServiceServer.get(`${tmpPrefix}/schema`,
|
|
116
|
+
(pRequest, pResponse, fNext) =>
|
|
117
|
+
{
|
|
118
|
+
if (!tmpCloneState.RemoteSchema)
|
|
119
|
+
{
|
|
120
|
+
pResponse.send(200, { Fetched: false, Tables: [] });
|
|
121
|
+
return fNext();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let tmpTableNames = [];
|
|
125
|
+
if (tmpCloneState.RemoteSchema.Tables)
|
|
126
|
+
{
|
|
127
|
+
tmpTableNames = Object.keys(tmpCloneState.RemoteSchema.Tables);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pResponse.send(200,
|
|
131
|
+
{
|
|
132
|
+
Fetched: true,
|
|
133
|
+
TableCount: tmpTableNames.length,
|
|
134
|
+
Tables: tmpTableNames
|
|
135
|
+
});
|
|
136
|
+
return fNext();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// POST /clone/reset — Delete the local SQLite database and start fresh
|
|
140
|
+
pOratorServiceServer.post(`${tmpPrefix}/reset`,
|
|
141
|
+
(pRequest, pResponse, fNext) =>
|
|
142
|
+
{
|
|
143
|
+
let tmpSQLitePath = tmpFable.settings.SQLite && tmpFable.settings.SQLite.SQLiteFilePath;
|
|
144
|
+
if (!tmpSQLitePath)
|
|
145
|
+
{
|
|
146
|
+
pResponse.send(400, { Success: false, Error: 'No SQLite file path configured.' });
|
|
147
|
+
return fNext();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
tmpFable.log.info(`Data Cloner: Resetting local database [${tmpSQLitePath}]...`);
|
|
151
|
+
|
|
152
|
+
try
|
|
153
|
+
{
|
|
154
|
+
// Close the existing SQLite connection and reset the provider state
|
|
155
|
+
if (tmpFable.MeadowSQLiteProvider)
|
|
156
|
+
{
|
|
157
|
+
if (tmpFable.MeadowSQLiteProvider._database)
|
|
158
|
+
{
|
|
159
|
+
tmpFable.MeadowSQLiteProvider._database.close();
|
|
160
|
+
}
|
|
161
|
+
tmpFable.MeadowSQLiteProvider.connected = false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (pCloseError)
|
|
165
|
+
{
|
|
166
|
+
tmpFable.log.warn(`Data Cloner: Error closing SQLite connection: ${pCloseError}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try
|
|
170
|
+
{
|
|
171
|
+
// Delete the database file
|
|
172
|
+
if (libFs.existsSync(tmpSQLitePath))
|
|
173
|
+
{
|
|
174
|
+
libFs.unlinkSync(tmpSQLitePath);
|
|
175
|
+
tmpFable.log.info('Data Cloner: SQLite database file deleted.');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (pDeleteError)
|
|
179
|
+
{
|
|
180
|
+
tmpFable.log.error(`Data Cloner: Error deleting SQLite file: ${pDeleteError}`);
|
|
181
|
+
pResponse.send(500, { Success: false, Error: `Failed to delete database: ${pDeleteError}` });
|
|
182
|
+
return fNext();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Reconnect to create a fresh database
|
|
186
|
+
tmpFable.MeadowSQLiteProvider.connectAsync(
|
|
187
|
+
(pReconnectError) =>
|
|
188
|
+
{
|
|
189
|
+
if (pReconnectError)
|
|
190
|
+
{
|
|
191
|
+
tmpFable.log.error(`Data Cloner: Error reconnecting SQLite: ${pReconnectError}`);
|
|
192
|
+
pResponse.send(500, { Success: false, Error: `Failed to reconnect: ${pReconnectError}` });
|
|
193
|
+
return fNext();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Clear sync state
|
|
197
|
+
tmpCloneState.SyncProgress = {};
|
|
198
|
+
tmpCloneState.SyncRESTErrors = {};
|
|
199
|
+
|
|
200
|
+
tmpFable.log.info('Data Cloner: Database reset complete — fresh SQLite file ready.');
|
|
201
|
+
|
|
202
|
+
pResponse.send(200, { Success: true, Message: 'Database reset. Deploy a schema to recreate tables.' });
|
|
203
|
+
return fNext();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// POST /clone/schema/deploy
|
|
208
|
+
pOratorServiceServer.post(`${tmpPrefix}/schema/deploy`,
|
|
209
|
+
(pRequest, pResponse, fNext) =>
|
|
210
|
+
{
|
|
211
|
+
if (!tmpCloneState.RemoteModelObject)
|
|
212
|
+
{
|
|
213
|
+
pResponse.send(400, { Success: false, Error: 'No schema fetched. Call POST /clone/schema/fetch first.' });
|
|
214
|
+
return fNext();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let tmpBody = pRequest.body || {};
|
|
218
|
+
let tmpSelectedTables = tmpBody.Tables || null;
|
|
219
|
+
|
|
220
|
+
let tmpFullModel = tmpCloneState.RemoteModelObject;
|
|
221
|
+
|
|
222
|
+
// Build a filtered model with ONLY the selected tables
|
|
223
|
+
let tmpFilteredTables = {};
|
|
224
|
+
let tmpFilteredSequence = [];
|
|
225
|
+
let tmpSourceTables = tmpFullModel.Tables || {};
|
|
226
|
+
|
|
227
|
+
if (tmpSelectedTables && Array.isArray(tmpSelectedTables) && tmpSelectedTables.length > 0)
|
|
228
|
+
{
|
|
229
|
+
for (let i = 0; i < tmpSelectedTables.length; i++)
|
|
230
|
+
{
|
|
231
|
+
let tmpTableName = tmpSelectedTables[i];
|
|
232
|
+
if (tmpSourceTables[tmpTableName])
|
|
233
|
+
{
|
|
234
|
+
tmpFilteredTables[tmpTableName] = tmpSourceTables[tmpTableName];
|
|
235
|
+
tmpFilteredSequence.push(tmpTableName);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else
|
|
240
|
+
{
|
|
241
|
+
// No selection — use all tables
|
|
242
|
+
tmpFilteredTables = tmpSourceTables;
|
|
243
|
+
tmpFilteredSequence = Object.keys(tmpSourceTables);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let tmpModelObject = { Tables: tmpFilteredTables, TablesSequence: tmpFilteredSequence };
|
|
247
|
+
|
|
248
|
+
let tmpTableNames = Object.keys(tmpModelObject.Tables || {});
|
|
249
|
+
tmpFable.log.info(`Data Cloner: Deploying ${tmpTableNames.length} tables to local ${tmpCloneState.ConnectionProvider}: [${tmpTableNames.join(', ')}]`);
|
|
250
|
+
|
|
251
|
+
// ---- Set up MeadowCloneRestClient ----
|
|
252
|
+
if (!tmpFable.MeadowCloneRestClient)
|
|
253
|
+
{
|
|
254
|
+
tmpFable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient',
|
|
255
|
+
{
|
|
256
|
+
ServerURL: tmpCloneState.RemoteServerURL,
|
|
257
|
+
RequestTimeout: 60000,
|
|
258
|
+
MaxRequestTimeout: 300000
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
{
|
|
263
|
+
tmpFable.MeadowCloneRestClient.serverURL = tmpCloneState.RemoteServerURL;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Override getJSON to delegate through pict-sessionmanager's
|
|
267
|
+
// RestClient, which already handles cookie injection & domain matching.
|
|
268
|
+
let libHttps = require('https');
|
|
269
|
+
libHttps.globalAgent.options.timeout = tmpFable.MeadowCloneRestClient.maxRequestTimeout;
|
|
270
|
+
tmpFable.MeadowCloneRestClient.getJSON = (pURL, fCallback) =>
|
|
271
|
+
{
|
|
272
|
+
let tmpFullURL = tmpFable.MeadowCloneRestClient.serverURL + pURL;
|
|
273
|
+
|
|
274
|
+
// Extract the entity name from the URL for error tracking
|
|
275
|
+
let tmpEntityHint = pURL.split('/')[0].replace(/s$/, '');
|
|
276
|
+
|
|
277
|
+
// Use the longer timeout for MAX(column) queries only
|
|
278
|
+
let tmpIsMaxRequest = (pURL.indexOf('/Max/') > -1);
|
|
279
|
+
let tmpPreviousTimeout = libHttps.globalAgent.options.timeout;
|
|
280
|
+
libHttps.globalAgent.options.timeout = tmpIsMaxRequest
|
|
281
|
+
? tmpFable.MeadowCloneRestClient.maxRequestTimeout
|
|
282
|
+
: tmpFable.MeadowCloneRestClient.requestTimeout;
|
|
283
|
+
|
|
284
|
+
tmpPict.RestClient.getJSON(tmpFullURL,
|
|
285
|
+
(pError, pHTTPResponse, pBody) =>
|
|
286
|
+
{
|
|
287
|
+
if (pError)
|
|
288
|
+
{
|
|
289
|
+
tmpFable.log.error(`Data Cloner: REST error for ${pURL}: ${pError}`);
|
|
290
|
+
if (tmpCloneState.SyncRESTErrors[tmpEntityHint] !== undefined)
|
|
291
|
+
{
|
|
292
|
+
tmpCloneState.SyncRESTErrors[tmpEntityHint]++;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else
|
|
296
|
+
{
|
|
297
|
+
// Track when the server returns a non-array for list endpoints
|
|
298
|
+
// Count/FilteredTo returns {"Count":N} (an object, not an array) which is expected
|
|
299
|
+
if (pURL.indexOf('FilteredTo') > -1 && pURL.indexOf('Count/FilteredTo') < 0 && !Array.isArray(pBody))
|
|
300
|
+
{
|
|
301
|
+
let tmpBodyPreview = (typeof(pBody) === 'string') ? pBody.substring(0, 300) : JSON.stringify(pBody).substring(0, 300);
|
|
302
|
+
tmpFable.log.warn(`Data Cloner: FilteredTo response for ${tmpEntityHint} is not an array — URL: ${pURL} — Response: ${tmpBodyPreview}`);
|
|
303
|
+
if (tmpCloneState.SyncRESTErrors[tmpEntityHint] !== undefined)
|
|
304
|
+
{
|
|
305
|
+
tmpCloneState.SyncRESTErrors[tmpEntityHint]++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
libHttps.globalAgent.options.timeout = tmpPreviousTimeout;
|
|
310
|
+
return fCallback(pError, pHTTPResponse, pBody);
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// ---- Set up MeadowSync ----
|
|
315
|
+
if (!tmpFable.ProgramConfiguration)
|
|
316
|
+
{
|
|
317
|
+
tmpFable.ProgramConfiguration = {};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
tmpFable.serviceManager.instantiateServiceProvider('MeadowSync',
|
|
321
|
+
{
|
|
322
|
+
SyncEntityList: tmpTableNames,
|
|
323
|
+
PageSize: 100,
|
|
324
|
+
SyncDeletedRecords: tmpCloneState.SyncDeletedRecords
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
tmpFable.MeadowSync.loadMeadowSchema(tmpModelObject,
|
|
328
|
+
(pSyncInitError) =>
|
|
329
|
+
{
|
|
330
|
+
if (pSyncInitError)
|
|
331
|
+
{
|
|
332
|
+
tmpFable.log.warn(`Data Cloner: MeadowSync schema init warning: ${pSyncInitError}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let tmpInitializedEntities = Object.keys(tmpFable.MeadowSync.MeadowSyncEntities);
|
|
336
|
+
tmpFable.log.info(`Data Cloner: MeadowSync initialized ${tmpInitializedEntities.length} sync entities: [${tmpInitializedEntities.join(', ')}]`);
|
|
337
|
+
|
|
338
|
+
// Store the deployed model so sync mode switches can re-create entities
|
|
339
|
+
tmpCloneState.DeployedModelObject = tmpModelObject;
|
|
340
|
+
|
|
341
|
+
tmpFable.log.info(`Data Cloner: Loading model for CRUD endpoints...`);
|
|
342
|
+
|
|
343
|
+
// Load the filtered model so CRUD endpoints are available
|
|
344
|
+
tmpFable.RetoldDataServiceMeadowEndpoints.loadModel('RemoteClone', tmpModelObject, tmpCloneState.ConnectionProvider,
|
|
345
|
+
(pLoadError) =>
|
|
346
|
+
{
|
|
347
|
+
if (pLoadError)
|
|
348
|
+
{
|
|
349
|
+
tmpFable.log.error(`Data Cloner: Model load error: ${pLoadError}`);
|
|
350
|
+
}
|
|
351
|
+
else
|
|
352
|
+
{
|
|
353
|
+
tmpFable.log.info(`Data Cloner: CRUD endpoints available for: [${tmpTableNames.join(', ')}]`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
pResponse.send(200,
|
|
357
|
+
{
|
|
358
|
+
Success: true,
|
|
359
|
+
TablesDeployed: tmpTableNames,
|
|
360
|
+
SyncEntities: tmpInitializedEntities,
|
|
361
|
+
Message: `${tmpInitializedEntities.length} / ${tmpTableNames.length} tables deployed. meadow-integration sync ready.`
|
|
362
|
+
});
|
|
363
|
+
return fNext();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataCloner Session Management Routes
|
|
3
|
+
*
|
|
4
|
+
* Registers /clone/session/* endpoints for remote session configuration,
|
|
5
|
+
* authentication, check, and deauthentication via pict-sessionmanager.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} pDataClonerService - The RetoldDataServiceDataCloner instance
|
|
8
|
+
* @param {Object} pOratorServiceServer - The Orator ServiceServer instance
|
|
9
|
+
*/
|
|
10
|
+
module.exports = (pDataClonerService, pOratorServiceServer) =>
|
|
11
|
+
{
|
|
12
|
+
let tmpFable = pDataClonerService.fable;
|
|
13
|
+
let tmpCloneState = pDataClonerService.cloneState;
|
|
14
|
+
let tmpPict = pDataClonerService.pict;
|
|
15
|
+
let tmpPrefix = pDataClonerService.routePrefix;
|
|
16
|
+
|
|
17
|
+
// POST /clone/session/configure
|
|
18
|
+
pOratorServiceServer.post(`${tmpPrefix}/session/configure`,
|
|
19
|
+
(pRequest, pResponse, fNext) =>
|
|
20
|
+
{
|
|
21
|
+
let tmpBody = pRequest.body || {};
|
|
22
|
+
let tmpServerURL = tmpBody.ServerURL;
|
|
23
|
+
|
|
24
|
+
if (!tmpServerURL)
|
|
25
|
+
{
|
|
26
|
+
pResponse.send(400, { Success: false, Error: 'ServerURL is required.' });
|
|
27
|
+
return fNext();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
tmpCloneState.RemoteServerURL = tmpServerURL;
|
|
31
|
+
|
|
32
|
+
// Remove existing session if reconfiguring
|
|
33
|
+
if (tmpPict.SessionManager.getSession('Remote'))
|
|
34
|
+
{
|
|
35
|
+
tmpPict.SessionManager.deauthenticate('Remote');
|
|
36
|
+
delete tmpPict.SessionManager.sessions['Remote'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Extract domain from ServerURL for cookie matching
|
|
40
|
+
let tmpDomainMatch = tmpBody.DomainMatch;
|
|
41
|
+
if (!tmpDomainMatch)
|
|
42
|
+
{
|
|
43
|
+
try
|
|
44
|
+
{
|
|
45
|
+
let tmpURL = new URL(tmpServerURL);
|
|
46
|
+
tmpDomainMatch = tmpURL.hostname;
|
|
47
|
+
}
|
|
48
|
+
catch (pParseError)
|
|
49
|
+
{
|
|
50
|
+
tmpDomainMatch = tmpServerURL;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Helper: ensure a URI template is fully qualified with the server URL
|
|
55
|
+
let fQualifyURI = (pTemplate, pDefault) =>
|
|
56
|
+
{
|
|
57
|
+
let tmpTemplate = pTemplate || pDefault;
|
|
58
|
+
// Already a full URL — use as-is
|
|
59
|
+
if (tmpTemplate.indexOf('://') > -1)
|
|
60
|
+
{
|
|
61
|
+
return tmpTemplate;
|
|
62
|
+
}
|
|
63
|
+
// Strip leading slash
|
|
64
|
+
let tmpPath = tmpTemplate.replace(/^\//, '');
|
|
65
|
+
// If the server URL already ends with a version prefix (e.g. /1.0/)
|
|
66
|
+
// and the path redundantly starts with the same prefix, strip it
|
|
67
|
+
// to avoid double-prefixing (e.g. /1.0/1.0/Authenticate).
|
|
68
|
+
let tmpVersionMatch = tmpServerURL.match(/\/(\d+\.\d+)\/?$/);
|
|
69
|
+
if (tmpVersionMatch)
|
|
70
|
+
{
|
|
71
|
+
let tmpURLPrefix = tmpVersionMatch[1] + '/';
|
|
72
|
+
if (tmpPath.indexOf(tmpURLPrefix) === 0)
|
|
73
|
+
{
|
|
74
|
+
tmpPath = tmpPath.substring(tmpURLPrefix.length);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return tmpServerURL + tmpPath;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let tmpSessionConfig = (
|
|
81
|
+
{
|
|
82
|
+
Type: 'Cookie',
|
|
83
|
+
|
|
84
|
+
// Authentication
|
|
85
|
+
AuthenticationMethod: tmpBody.AuthenticationMethod || 'get',
|
|
86
|
+
AuthenticationURITemplate: fQualifyURI(tmpBody.AuthenticationURITemplate, 'Authenticate/{~D:Record.UserName~}/{~D:Record.Password~}'),
|
|
87
|
+
AuthenticationRetryCount: 2,
|
|
88
|
+
AuthenticationRetryDebounce: 200,
|
|
89
|
+
|
|
90
|
+
// Session check
|
|
91
|
+
CheckSessionURITemplate: fQualifyURI(tmpBody.CheckSessionURITemplate, 'CheckSession'),
|
|
92
|
+
CheckSessionLoginMarkerType: tmpBody.CheckSessionLoginMarkerType || 'boolean',
|
|
93
|
+
CheckSessionLoginMarker: tmpBody.CheckSessionLoginMarker || 'LoggedIn',
|
|
94
|
+
|
|
95
|
+
// Cookie injection
|
|
96
|
+
DomainMatch: tmpDomainMatch,
|
|
97
|
+
CookieName: tmpBody.CookieName || 'SessionID',
|
|
98
|
+
CookieValueAddress: tmpBody.CookieValueAddress || 'SessionID',
|
|
99
|
+
CookieValueTemplate: tmpBody.CookieValueTemplate || false
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// If authentication is POST-based, set up the request body template
|
|
103
|
+
if (tmpBody.AuthenticationMethod === 'post')
|
|
104
|
+
{
|
|
105
|
+
tmpSessionConfig.AuthenticationRequestBody = tmpBody.AuthenticationRequestBody || (
|
|
106
|
+
{
|
|
107
|
+
username: '{~D:Record.UserName~}',
|
|
108
|
+
password: '{~D:Record.Password~}'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
tmpPict.SessionManager.addSession('Remote', tmpSessionConfig);
|
|
113
|
+
tmpPict.SessionManager.connectToRestClient();
|
|
114
|
+
|
|
115
|
+
tmpCloneState.SessionConfigured = true;
|
|
116
|
+
tmpCloneState.SessionAuthenticated = false;
|
|
117
|
+
|
|
118
|
+
tmpFable.log.info(`Data Cloner: Session configured for ${tmpServerURL} (domain: ${tmpDomainMatch})`);
|
|
119
|
+
|
|
120
|
+
pResponse.send(200,
|
|
121
|
+
{
|
|
122
|
+
Success: true,
|
|
123
|
+
ServerURL: tmpServerURL,
|
|
124
|
+
DomainMatch: tmpDomainMatch,
|
|
125
|
+
AuthenticationMethod: tmpSessionConfig.AuthenticationMethod
|
|
126
|
+
});
|
|
127
|
+
return fNext();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// POST /clone/session/authenticate
|
|
131
|
+
pOratorServiceServer.post(`${tmpPrefix}/session/authenticate`,
|
|
132
|
+
(pRequest, pResponse, fNext) =>
|
|
133
|
+
{
|
|
134
|
+
if (!tmpCloneState.SessionConfigured)
|
|
135
|
+
{
|
|
136
|
+
pResponse.send(400, { Success: false, Error: 'Session not configured. Call POST /clone/session/configure first.' });
|
|
137
|
+
return fNext();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let tmpBody = pRequest.body || {};
|
|
141
|
+
let tmpCredentials = (
|
|
142
|
+
{
|
|
143
|
+
UserName: tmpBody.UserName || tmpBody.username,
|
|
144
|
+
Password: tmpBody.Password || tmpBody.password
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (!tmpCredentials.UserName || !tmpCredentials.Password)
|
|
148
|
+
{
|
|
149
|
+
pResponse.send(400, { Success: false, Error: 'UserName and Password are required.' });
|
|
150
|
+
return fNext();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
tmpFable.log.info(`Data Cloner: Authenticating as ${tmpCredentials.UserName}...`);
|
|
154
|
+
|
|
155
|
+
tmpPict.SessionManager.authenticate('Remote', tmpCredentials,
|
|
156
|
+
(pAuthError, pSessionState) =>
|
|
157
|
+
{
|
|
158
|
+
if (pAuthError)
|
|
159
|
+
{
|
|
160
|
+
tmpFable.log.error(`Data Cloner: Authentication failed: ${pAuthError.message || pAuthError}`);
|
|
161
|
+
tmpCloneState.SessionAuthenticated = false;
|
|
162
|
+
pResponse.send(401,
|
|
163
|
+
{
|
|
164
|
+
Success: false,
|
|
165
|
+
Error: `Authentication failed: ${pAuthError.message || pAuthError}`
|
|
166
|
+
});
|
|
167
|
+
return fNext();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
tmpCloneState.SessionAuthenticated = pSessionState && pSessionState.Authenticated;
|
|
171
|
+
|
|
172
|
+
tmpFable.log.info(`Data Cloner: Authentication ${tmpCloneState.SessionAuthenticated ? 'succeeded' : 'failed'}.`);
|
|
173
|
+
|
|
174
|
+
pResponse.send(200,
|
|
175
|
+
{
|
|
176
|
+
Success: tmpCloneState.SessionAuthenticated,
|
|
177
|
+
Authenticated: tmpCloneState.SessionAuthenticated,
|
|
178
|
+
SessionData: pSessionState ? pSessionState.SessionData : {}
|
|
179
|
+
});
|
|
180
|
+
return fNext();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// GET /clone/session/check
|
|
185
|
+
pOratorServiceServer.get(`${tmpPrefix}/session/check`,
|
|
186
|
+
(pRequest, pResponse, fNext) =>
|
|
187
|
+
{
|
|
188
|
+
if (!tmpCloneState.SessionConfigured)
|
|
189
|
+
{
|
|
190
|
+
pResponse.send(200,
|
|
191
|
+
{
|
|
192
|
+
Configured: false,
|
|
193
|
+
Authenticated: false
|
|
194
|
+
});
|
|
195
|
+
return fNext();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
tmpPict.SessionManager.checkSession('Remote',
|
|
199
|
+
(pCheckError, pAuthenticated, pCheckData) =>
|
|
200
|
+
{
|
|
201
|
+
tmpCloneState.SessionAuthenticated = pAuthenticated;
|
|
202
|
+
|
|
203
|
+
pResponse.send(200,
|
|
204
|
+
{
|
|
205
|
+
Configured: tmpCloneState.SessionConfigured,
|
|
206
|
+
Authenticated: pAuthenticated,
|
|
207
|
+
ServerURL: tmpCloneState.RemoteServerURL,
|
|
208
|
+
CheckData: pCheckData || {}
|
|
209
|
+
});
|
|
210
|
+
return fNext();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// POST /clone/session/deauthenticate
|
|
215
|
+
pOratorServiceServer.post(`${tmpPrefix}/session/deauthenticate`,
|
|
216
|
+
(pRequest, pResponse, fNext) =>
|
|
217
|
+
{
|
|
218
|
+
if (tmpCloneState.SessionConfigured)
|
|
219
|
+
{
|
|
220
|
+
tmpPict.SessionManager.deauthenticate('Remote');
|
|
221
|
+
}
|
|
222
|
+
tmpCloneState.SessionAuthenticated = false;
|
|
223
|
+
|
|
224
|
+
tmpFable.log.info('Data Cloner: Session deauthenticated.');
|
|
225
|
+
|
|
226
|
+
pResponse.send(200, { Success: true, Authenticated: false });
|
|
227
|
+
return fNext();
|
|
228
|
+
});
|
|
229
|
+
};
|