@friggframework/core 2.0.0-next.112 → 2.0.0-next.114

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.
Files changed (64) hide show
  1. package/.eslintrc.json +18 -1
  2. package/CLAUDE.md +51 -12
  3. package/README.md +19 -22
  4. package/core/CLAUDE.md +28 -13
  5. package/core/Worker.js +32 -37
  6. package/core/create-handler.js +97 -197
  7. package/core/index.js +8 -1
  8. package/core/invocation-scope.js +225 -0
  9. package/core/secrets-to-env.js +7 -6
  10. package/database/config.js +0 -7
  11. package/database/encryption/README.md +5 -1
  12. package/database/encryption/encryption-schema-registry.js +3 -0
  13. package/database/encryption/logger.js +5 -2
  14. package/errors/fetch-error.js +58 -64
  15. package/handlers/app-definition-loader.js +38 -2
  16. package/handlers/app-handler-helpers.js +18 -4
  17. package/handlers/backend-utils.js +10 -13
  18. package/handlers/database-migration-handler.js +10 -1
  19. package/handlers/integration-event-dispatcher.js +2 -2
  20. package/handlers/routers/integration-webhook-routers.js +13 -6
  21. package/handlers/routers/websocket.js +27 -7
  22. package/handlers/workers/db-migration.js +12 -1
  23. package/handlers/workers/dlq-processor.js +64 -38
  24. package/index.js +19 -1
  25. package/integrations/integration-base.js +56 -38
  26. package/integrations/integration-router.js +21 -20
  27. package/integrations/use-cases/delete-integration-for-user.js +24 -13
  28. package/jest.config.js +2 -0
  29. package/logs/.eslintrc.json +26 -1
  30. package/logs/__fixtures__/events.js +82 -0
  31. package/logs/__fixtures__/matchers.js +45 -0
  32. package/logs/__fixtures__/secrets.js +59 -0
  33. package/logs/__fixtures__/vectors.js +329 -0
  34. package/logs/__fixtures__/with-env.js +45 -0
  35. package/logs/context.js +109 -0
  36. package/logs/contract-keys.js +67 -0
  37. package/logs/debug-shims.js +46 -0
  38. package/logs/denied-keys.js +21 -0
  39. package/logs/index.js +14 -3
  40. package/logs/jest-logger-setup.js +25 -0
  41. package/logs/levels.js +71 -0
  42. package/logs/logger-runtime.js +137 -0
  43. package/logs/logger.js +134 -42
  44. package/logs/record.js +276 -0
  45. package/logs/redact.js +328 -0
  46. package/logs/serialize.js +329 -0
  47. package/logs/sinks.js +93 -0
  48. package/logs/summarize-event.js +163 -0
  49. package/modules/module.js +32 -19
  50. package/modules/requester/oauth-2.js +28 -22
  51. package/modules/requester/requester.js +69 -6
  52. package/package.json +5 -6
  53. package/queues/queue-delivery.js +41 -0
  54. package/queues/queuer-util.js +1 -15
  55. package/telemetry/bind-telemetry-context.js +4 -0
  56. package/telemetry/instrument-handler.js +19 -2
  57. package/telemetry/no-op-telemetry.js +4 -0
  58. package/telemetry/otel-telemetry.js +25 -6
  59. package/telemetry/telemetry-context.js +19 -8
  60. package/telemetry/telemetry-runtime.js +17 -0
  61. package/telemetry/telemetry-service.js +1 -0
  62. package/types/errors/index.d.ts +24 -10
  63. package/types/logs/index.d.ts +61 -2
  64. package/user/repositories/user-repository-documentdb.js +10 -42
@@ -21,6 +21,7 @@ const { validateExtensionBinding } = require('./extension');
21
21
  const { getTelemetry } = require('../telemetry/telemetry-runtime');
22
22
  const { instrumentHandler } = require('../telemetry/instrument-handler');
23
23
  const { bindTelemetryContext } = require('../telemetry/bind-telemetry-context');
24
+ const { getLogger } = require('../logs');
24
25
 
