@friggframework/core 2.0.0--canary.545.302ab9b.0 → 2.0.0--canary.545.a8d08b4.0

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/core/Worker.js CHANGED
@@ -1,23 +1,41 @@
1
- const {
2
- SQSClient,
3
- GetQueueUrlCommand,
4
- SendMessageCommand,
5
- } = require('@aws-sdk/client-sqs');
6
1
  const _ = require('lodash');
7
2
  const { RequiredPropertyError } = require('../errors');
8
3
  const { get } = require('../assertions');
9
4
 
10
- const sqs = new SQSClient({ region: process.env.AWS_REGION });
11
-
5
+ /**
6
+ * Worker - Queue message producer/consumer base class.
7
+ *
8
+ * Subclass and override _run() to implement your worker logic.
9
+ * The queue transport (SQS, Netlify, etc.) is abstracted behind
10
+ * QueueClientInterface, injected via constructor options.
11
+ *
12
+ * BREAKING CHANGE (v3): A queueClient must be explicitly provided.
13
+ * For AWS/SQS, pass `new SqsQueueClient()` from @friggframework/provider-aws.
14
+ * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.
15
+ */
12
16
  class Worker {
17
+ constructor(options = {}) {
18
+ this._queueClient = options.queueClient || null;
19
+ }
20
+
21
+ /**
22
+ * Get the queue client. Throws if none was injected.
23
+ * @returns {QueueClientInterface}
24
+ */
25
+ _getQueueClient() {
26
+ if (!this._queueClient) {
27
+ throw new Error(
28
+ 'Worker requires a queueClient. Pass one via constructor options, e.g.:\n' +
29
+ ' const { SqsQueueClient } = require("@friggframework/provider-aws");\n' +
30
+ ' new MyWorker({ queueClient: new SqsQueueClient() })\n' +
31
+ 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.'
32
+ );
33
+ }
34
+ return this._queueClient;
35
+ }
36
+
13
37
  async getQueueURL(params) {
14
- // Passing params in because there will be multiple QueueNames
15
- // let params = {
16
- // QueueName: process.env.QueueName
17
- // };
18
- const command = new GetQueueUrlCommand(params);
19
- const data = await sqs.send(command);
20
- return data.QueueUrl;
38
+ return this._getQueueClient().getQueueUrl(params);
21
39
  }
22
40
 
23
41
  async run(params, context = {}) {
@@ -51,8 +69,7 @@ class Worker {
51
69
  }
52
70
 
53
71
  async sendAsyncSQSMessage(params) {
54
- const command = new SendMessageCommand(params);
55
- const data = await sqs.send(command);
72
+ const data = await this._getQueueClient().sendMessage(params);
56
73
  return data.MessageId;
57
74
  }
58
75
 
@@ -1,13 +1,31 @@
1
+ /**
2
+ * WebsocketConnection Mongoose Model
3
+ *
4
+ * BREAKING CHANGE (v3): Call WebsocketConnection.setMessageSender() before
5
+ * using getActiveConnections(). For AWS API Gateway, pass
6
+ * `new ApiGatewayMessageSender()` from @friggframework/provider-aws.
7
+ * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.
8
+ */
1
9
  const { mongoose } = require('../mongoose');
2
10
  const {
3
- ApiGatewayManagementApiClient,
4
- PostToConnectionCommand,
5
- } = require('@aws-sdk/client-apigatewaymanagementapi');
11
+ StaleConnectionError,
12
+ } = require('../../websocket/websocket-message-sender-interface');
13
+
14
+ let _messageSender = null;
6
15
 
7
16
  const schema = new mongoose.Schema({
8
17
  connectionId: { type: mongoose.Schema.Types.String },
9
18
  });
10
19
 
20
+ /**
21
+ * Set the message sender for WebSocket send functionality.
22
+ * Must be called before getActiveConnections().
23
+ * @param {WebSocketMessageSenderInterface} sender
24
+ */
25
+ schema.statics.setMessageSender = function (sender) {
26
+ _messageSender = sender;
27
+ };
28
+
11
29
  // Add a static method to get active connections
12
30
  schema.statics.getActiveConnections = async function () {
13
31
  try {
@@ -16,25 +34,28 @@ schema.statics.getActiveConnections = async function () {
16
34
  return [];
17
35
  }
18
36
 
37
+ if (!_messageSender) {
38
+ throw new Error(
39
+ 'WebsocketConnection requires a message sender. Call WebsocketConnection.setMessageSender() first, e.g.:\n' +
40
+ ' const { ApiGatewayMessageSender } = require("@friggframework/provider-aws");\n' +
41
+ ' WebsocketConnection.setMessageSender(new ApiGatewayMessageSender());\n' +
42
+ 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.'
43
+ );
44
+ }
45
+
46
+ const sender = _messageSender;
19
47
  const connections = await this.find({}, 'connectionId');
20
48
  return connections.map((conn) => ({
21
49
  connectionId: conn.connectionId,
22
50
  send: async (data) => {
23
- const apigwManagementApi = new ApiGatewayManagementApiClient({
24
- endpoint: process.env.WEBSOCKET_API_ENDPOINT,
25
- });
26
-
27
51
  try {
28
- const command = new PostToConnectionCommand({
29
- ConnectionId: conn.connectionId,
30
- Data: JSON.stringify(data),
31
- });
32
- await apigwManagementApi.send(command);
52
+ await sender.send(
53
+ conn.connectionId,
54
+ data,
55
+ process.env.WEBSOCKET_API_ENDPOINT
56
+ );
33
57
  } catch (error) {
34
- if (
35
- error.statusCode === 410 ||
36
- error.$metadata?.httpStatusCode === 410
37
- ) {
58
+ if (error instanceof StaleConnectionError) {
38
59
  console.log(`Stale connection ${conn.connectionId}`);
39
60
  await this.deleteOne({
40
61
  connectionId: conn.connectionId,
@@ -1,64 +1,75 @@
1
1
  /**
2
2
  * Cryptor - Encryption Service Adapter
3
3
  *
4
- * Infrastructure Layer adapter for AWS KMS and local AES encryption.
5
- * Provides envelope encryption pattern for field-level encryption.
4
+ * Infrastructure Layer adapter for envelope encryption.
5
+ * Key management is delegated to an EncryptionKeyProviderInterface adapter:
6
+ * - KmsEncryptionKeyProvider (in @friggframework/provider-aws) for AWS KMS
7
+ * - AesEncryptionKeyProvider (in core) for local AES keys
6
8
  *
7
9
  * Envelope Encryption Pattern:
8
- * 1. Generate Data Encryption Key (DEK) via KMS or locally
10
+ * 1. Generate Data Encryption Key (DEK) via key provider
9
11
  * 2. Encrypt field value with DEK using AES-256-CTR
10
- * 3. Encrypt DEK with Master Key (KMS CMK or AES_KEY)
12
+ * 3. Store encrypted DEK alongside ciphertext
11
13
  * 4. Return format: "keyId:encryptedText:encryptedKey"
12
14
  *
13
- * Benefits:
14
- * - Reduces KMS API calls (unique DEK per operation)
15
- * - Master key never leaves KMS
16
- * - Enables key rotation without re-encrypting data
15
+ * BREAKING CHANGE (v3): A keyProvider must be explicitly provided,
16
+ * or pass { shouldUseAws: false } to auto-create AesEncryptionKeyProvider.
17
+ * For AWS KMS, pass `new KmsEncryptionKeyProvider()` from @friggframework/provider-aws.
18
+ * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.
17
19
  */
18
20
 
19
- const crypto = require('crypto');
20
- const {
21
- KMSClient,
22
- GenerateDataKeyCommand,
23
- DecryptCommand,
24
- } = require('@aws-sdk/client-kms');
25
21
  const aes = require('./aes');
26
22
 
27
23
  class Cryptor {
28
- constructor({ shouldUseAws }) {
29
- this.shouldUseAws = shouldUseAws;
30
- }
31
-
32
- async generateDataKey() {
33
- if (this.shouldUseAws) {
34
- const kmsClient = new KMSClient({});
35
- const command = new GenerateDataKeyCommand({
36
- KeyId: process.env.KMS_KEY_ARN,
37
- KeySpec: 'AES_256',
38
- });
39
- const dataKey = await kmsClient.send(command);
40
-
41
- const keyId = Buffer.from(dataKey.KeyId).toString('base64');
42
- const encryptedKey = Buffer.from(dataKey.CiphertextBlob).toString(
43
- 'base64'
24
+ /**
25
+ * @param {Object} options
26
+ * @param {boolean} [options.shouldUseAws] - true requires explicit keyProvider; false auto-creates AesEncryptionKeyProvider
27
+ * @param {EncryptionKeyProviderInterface} [options.keyProvider] - Explicit key provider (takes precedence)
28
+ */
29
+ constructor({ shouldUseAws, keyProvider } = {}) {
30
+ if (keyProvider) {
31
+ this._keyProvider = keyProvider;
32
+ } else if (shouldUseAws) {
33
+ throw new Error(
34
+ 'Cryptor with shouldUseAws=true requires an explicit keyProvider. Pass one via constructor options, e.g.:\n' +
35
+ ' const { KmsEncryptionKeyProvider } = require("@friggframework/provider-aws");\n' +
36
+ ' new Cryptor({ shouldUseAws: true, keyProvider: new KmsEncryptionKeyProvider() })\n' +
37
+ 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.'
44
38
  );
45
- const plaintext = dataKey.Plaintext;
46
- return { keyId, encryptedKey, plaintext };
39
+ } else {
40
+ // AES mode no AWS dependency needed
41
+ const { AesEncryptionKeyProvider } = require('./aes-encryption-key-provider');
42
+ this._keyProvider = new AesEncryptionKeyProvider();
47
43
  }
44
+ this._shouldUseAws = shouldUseAws;
45
+ }
48
46
 
49
- const { AES_KEY, AES_KEY_ID } = process.env;
50
- const randomKey = crypto.randomBytes(32).toString('hex').slice(0, 32);
47
+ /**
48
+ * Get the key provider.
49
+ * @returns {EncryptionKeyProviderInterface}
50
+ */
51
+ _getKeyProvider() {
52
+ return this._keyProvider;
53
+ }
51
54
 
52
- return {
53
- keyId: Buffer.from(AES_KEY_ID).toString('base64'),
54
- encryptedKey: Buffer.from(aes.encrypt(randomKey, AES_KEY)).toString(
55
- 'base64'
56
- ),
57
- plaintext: randomKey,
58
- };
55
+ async generateDataKey() {
56
+ return this._getKeyProvider().generateDataKey();
59
57
  }
60
58
 
59
+ /**
60
+ * Look up an AES key by identifier from environment variables.
61
+ * Kept for backward compatibility.
62
+ *
63
+ * @param {string} keyId - Key identifier
64
+ * @returns {string} The master key
65
+ */
61
66
  getKeyFromEnvironment(keyId) {
67
+ const provider = this._getKeyProvider();
68
+ if (typeof provider.getKeyFromEnvironment === 'function') {
69
+ return provider.getKeyFromEnvironment(keyId);
70
+ }
71
+
72
+ // Fallback for providers that don't implement getKeyFromEnvironment
62
73
  const availableKeys = {
63
74
  [process.env.AES_KEY_ID]: process.env.AES_KEY,
64
75
  [process.env.DEPRECATED_AES_KEY_ID]: process.env.DEPRECATED_AES_KEY,
@@ -74,19 +85,7 @@ class Cryptor {
74
85
  }
75
86
 
76
87
  async decryptDataKey(keyId, encryptedKey) {
77
- if (this.shouldUseAws) {
78
- const kmsClient = new KMSClient({});
79
- const command = new DecryptCommand({
80
- KeyId: keyId,
81
- CiphertextBlob: encryptedKey,
82
- });
83
- const dataKey = await kmsClient.send(command);
84
-
85
- return dataKey.Plaintext;
86
- }
87
-
88
- const key = this.getKeyFromEnvironment(keyId);
89
- return aes.decrypt(encryptedKey, key);
88
+ return this._getKeyProvider().decryptDataKey(keyId, encryptedKey);
90
89
  }
91
90
 
92
91
  async encrypt(text) {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * AES Encryption Key Provider (Adapter)
3
+ *
4
+ * Local AES-based implementation of EncryptionKeyProviderInterface.
5
+ * Uses environment-variable-based master keys for envelope encryption.
6
+ *
7
+ * No external dependencies — works on any platform.
8
+ *
9
+ * Environment Variables:
10
+ * - AES_KEY_ID: Identifier for the current AES master key
11
+ * - AES_KEY: The current AES master key (32 chars)
12
+ * - DEPRECATED_AES_KEY_ID: (optional) Previous key ID for key rotation
13
+ * - DEPRECATED_AES_KEY: (optional) Previous key for decrypting old data
14
+ */
15
+
16
+ const crypto = require('crypto');
17
+ const aes = require('./aes');
18
+ const {
19
+ EncryptionKeyProviderInterface,
20
+ } = require('./encryption-key-provider-interface');
21
+
22
+ class AesEncryptionKeyProvider extends EncryptionKeyProviderInterface {
23
+ /**
24
+ * Generate a data encryption key using local AES
25
+ *
26
+ * Creates a random DEK and encrypts it with the AES master key
27
+ * from environment variables.
28
+ *
29
+ * @returns {Promise<{keyId: string, encryptedKey: string, plaintext: string}>}
30
+ */
31
+ async generateDataKey() {
32
+ const { AES_KEY, AES_KEY_ID } = process.env;
33
+ const randomKey = crypto.randomBytes(32).toString('hex').slice(0, 32);
34
+
35
+ return {
36
+ keyId: Buffer.from(AES_KEY_ID).toString('base64'),
37
+ encryptedKey: Buffer.from(aes.encrypt(randomKey, AES_KEY)).toString(
38
+ 'base64'
39
+ ),
40
+ plaintext: randomKey,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Decrypt a data encryption key using the AES master key
46
+ *
47
+ * Looks up the master key by keyId from environment variables,
48
+ * supporting key rotation via DEPRECATED_AES_KEY.
49
+ *
50
+ * @param {string} keyId - Key identifier to look up in environment
51
+ * @param {string|Buffer} encryptedKey - Encrypted DEK to decrypt
52
+ * @returns {Promise<string>} Decrypted plaintext DEK
53
+ */
54
+ async decryptDataKey(keyId, encryptedKey) {
55
+ const key = this.getKeyFromEnvironment(keyId);
56
+ return aes.decrypt(encryptedKey, key);
57
+ }
58
+
59
+ /**
60
+ * Look up an AES master key by its identifier
61
+ *
62
+ * @param {string} keyId - Key identifier
63
+ * @returns {string} The master key
64
+ * @throws {Error} If key not found in environment
65
+ */
66
+ getKeyFromEnvironment(keyId) {
67
+ const availableKeys = {
68
+ [process.env.AES_KEY_ID]: process.env.AES_KEY,
69
+ [process.env.DEPRECATED_AES_KEY_ID]: process.env.DEPRECATED_AES_KEY,
70
+ };
71
+
72
+ const key = availableKeys[keyId];
73
+
74
+ if (!key) {
75
+ throw new Error('Encryption key not found');
76
+ }
77
+
78
+ return key;
79
+ }
80
+ }
81
+
82
+ module.exports = { AesEncryptionKeyProvider };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Encryption Key Provider Interface (Port)
3
+ *
4
+ * Defines the contract for envelope encryption key operations.
5
+ * Used by Cryptor for generating and decrypting data encryption keys.
6
+ *
7
+ * Following Frigg's hexagonal architecture pattern:
8
+ * - Port defines WHAT the service does (contract)
9
+ * - Adapters implement HOW (AWS KMS, local AES, Vault, etc.)
10
+ *
11
+ * Envelope Encryption Pattern:
12
+ * 1. generateDataKey() creates a fresh DEK (plaintext + encrypted form)
13
+ * 2. Caller encrypts data with the plaintext DEK
14
+ * 3. Caller stores the encrypted DEK alongside the ciphertext
15
+ * 4. decryptDataKey() recovers the plaintext DEK from the encrypted form
16
+ * 5. Caller decrypts data with the recovered DEK
17
+ */
18
+ class EncryptionKeyProviderInterface {
19
+ /**
20
+ * Generate a new data encryption key
21
+ *
22
+ * Returns both the plaintext key (for immediate encryption) and an
23
+ * encrypted copy (for storage alongside the ciphertext).
24
+ *
25
+ * @returns {Promise<{keyId: string, encryptedKey: string, plaintext: string|Buffer}>}
26
+ * - keyId: Base64-encoded identifier for the master key used
27
+ * - encryptedKey: Base64-encoded encrypted copy of the DEK
28
+ * - plaintext: The raw DEK (string or Buffer) for immediate use
29
+ */
30
+ async generateDataKey() {
31
+ throw new Error(
32
+ 'Method generateDataKey must be implemented by subclass'
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Decrypt a previously encrypted data encryption key
38
+ *
39
+ * @param {string} keyId - Identifier of the master key used for encryption
40
+ * @param {string|Buffer} encryptedKey - The encrypted DEK to decrypt
41
+ * @returns {Promise<string|Buffer>} The decrypted plaintext DEK
42
+ */
43
+ async decryptDataKey(keyId, encryptedKey) {
44
+ throw new Error(
45
+ 'Method decryptDataKey must be implemented by subclass'
46
+ );
47
+ }
48
+ }
49
+
50
+ module.exports = { EncryptionKeyProviderInterface };
@@ -21,9 +21,7 @@
21
21
  const { Router } = require('express');
22
22
  const catchAsyncError = require('express-async-handler');
23
23
  const { validateAdminApiKey } = require('../middleware/admin-auth');
24
- const {
25
- MigrationStatusRepositoryS3,
26
- } = require('../../database/repositories/migration-status-repository-s3');
24
+ const { resolveProvider } = require('../../providers/resolve-provider');
27
25
  const {
28
26
  TriggerDatabaseMigrationUseCase,
29
27
  ValidationError: TriggerValidationError,
@@ -33,39 +31,71 @@ const {
33
31
  ValidationError: GetValidationError,
34
32
  NotFoundError,
35
33
  } = require('../../database/use-cases/get-migration-status-use-case');
36
- const { LambdaInvoker } = require('../../database/adapters/lambda-invoker');
37
34
  const {
38
35
  GetDatabaseStateViaWorkerUseCase,
39
36
  } = require('../../database/use-cases/get-database-state-via-worker-use-case');
40
37
 
41
38
  const router = Router();
42
39
 
43
- // Dependency injection
44
- // Use S3 repository to avoid User table dependency (chicken-and-egg problem)
45
- const bucketName =
46
- process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET;
47
- const migrationStatusRepository = new MigrationStatusRepositoryS3(bucketName);
48
-
49
- const triggerMigrationUseCase = new TriggerDatabaseMigrationUseCase({
50
- migrationStatusRepository,
51
- // Note: QueuerUtil is used directly in the use case (static utility)
52
- });
53
- const getStatusUseCase = new GetMigrationStatusUseCase({
54
- migrationStatusRepository,
55
- });
56
-
57
- // Lambda invocation for database state check (keeps router lightweight)
58
- const lambdaInvoker = new LambdaInvoker();
59
- const workerFunctionName =
60
- process.env.WORKER_FUNCTION_NAME ||
61
- `${process.env.SERVICE || 'unknown'}-${
62
- process.env.STAGE || 'production'
63
- }-dbMigrationWorker`;
64
-
65
- const getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({
66
- lambdaInvoker,
67
- workerFunctionName,
68
- });
40
+ // Dependency injection — lazy-initialized on first request.
41
+ // Uses resolveProvider() to get the correct adapters for the active platform.
42
+ let _migrationStatusRepository = null;
43
+ let _triggerMigrationUseCase = null;
44
+ let _getStatusUseCase = null;
45
+ let _functionInvoker = null;
46
+
47
+ function getMigrationDeps() {
48
+ if (!_migrationStatusRepository) {
49
+ const provider = resolveProvider();
50
+
51
+ // Migration status storage (AWS: S3, others: provider-specific)
52
+ const MigrationStatusRepository = provider.MigrationStatusRepositoryS3;
53
+ if (!MigrationStatusRepository) {
54
+ throw new Error(
55
+ `Provider '${provider.name}' does not export a MigrationStatusRepository`
56
+ );
57
+ }
58
+ const bucketName =
59
+ process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET;
60
+ _migrationStatusRepository = new MigrationStatusRepository(bucketName);
61
+ _triggerMigrationUseCase = new TriggerDatabaseMigrationUseCase({
62
+ migrationStatusRepository: _migrationStatusRepository,
63
+ });
64
+ _getStatusUseCase = new GetMigrationStatusUseCase({
65
+ migrationStatusRepository: _migrationStatusRepository,
66
+ });
67
+
68
+ // Function invoker (AWS: LambdaInvoker, Netlify: HTTP-based)
69
+ _functionInvoker = provider.invokeFunctionAdapter;
70
+ if (!_functionInvoker) {
71
+ throw new Error(
72
+ `Provider '${provider.name}' does not export an invokeFunctionAdapter`
73
+ );
74
+ }
75
+ }
76
+ return {
77
+ migrationStatusRepository: _migrationStatusRepository,
78
+ triggerMigrationUseCase: _triggerMigrationUseCase,
79
+ getStatusUseCase: _getStatusUseCase,
80
+ lambdaInvoker: _functionInvoker,
81
+ };
82
+ }
83
+ let _getDatabaseStateUseCase = null;
84
+ function getDatabaseStateUseCaseInstance() {
85
+ if (!_getDatabaseStateUseCase) {
86
+ const { lambdaInvoker } = getMigrationDeps();
87
+ const workerFunctionName =
88
+ process.env.WORKER_FUNCTION_NAME ||
89
+ `${process.env.SERVICE || 'unknown'}-${
90
+ process.env.STAGE || 'production'
91
+ }-dbMigrationWorker`;
92
+ _getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({
93
+ lambdaInvoker,
94
+ workerFunctionName,
95
+ });
96
+ }
97
+ return _getDatabaseStateUseCase;
98
+ }
69
99
 
70
100
  // Apply admin API key validation to all routes (shared middleware)
71
101
  router.use(validateAdminApiKey);
@@ -106,7 +136,7 @@ router.post(
106
136
  );
107
137
 
108
138
  try {
109
- const result = await triggerMigrationUseCase.execute({
139
+ const result = await getMigrationDeps().triggerMigrationUseCase.execute({
110
140
  userId,
111
141
  dbType,
112
142
  stage,
@@ -153,12 +183,12 @@ router.get(
153
183
  const stage = req.query.stage || process.env.STAGE || 'production';
154
184
 
155
185
  console.log(
156
- `Checking database state: stage=${stage}, worker=${workerFunctionName}`
186
+ `Checking database state: stage=${stage}`
157
187
  );
158
188
 
159
189
  try {
160
190
  // Invoke worker Lambda to check database state
161
- const status = await getDatabaseStateUseCase.execute(stage);
191
+ const status = await getDatabaseStateUseCaseInstance().execute(stage);
162
192
 
163
193
  res.status(200).json(status);
164
194
  } catch (error) {
@@ -210,7 +240,7 @@ router.get(
210
240
  );
211
241
 
212
242
  try {
213
- const status = await getStatusUseCase.execute(migrationId, stage);
243
+ const status = await getMigrationDeps().getStatusUseCase.execute(migrationId, stage);
214
244
 
215
245
  res.status(200).json(status);
216
246
  } catch (error) {