@friggframework/core 2.0.0-next.105 → 2.0.0-next.107

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.
@@ -383,10 +383,7 @@ class IntegrationBase {
383
383
  this.id,
384
384
  'errors',
385
385
  'Authentication Error',
386
- `There was an error with your ${this[
387
- module
388
- ].getName()} Entity.
389
- Please reconnect/re-authenticate, or reach out to Support for assistance.`,
386
+ this._authErrorMessage(this[module].getName()),
390
387
  Date.now()
391
388
  );
392
389
  }
@@ -395,6 +392,16 @@ class IntegrationBase {
395
392
  return didAuthPass;
396
393
  }
397
394
 
395
+ /**
396
+ * @param {string} [moduleName] - The module whose credentials failed.
397
+ * @param {number} [statusCode] - HTTP status the module rejected us with.
398
+ * @returns {string} A user-facing message.
399
+ */
400
+ _authErrorMessage(moduleName, statusCode) {
401
+ const status = statusCode ? ` (HTTP ${statusCode})` : '';
402
+ return `There was an error with your ${moduleName} Entity${status}. Please reconnect/re-authenticate, or reach out to Support for assistance.`;
403
+ }
404
+
398
405
  /**
399
406
  * Reconcile the auth-health axis (ERROR ↔ ENABLED) from a testAuth result.
400
407
  * On success it never clears DISABLED — a user pause is not an auth-health
@@ -848,6 +855,9 @@ class IntegrationBase {
848
855
  if (!this.id) return;
849
856
 
850
857
  if (delegateString === 'CREDENTIAL_INVALIDATED') {
858
+ if (this.status === 'ERROR') return;
859
+
860
+ const moduleName = notifier?.name;
851
861
  const detail =
852
862
  object?.reason || object?.statusCode
853
863
  ? ` (status ${object?.statusCode ?? '?'}: ${
@@ -856,11 +866,15 @@ class IntegrationBase {
856
866
  : '';
857
867
  console.log(
858
868
  `[Frigg] Module ${
859
- notifier?.name || '?'
869
+ moduleName || '?'
860
870
  } reported invalid credentials for integration ${
861
871
  this.id
862
872
  } — marking ERROR${detail}`
863
873
  );
874
+ await this._recordCredentialRejection(
875
+ moduleName,
876
+ object?.statusCode
877
+ );
864
878
  await this.persistStatus('ERROR');
865
879
  return;
866
880
  }
@@ -877,6 +891,30 @@ class IntegrationBase {
877
891
  await this.persistStatus('ENABLED');
878
892
  }
879
893
  }
894
+
895
+ /**
896
+ * Takes no `reason`: the delegate's is a FetchError message echoing the
897
+ * request, Authorization header included outside prod, and this is shown to
898
+ * end users. Best-effort so it cannot block the caller's status flip.
899
+ * @param {string} [moduleName] - The module that reported the rejection.
900
+ * @param {number} [statusCode] - HTTP status the module rejected us with.
901
+ */
902
+ async _recordCredentialRejection(moduleName, statusCode) {
903
+ try {
904
+ await this.updateIntegrationMessages.execute(
905
+ this.id,
906
+ 'errors',
907
+ 'Authentication Error',
908
+ this._authErrorMessage(moduleName, statusCode),
909
+ Date.now()
910
+ );
911
+ } catch (error) {
912
+ console.error(
913
+ `[Frigg] Failed to record credential rejection for integration ${this.id}:`,
914
+ error
915
+ );
916
+ }
917
+ }
880
918
  }
881
919
 
882
920
  module.exports = { IntegrationBase };
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * 2. Derived-fields phase — duration, recordsPerSecond,
18
18
  * estimatedCompletion. Computed from the post-atomic snapshot and
19
- * written via the legacy (non-atomic) `update()` method.
20
- * Intentionally best-effort: under concurrent writers they reflect
21
- * "whichever handler wrote last" — the same semantics they had
22
- * before and all they've ever guaranteed. Preserved for backward
23
- * compatibility with consumers (UI, WebSocket listeners).
19
+ * written through the same atomic primitive, scoped to only those
20
+ * three paths. The values themselves stay best-effort (under
21
+ * concurrent writers they reflect "whichever handler wrote last"),
22
+ * but the write can no longer clobber another caller's counters.
23
+ * Writing the full context/results blob here — as the legacy
24
+ * `update()` path did — replayed phase 1's snapshot over the row and
25
+ * permanently discarded increments landed in between.
24
26
  *
25
27
  * Optionally broadcasts progress via WebSocket service if provided.
26
28
  *
@@ -124,44 +126,48 @@ class UpdateProcessMetrics {
124
126
  throw processNotFound(`Process not found: ${processId}`);
125
127
  }
126
128
 
127
- // Phase 2: derived metrics (non-atomic, best-effort). Preserved
128
- // for backward compatibility these were always stale under
129
- // concurrent writers even before this refactor.
129
+ // Phase 2: derived metrics. Written through the SAME atomic path
130
+ // as phase 1, touching ONLY the paths this phase owns. Writing the
131
+ // whole context/results blob here (as the legacy `update()` did)
132
+ // replayed phase 1's snapshot over the row and silently discarded
133
+ // any increment a concurrent caller had landed in between.
130
134
  const context = updatedProcess.context || {};