25
26
  const constantsToBeMigrated = {
26
27
  defaultEvents: {
@@ -64,6 +65,10 @@ class IntegrationBase {
64
65
  this.getTelemetryContext()
65
66
  );
66
67
 
68
+ logger = getLogger(
69
+ `integration.${this.constructor.Definition?.name ?? 'unknown'}`
70
+ ).child(() => this.getLoggerBindings());
71
+
67
72
  static getOptionDetails() {
68
73
  const options = new Options({
69
74
  module: Object.values(this.Definition.modules)[0], // This is a placeholder until we revamp the frontend
@@ -254,6 +259,14 @@ class IntegrationBase {
254
259
  };
255
260
  }
256
261
 
262
+ // Stage and appName are record resource fields; binding them would
263
+ // only land in droppedKeys.
264
+ getLoggerBindings() {
265
+ const { integrationId, integrationType, userId, version } =
266
+ this.getTelemetryContext();
267
+ return { integrationId, integrationType, userId, version };
268
+ }
269
+
257
270
  assertHydrated(message = 'Integration instance is not hydrated') {
258
271
  if (!this.isHydrated) {
259
272
  throw new Error(message);
@@ -411,15 +424,15 @@ class IntegrationBase {
411
424
  */
412
425
  async reconcileAuthStatus(authPassed) {
413
426
  if (!authPassed) {
414
- console.log(
415
- `[Frigg] Integration ${this.id} failed to authenticate`
416
- );
427
+ this.logger.warn('Integration failed to authenticate', {
428
+ eventName: `${this.logger.name}.auth_failed`,
429
+ });
417
430
  await this.persistStatus('ERROR');
418
431
  }
419
432
  if (authPassed && this.status === 'ERROR') {
420
- console.log(
421
- `[Frigg] auth confirmed for integration ${this.id} — clearing ERROR → ENABLED`
422
- );
433
+ this.logger.info('Auth confirmed, clearing ERROR', {
434
+ eventName: `${this.logger.name}.auth_confirmed`,
435
+ });
423
436
  await this.persistStatus('ENABLED');
424
437
  }
425
438
  }
@@ -615,12 +628,22 @@ class IntegrationBase {
615
628
  this[key] = module;
616
629
  }
617
630
 
631
+ // The raw error can echo a request with its credentials, and messages
632
+ // reach end users, so only a fixed text is stored.
618
633
  addError(error) {
619
634
  if (!this.messages.errors) {
620
635
  this.messages.errors = [];
621
636
  }
622
- this.messages.errors.push(error);
637
+ this.messages.errors.push({
638
+ title: 'Integration Error',
639
+ message: `Integration ${this.id} hit an error. Contact support.`,
640
+ timestamp: Date.now(),
641
+ });
623
642
  this.status = 'ERROR';
643
+ this.logger.error('Integration error recorded', {
644
+ eventName: `${this.logger.name}.error_recorded`,
645
+ error,
646
+ });
624
647
  }
625
648
 
626
649
  addWarning(warning) {
@@ -639,9 +662,10 @@ class IntegrationBase {
639
662
  async persistStatus(status) {
640
663
  await this.updateIntegrationStatus.execute(this.id, status);
641
664
  this.status = status;
642
- console.log(
643
- `[Frigg] Integration ${this.id} status changed to ${status}`
644
- );
665
+ this.logger.info('Integration status changed', {
666
+ eventName: `${this.logger.name}.status_changed`,
667
+ integrationStatus: status,
668
+ });
645
669
  }
646
670
 
647
671
  /**
@@ -753,10 +777,14 @@ class IntegrationBase {
753
777
  // it wins. Warn if the binding tried to wire an override that's now ignored.
754
778
  if (this.events[eventName]) {
755
779
  if (typeof handlers[eventName] === 'string') {
756
- console.warn(
757
- `[Frigg] Integration "${integrationName}" binding "${bindingName}": ` +
758
- `handler "${handlers[eventName]}" for event "${eventName}" is ignored because ` +
759
- `this.events["${eventName}"] was already set (subclass constructor or earlier merge)`
780
+ this.logger.warn(
781
+ 'Binding handler is ignored: the event is already set',
782
+ {
783
+ eventName: `${this.logger.name}.extension_handler_shadowed`,
784
+ bindingName,
785
+ handler: handlers[eventName],
786
+ event: eventName,
787
+ }
760
788
  );
761
789
  }
762
790
  continue;
@@ -858,19 +886,12 @@ class IntegrationBase {
858
886
  if (this.status === 'ERROR') return;
859
887
 
860
888
  const moduleName = notifier?.name;
861
- const detail =
862
- object?.reason || object?.statusCode
863
- ? ` (status ${object?.statusCode ?? '?'}: ${
864
- object?.reason ?? 'no reason given'
865
- })`
866
- : '';
867
- console.log(
868
- `[Frigg] Module ${
869
- moduleName || '?'
870
- } reported invalid credentials for integration ${
871
- this.id
872
- } — marking ERROR${detail}`
873
- );
889
+ this.logger.warn('Module reported invalid credentials, marking ERROR', {
890
+ eventName: `${this.logger.name}.credentials_invalidated`,
891
+ moduleName,
892
+ statusCode: object?.statusCode,
893
+ reason: object?.reason,
894
+ });
874
895
  await this._recordCredentialRejection(
875
896
  moduleName,
876
897
  object?.statusCode
@@ -881,13 +902,10 @@ class IntegrationBase {
881
902
 
882
903
  if (delegateString === 'CREDENTIAL_VALIDATED') {
883
904
  if (this.status !== 'ERROR') return;
884
- console.log(
885
- `[Frigg] Module ${
886
- notifier?.name || '?'
887
- } reported valid credentials for integration ${
888
- this.id
889
- } — clearing ERROR → ENABLED`
890
- );
905
+ this.logger.info('Module reported valid credentials, clearing ERROR', {
906
+ eventName: `${this.logger.name}.credentials_validated`,
907
+ moduleName: notifier?.name,
908
+ });
891
909
  await this.persistStatus('ENABLED');
892
910
  }
893
911
  }
@@ -909,10 +927,10 @@ class IntegrationBase {
909
927
  Date.now()
910
928
  );
911
929
  } catch (error) {
912
- console.error(
913
- `[Frigg] Failed to record credential rejection for integration ${this.id}:`,
914
- error
915
- );
930
+ this.logger.error('Failed to record credential rejection', {
931
+ eventName: `${this.logger.name}.credential_rejection_record_failed`,
932
+ error,
933
+ });
916
934
  }
917
935
  }
918
936
  }
@@ -2,6 +2,7 @@ const express = require('express');
2
2
  const { get } = require('../assertions');
3
3
  const Boom = require('@hapi/boom');
4
4
  const catchAsyncError = require('express-async-handler');
5
+ const { getLogger } = require('../logs');
5
6
  const {
6
7
  createIntegrationRepository,
7
8
  } = require('./repositories/integration-repository-factory');
@@ -70,6 +71,8 @@ const {
70
71
  ProcessAuthorizationCallback,
71
72
  } = require('../modules/use-cases/process-authorization-callback');
72
73
 
74
+ const log = getLogger('frigg.integrations');
75
+
73
76
  function createIntegrationRouter() {
74
77
  const { integrations: integrationClasses, userConfig } =
75
78
  loadAppDefinition();
@@ -536,29 +539,27 @@ function setEntityRoutes(router, authenticateUser, useCases) {
536
539
  params.data && typeof params.data === 'object'
537
540
  ? Object.keys(params.data)
538
541
  : [];
539
- console.log(
540
- `[Frigg] POST /api/authorize userId=${userId} entityType=${params.entityType} dataKeys=${JSON.stringify(dataKeys)}`
541
- );
542
+ log.debug('Authorize requested', {
543
+ userId,
544
+ entityType: params.entityType,
545
+ dataKeys,
546
+ });
542
547
 
543
- try {
544
- const entityDetails =
545
- await processAuthorizationCallback.execute(
546
- userId,
547
- params.entityType,
548
- params.data
549
- );
548
+ const entityDetails = await processAuthorizationCallback.execute(
549
+ userId,
550
+ params.entityType,
551
+ params.data
552
+ );
550
553
 
551
- console.log(
552
- `[Frigg] POST /api/authorize success userId=${userId} entityType=${params.entityType} credentialId=${entityDetails?.credential_id} entityId=${entityDetails?.entity_id}`
553
- );
554
+ log.info('Entity authorized', {
555
+ eventName: 'frigg.integrations.authorized',
556
+ userId,
557
+ entityType: params.entityType,
558
+ credentialId: entityDetails?.credential_id,
559
+ entityId: entityDetails?.entity_id,
560
+ });
554
561
 
555
- res.json(entityDetails);
556
- } catch (err) {
557
- console.error(
558
- `[Frigg] POST /api/authorize failed userId=${userId} entityType=${params.entityType} error=${err?.message || err}`
559
- );
560
- throw err;
561
- }
562
+ res.json(entityDetails);
562
563
  })
563
564
  );
564
565
 
@@ -1,4 +1,7 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { getLogger } = require('../../logs');
3
+
4
+ const log = getLogger('frigg.integrations');
2
5
  // Removed Integration wrapper - using IntegrationBase directly
3
6
 
4
7
  /**
@@ -65,17 +68,25 @@ class DeleteIntegrationForUser {
65
68
  );
66
69
  modules.push(moduleInstance);
67
70
  } catch (error) {
68
- console.error(
69
- `[Integration Deletion] Failed to load module for entity ${entityId}:`,
70
- error.message
71
- );
71
+ log.warn('Failed to load module for deletion', {
72
+ eventName: 'frigg.integrations.delete_module_load_failed',
73
+ integrationId,
74
+ entityId,
75
+ error,
76
+ });
72
77
  failedModuleLoads.push({ entityId, error: error.message });
73
78
  }
74
79
  }
75
80
 
76
81
  if (failedModuleLoads.length > 0) {
77
- console.warn(
78
- `[Integration Deletion] ${failedModuleLoads.length}/${integrationRecord.entitiesIds.length} module(s) failed to load. Webhooks for these modules may require manual cleanup.`
82
+ log.warn(
83
+ 'Some modules failed to load. Webhooks for these modules may require manual cleanup.',
84
+ {
85
+ eventName: 'frigg.integrations.delete_modules_incomplete',
86
+ integrationId,
87
+ failedCount: failedModuleLoads.length,
88
+ totalCount: integrationRecord.entitiesIds.length,
89
+ }
79
90
  );
80
91
  }
81
92
 
@@ -97,19 +108,19 @@ class DeleteIntegrationForUser {
97
108
  try {
98
109
  await integrationInstance.send('ON_DELETE');
99
110
  } catch (error) {
100
- const reason = error?.message ?? String(error);
101
- console.error(
102
- `[Integration Deletion] onDelete failed for integration ${integrationId}, leaving it IN_DELETION:`,
103
- reason
104
- );
111
+ // The error text can echo a request with its credentials and
112
+ // messages reach end users; the boundary logs the cause once.
105
113
  await integrationInstance.updateIntegrationMessages.execute(
106
114
  integrationId,
107
115
  'errors',
108
116
  'Integration Deletion Error',
109
- `Deletion did not complete: ${reason}`,
117
+ 'Deletion did not complete. Contact support.',
110
118
  Date.now()
111
119
  );
112
- throw error;
120
+ throw new Error(
121
+ `Integration ${integrationId} deletion did not complete`,
122
+ { cause: error }
123
+ );
113
124
  }
114
125
 
115
126
  await this.integrationRepository.deleteIntegrationById(integrationId);
package/jest.config.js CHANGED
@@ -18,4 +18,6 @@ module.exports = {
18
18
 
19
19
  // A path to a module which exports an async function that is triggered once after all test suites
20
20
  globalTeardown: './jest-teardown.js',
21
+
22
+ setupFilesAfterEnv: ['./logs/jest-logger-setup.js'],
21
23
  };
@@ -1,3 +1,28 @@
1
1
  {
2
- "extends": "@friggframework/eslint-config"
2
+ "extends": "@friggframework/eslint-config",
3
+ "rules": {
4
+ "no-console": "error",
5
+ "no-restricted-syntax": [
6
+ "error",
7
+ {
8
+ "selector": "CallExpression[callee.object.object.name='process'][callee.object.property.name=/^(stdout|stderr)$/][callee.property.name='write']",
9
+ "message": "Write through the logger; only logs/sinks.js writes to fd 1"
10
+ }
11
+ ]
12
+ },
13
+ "overrides": [
14
+ {
15
+ "files": ["*.test.js", "__fixtures__/**", "jest-logger-setup.js"],
16
+ "rules": {
17
+ "no-console": "off",
18
+ "no-restricted-syntax": "off"
19
+ }
20
+ },
21
+ {
22
+ "files": ["sinks.js"],
23
+ "rules": {
24
+ "no-restricted-syntax": "off"
25
+ }
26
+ }
27
+ ]
3
28
  }
@@ -0,0 +1,82 @@
1
+ const { SECRETS } = require('./secrets');
2
+
3
+ function httpApiV2Event() {
4
+ return {
5
+ version: '2.0',
6
+ routeKey: 'GET /api/authorize',
7
+ rawPath: '/api/authorize',
8
+ rawQueryString: `code=${SECRETS.oauthCode}&state=abc123&api_key=${SECRETS.apiKeyQuery}`,
9
+ cookies: [`session=${SECRETS.cookie}`],
10
+ headers: {
11
+ 'x-frigg-api-key': SECRETS.friggApiKey,
12
+ authorization: `Bearer ${SECRETS.bearer}`,
13
+ cookie: `session=${SECRETS.cookie}`,
14
+ 'content-type': 'application/json',
15
+ },
16
+ queryStringParameters: {
17
+ code: SECRETS.oauthCode,
18
+ state: 'abc123',
19
+ api_key: SECRETS.apiKeyQuery,
20
+ },
21
+ requestContext: {
22
+ http: { method: 'GET', path: '/api/authorize', sourceIp: '203.0.113.9' },
23
+ requestId: 'req-v2-1',
24
+ },
25
+ body: JSON.stringify({
26
+ code: SECRETS.oauthCode,
27
+ code_verifier: SECRETS.codeVerifier,
28
+ client_secret: SECRETS.clientSecret,
29
+ }),
30
+ isBase64Encoded: false,
31
+ };
32
+ }
33
+
34
+ function restV1Event() {
35
+ return {
36
+ resource: '/api/{proxy+}',
37
+ path: '/api/integrations',
38
+ httpMethod: 'POST',
39
+ headers: {
40
+ Authorization: `Basic ${Buffer.from(`user:${SECRETS.password}`).toString('base64')}`,
41
+ 'X-Frigg-Api-Key': SECRETS.friggApiKey,
42
+ Cookie: `session=${SECRETS.cookie}`,
43
+ },
44
+ multiValueHeaders: {
45
+ Authorization: [`Basic ${Buffer.from(`user:${SECRETS.password}`).toString('base64')}`],
46
+ 'Set-Cookie': [`session=${SECRETS.cookie}`],
47
+ },
48
+ queryStringParameters: { access_token: SECRETS.accessToken },
49
+ multiValueQueryStringParameters: { access_token: [SECRETS.accessToken] },
50
+ requestContext: { requestId: 'req-v1-1', stage: 'dev' },
51
+ body: JSON.stringify({ password: SECRETS.password, refresh_token: SECRETS.refreshToken }),
52
+ isBase64Encoded: false,
53
+ };
54
+ }
55
+
56
+ function sqsEvent() {
57
+ return {
58
+ Records: [
59
+ {
60
+ messageId: 'msg-1',
61
+ receiptHandle: 'receipt-1',
62
+ attributes: { ApproximateReceiveCount: '1' },
63
+ body: JSON.stringify({
64
+ event: 'PROCESS_BATCH',
65
+ data: {
66
+ processId: 'proc-1',
67
+ integrationId: 'int-1',
68
+ access_token: SECRETS.accessToken,
69
+ credentials: { client_secret: SECRETS.clientSecret },
70
+ },
71
+ }),
72
+ },
73
+ {
74
+ messageId: 'msg-2',
75
+ attributes: { ApproximateReceiveCount: '3' },
76
+ body: `not json Authorization: Bearer ${SECRETS.bearer}`,
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ module.exports = { httpApiV2Event, restV1Event, sqsEvent };
@@ -0,0 +1,45 @@
1
+ const WINDOW = 8;
2
+
3
+ function forms(secret) {
4
+ return [
5
+ secret,
6
+ encodeURIComponent(secret),
7
+ Buffer.from(secret).toString('base64'),
8
+ ];
9
+ }
10
+
11
+ function windows(value) {
12
+ if (value.length <= WINDOW) return [value];
13
+ const result = [];
14
+ for (let i = 0; i + WINDOW <= value.length; i += 1) {
15
+ result.push(value.slice(i, i + WINDOW));
16
+ }
17
+ return result;
18
+ }
19
+
20
+ function findSecretWindow(received, secrets) {
21
+ const haystack =
22
+ typeof received === 'string' ? received : JSON.stringify(received);
23
+ for (const secret of secrets) {
24
+ for (const form of forms(secret)) {
25
+ for (const w of windows(form)) {
26
+ if (haystack.includes(w)) return { secret, window: w };
27
+ }
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function toContainNoSecretWindow(received, secrets) {
34
+ const list = Array.isArray(secrets) ? secrets : Object.values(secrets);
35
+ const hit = findSecretWindow(received, list);
36
+ return {
37
+ pass: hit === null,
38
+ message: () =>
39
+ hit
40
+ ? `expected no ${WINDOW}-char window of a secret, found "${hit.window}" (from a secret of length ${hit.secret.length})`
41
+ : 'expected a secret window, found none',
42
+ };
43
+ }
44
+
45
+ module.exports = { toContainNoSecretWindow, findSecretWindow, WINDOW };
@@ -0,0 +1,59 @@
1
+ // Fake values, generated at load time so secret scanners find no literal in
2
+ // the source. A fixed seed per name keeps every run identical.
3
+ const MIXED = 'BCDFGHJKLMNPQRSTVWXZbcdfghjkmnpqrstvwxz23456789';
4
+ const HEX = '0123456789abcdef';
5
+
6
+ function seededRandom(name) {
7
+ let seed = [...name].reduce(
8
+ (h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619),
9
+ 2166136261
10
+ );
11
+ return () => {
12
+ seed = (seed + 0x6d2b79f5) | 0;
13
+ let t = Math.imul(seed ^ (seed >>> 15), seed | 1);
14
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
15
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
+ };
17
+ }
18
+
19
+ function draw(name, alphabet, length) {
20
+ const next = seededRandom(name);
21
+ let out = '';
22
+ for (let i = 0; i < length; i += 1) {
23
+ out += alphabet[Math.floor(next() * alphabet.length)];
24
+ }
25
+ return out;
26
+ }
27
+
28
+ // The fixed 'Zq' + 'B7k' start puts upper case, lower case and a digit in every token.
29
+ function fakeToken(name, length = 28) {
30
+ return `ZqB7k${draw(name, MIXED, length - 5)}`;
31
+ }
32
+
33
+ const base64url = (value) => Buffer.from(value).toString('base64url');
34
+
35
+ const SECRETS = Object.freeze({
36
+ bearer: fakeToken('bearer'),
37
+ apiKeyQuery: fakeToken('apiKeyQuery'),
38
+ friggApiKey: fakeToken('friggApiKey'),
39
+ cookie: fakeToken('cookie'),
40
+ oauthCode: fakeToken('oauthCode'),
41
+ codeVerifier: fakeToken('codeVerifier'),
42
+ password: fakeToken('password'),
43
+ clientSecret: fakeToken('clientSecret'),
44
+ accessToken: fakeToken('accessToken'),
45
+ refreshToken: fakeToken('refreshToken'),
46
+ idToken: fakeToken('idToken'),
47
+ hashword: fakeToken('hashword'),
48
+ dbPassword: fakeToken('dbPassword'),
49
+ signature: fakeToken('signature'),
50
+ jwt: [
51
+ base64url(JSON.stringify({ alg: 'HS256' })),
52
+ base64url(JSON.stringify({ sub: `fake-${draw('jwtSub', MIXED, 8)}` })),
53
+ base64url(draw('jwtSig', MIXED, 24)),
54
+ ].join('.'),
55
+ hexToken: draw('hexToken', HEX, 40),
56
+ base64Run: Buffer.from(draw('base64Run', MIXED, 42)).toString('base64'),
57
+ });
58
+
59
+ module.exports = { SECRETS, fakeToken };