131
- const results = updatedProcess.results || { aggregateData: {} };
132
- if (!results.aggregateData) results.aggregateData = {};
133
135
 
134
136
  if (context.processedRecords > 0 || context.totalRecords > 0) {
135
137
  const startTime = new Date(
136
138
  context.startTime || updatedProcess.createdAt
137
139
  );
138
140
  const elapsed = Date.now() - startTime.getTime();
139
- results.aggregateData.duration = elapsed;
140
141
 
141
- if (elapsed > 0 && context.processedRecords > 0) {
142
- results.aggregateData.recordsPerSecond =
143
- context.processedRecords / (elapsed / 1000);
144
- } else {
145
- results.aggregateData.recordsPerSecond = 0;
146
- }
142
+ const recordsPerSecond =
143
+ elapsed > 0 && context.processedRecords > 0
144
+ ? context.processedRecords / (elapsed / 1000)
145
+ : 0;
146
+
147
+ const set = {
148
+ 'results.aggregateData.duration': elapsed,
149
+ 'results.aggregateData.recordsPerSecond': recordsPerSecond,
150
+ };
147
151
 
148
- if (context.totalRecords > 0 && context.processedRecords > 0) {
152
+ if (
153
+ context.totalRecords > 0 &&
154
+ context.processedRecords > 0 &&
155
+ recordsPerSecond > 0
156
+ ) {
149
157
  const remaining =
150
158
  context.totalRecords - context.processedRecords;
151
- if (results.aggregateData.recordsPerSecond > 0) {
152
- const etaMs =
153
- (remaining / results.aggregateData.recordsPerSecond) *
154
- 1000;
155
- const eta = new Date(Date.now() + etaMs);
156
- context.estimatedCompletion = eta.toISOString();
157
- }
159
+ const etaMs = (remaining / recordsPerSecond) * 1000;
160
+ set['context.estimatedCompletion'] = new Date(
161
+ Date.now() + etaMs
162
+ ).toISOString();
158
163
  }
159
164
 
160
165
  try {
161
- updatedProcess = await this.processRepository.update(
162
- processId,
163
- { context, results }
164
- );
166
+ const withDerived =
167
+ await this.processRepository.applyProcessUpdate(processId, {
168
+ set,
169
+ });
170
+ if (withDerived) updatedProcess = withDerived;
165
171
  } catch (error) {
166
172
  // Derived-field write failures are NON-FATAL — atomic
167
173
  // counters from phase 1 already landed. Log and return the
@@ -322,6 +322,11 @@ class OAuth2Requester extends Requester {
322
322
  response_status: error?.response?.status,
323
323
  response_data: error?.response?.data,
324
324
  });
325
+ // Status only: the refresh body carries client_secret, and
326
+ // FetchError embeds the body in its message outside prod.
327
+ await this.notify(this.DLGT_INVALID_AUTH, {
328
+ statusCode: error?.statusCode,
329
+ });
325
330
  return false;
326
331
  }
327
332
  }
@@ -353,8 +358,13 @@ class OAuth2Requester extends Requester {
353
358
 
354
359
  await this.setTokens(tokenRes);
355
360
  return tokenRes;
356
- } catch {
357
- await this.notify(this.DLGT_INVALID_AUTH);
361
+ } catch (error) {
362
+ // Status only. This request's body holds the password or client
363
+ // secret, and FetchError embeds the body in its message outside
364
+ // prod, so forwarding the error itself would log the credential.
365
+ await this.notify(this.DLGT_INVALID_AUTH, {
366
+ statusCode: error?.statusCode,
367
+ });
358
368
  }
359
369
  }
360
370
 
@@ -387,8 +397,13 @@ class OAuth2Requester extends Requester {
387
397
 
388
398
  await this.setTokens(tokenRes);
389
399
  return tokenRes;
390
- } catch {
391
- await this.notify(this.DLGT_INVALID_AUTH);
400
+ } catch (error) {
401
+ // Status only. This request's body holds the password or client
402
+ // secret, and FetchError embeds the body in its message outside
403
+ // prod, so forwarding the error itself would log the credential.
404
+ await this.notify(this.DLGT_INVALID_AUTH, {
405
+ statusCode: error?.statusCode,
406
+ });
392
407
  }
393
408
  }
394
409
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.105",
4
+ "version": "2.0.0-next.107",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0-next.105",
52
- "@friggframework/prettier-config": "2.0.0-next.105",
53
- "@friggframework/test": "2.0.0-next.105",
51
+ "@friggframework/eslint-config": "2.0.0-next.107",
52
+ "@friggframework/prettier-config": "2.0.0-next.107",
53
+ "@friggframework/test": "2.0.0-next.107",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "d200bd6b1385a33736b67f48e3aa5ac3f5e167c8"
93
+ "gitHead": "27ac136ac4fc2da4c9d9806ee548a8f8c49eaa15"
94
94
  }