@friggframework/core 2.0.0--canary.654.70623e2.0 → 2.0.0--canary.656.10f1676.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.
Files changed (33) hide show
  1. package/CLAUDE.md +132 -1
  2. package/core/CLAUDE.md +5 -2
  3. package/core/Worker.js +2 -1
  4. package/core/index.js +6 -0
  5. package/core/invocation-deadline.js +56 -0
  6. package/core/invocation-scope.js +24 -15
  7. package/database/documentdb-encryption-service.js +6 -2
  8. package/database/documentdb-utils.js +2 -1
  9. package/database/encryption/README.md +20 -0
  10. package/database/encryption/integration-mapping-encryption.js +97 -0
  11. package/database/encryption/prisma-encryption-extension.js +20 -9
  12. package/errors/fetch-error.js +12 -6
  13. package/errors/index.js +2 -0
  14. package/errors/rate-limit-error.js +44 -0
  15. package/handlers/backend-utils.js +4 -2
  16. package/handlers/integration-event-dispatcher.js +2 -2
  17. package/index.js +18 -0
  18. package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
  19. package/integrations/repositories/integration-mapping-query.js +193 -0
  20. package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
  21. package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
  22. package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
  23. package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
  24. package/logs/record.js +32 -7
  25. package/modules/requester/rate-limit/index.js +4 -0
  26. package/modules/requester/rate-limit/parsers.js +268 -0
  27. package/modules/requester/rate-limit/policy.js +323 -0
  28. package/modules/requester/requester.js +174 -6
  29. package/package.json +5 -5
  30. package/queues/queue-delivery.js +41 -0
  31. package/types/core/index.d.ts +42 -0
  32. package/types/errors/index.d.ts +56 -0
  33. package/types/module-plugin/index.d.ts +52 -0
package/CLAUDE.md CHANGED
@@ -211,7 +211,56 @@ packages/core/
211
211
  - `integration-repository-factory.js` - Creates database-specific repositories
212
212
  - `integration-repository-mongo.js` - MongoDB implementation
213
213
  - `integration-repository-postgres.js` - PostgreSQL implementation
214
- - `integration-mapping-repository-*.js` - Mapping data persistence
214
+ - `integration-mapping-repository-*.js` - Mapping data persistence.
215
+ `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
216
+ returns `{ mappings, total }`: one filtered, ordered page of an
217
+ integration's mappings, for callers that cannot load every row through
218
+ `findMappingsByIntegration`. `where` is an array of ANDed conditions
219
+ (at most 20); an entry may be `{ anyOf: [...] }`, ORed, one level deep.
220
+ Conditions are `{ path: 'mapping.<segment>...', op: 'exists' | 'notExists' }`
221
+ (JSON null counts as absent), `{ path: 'mapping.…', op: 'in', value: string[] }`
222
+ (1–500 strings) and `{ path: 'sourceId', op: 'notStartsWith', value }`
223
+ (a NULL sourceId matches). `orderBy` is `{ path: 'mapping.…', direction:
224
+ 'asc' | 'desc' }`, nulls last, ties broken by id in the same direction;
225
+ without it rows come in id order. `take` is 1–500. `omit` lists top-level
226
+ mapping keys to leave out of the rows; never write such rows back. Path
227
+ segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
228
+ DocumentDB adapters give the same pages and totals;
229
+ `integration-mapping-repository-query-parity.test.js` checks that against
230
+ real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
231
+ `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
232
+ strings compare by the database collation on PostgreSQL and by code point
233
+ on MongoDB and DocumentDB, and arrays or objects at the sort path order
234
+ among themselves only on PostgreSQL (the other adapters fall back to id).
235
+ The legacy `IntegrationMappingRepository` inherits the port's
236
+ "not supported by this database adapter yet" error. Every adapter refuses
237
+ to run while field-level encryption still encrypts
238
+ `IntegrationMapping.mapping` on write (see `database/encryption/README.md`),
239
+ and returns rows decrypted like `findMappingsByIntegration`. PostgreSQL and
240
+ MongoDB decrypt through `decryptQueriedMappings`
241
+ (`database/encryption/integration-mapping-encryption.js`), a no-op while
242
+ encryption is off or lists no `IntegrationMapping` field besides `mapping`.
243
+ Validation lives in `integration-mapping-query.js`; a new operator is one
244
+ entry in its `OPERATORS` table, one in the Postgres adapter's
245
+ `CONDITION_SQL` and one in `CONDITION_EXPRESSIONS` in
246
+ `integration-mapping-query-pipeline.js`, the aggregation stages both
247
+ MongoDB-protocol adapters share. Those stages may only use what Amazon
248
+ DocumentDB 4.0 and 5.0 support (no `$facet`, `$getField`, `$set` or
249
+ `$unset`).
250
+ **Cost**: PostgreSQL answers in one SQL statement, which reads every row
251
+ of the integration and evaluates the JSON paths per row, because no JSON
252
+ index exists. With ~4 KB mappings on PostgreSQL 16 that is about 0.3 s per
253
+ call at 10⁴ rows per integration and 2.5–3 s at 10⁵. MongoDB answers in one
254
+ aggregate: the `integrationId` index narrows `$match` to the integration,
255
+ the `$expr` is then evaluated per document, and `$facet` returns the page
256
+ with its `$count`, so a page (after `omit`) must fit the 16 MB document
257
+ limit. DocumentDB has no `$facet`: the page and the count are two
258
+ concurrent aggregates, so the total does not come from the same snapshot
259
+ as the page. Both sort in memory on a computed key (`allowDiskUse`). With
260
+ ~4 KB mappings on MongoDB 7 that is about 0.05–0.1 s per call at 10⁴ rows
261
+ per integration and 0.5–0.7 s at 10⁵ (the DocumentDB pipeline, run on
262
+ MongoDB, 0.6–0.8 s). On every adapter a deep offset or an empty page past
263
+ the end costs about the same as the first page.
215
264
  - `process-repository-*.js` - Process (long-running job) persistence.
216
265
  Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
217
266
  to `update(id, patch)` that routes increments, sets, and bounded-array
@@ -320,6 +369,48 @@ class MyIntegration extends IntegrationBase {
320
369
  - `ApiKeyRequester` - API key authentication
321
370
  - `BasicAuthRequester` - Basic authentication
322
371
 
372
+ **Rate limits** (ADR-049): an API module declares `static rateLimit` on its
373
+ `Requester` subclass. For a throttled response the Requester looks for a hint
374
+ in this order:
375
+
376
+ | Step | Source |
377
+ |---|---|
378
+ | 1 | `classify({ status, headers, body })` of the module. It recognises a limit that is not a plain 429 (a 403 limit code, a `policyName` in the body) and may name only a `reason` |
379
+ | 2 | The header parsers in `parsers` order. Default: `retryAfter`, `resetHeaders`, `ietf` |
380
+ | 3 | The static policy: `minRetryAfterMs`, or the `perMs` of the window named by the `reason` |
381
+ | 4 | The fixed `backOff` ladder (1, 3, 10, 30, 60, 180 s) |
382
+
383
+ - A 429 with no hint from steps 1 to 3 keeps today's ladder: the same calls,
384
+ the same delays, then a plain `FetchError`. Nothing budgets it.
385
+ - With a hint, the wait is `max(hint, minRetryAfterMs, 1 s)` plus at most 10 %
386
+ jitter. The total sleep of one request is capped at `maxInProcessWaitMs`
387
+ (default 5 minutes) and at the time left in the invocation less one request
388
+ timeout (`remainingInvocationMs()`).
389
+ - A wait that does not fit throws `RateLimitError` (`isRateLimited`, `retryAt`,
390
+ `waitMs`, `reason`, `policy`, `source`, `module`, `scopeKey`). The queue
391
+ worker does not halt it, whatever its status.
392
+ - `Retry-After` is read on a 429 only. Another status counts only when
393
+ `classify` recognises it. A 5xx keeps the 5xx ladder.
394
+ - `maxInProcessWaitMs: 0` turns off sleeping: every hinted wait throws.
395
+ - A module that does not use the Requester (for example one on jsforce) calls
396
+ `classifyRateLimit(policy, { status, headers, body })` around its own client
397
+ and throws `RateLimitError` itself.
398
+
399
+ ```javascript
400
+ class Api extends OAuth2Requester {
401
+ static rateLimit = {
402
+ scope: 'entity',
403
+ minRetryAfterMs: 1_000,
404
+ classify({ status, body }) {
405
+ if (status === 403 && body?.code === 'DAILY_LIMIT') {
406
+ return { reason: 'daily', waitMs: 60 * 60_000 };
407
+ }
408
+ return null;
409
+ },
410
+ };
411
+ }
412
+ ```
413
+
323
414
  **Module Factory**:
324
415
  - `ModuleFactory` - Creates and configures API module instances
325
416
  - Handles credential injection
@@ -414,6 +505,44 @@ const decrypted = await cryptor.decrypt(encrypted);
414
505
  - `integration-event-dispatcher.js` - Routes events to integration handlers
415
506
  - Supports lifecycle events and user actions
416
507
 
508
+ **Queue handler delivery**: a handler dispatched from the integration queue
509
+ (any `this.events` entry reached through SQS, including `ON_WEBHOOK` and
510
+ scheduled jobs) receives `{ data, context, delivery }`. HTTP-dispatched
511
+ handlers (`{ req, res, next }`) and `this.on` events do not.
512
+
513
+ | Field | Type | Value |
514
+ |---|---|---|
515
+ | `delivery.receiveCount` | `number \| undefined` | SQS `ApproximateReceiveCount` of this delivery |
516
+ | `delivery.maxReceiveCount` | `number \| undefined` | Receives allowed before SQS moves the message to the DLQ (`FRIGG_QUEUE_MAX_RECEIVE_COUNT`) |
517
+ | `delivery.isLastAttempt` | `boolean` | `true` only when both counts are known and `receiveCount >= maxReceiveCount` |
518
+
519
+ `isLastAttempt` is `false` when either count is unknown: a local or non-SQS
520
+ invocation, or a queue whose redrive policy the stack does not own
521
+ (`ownership.queue: 'external'`). The value is information only: core still
522
+ rethrows retryable errors and discards halt errors (4xx except 408, 429 and
523
+ errors with `isRateLimited`).
524
+
525
+ Use it to end a run or count lost work on the final try: when a retryable
526
+ error (429, 5xx, network) is about to be rethrown and `isLastAttempt` is
527
+ `true`, the message goes to the DLQ next, so mark the run failed or count the
528
+ message's records as failed before rethrowing.
529
+
530
+ ```javascript
531
+ async processBatch({ data, delivery }) {
532
+ try {
533
+ await this.syncPage(data);
534
+ } catch (error) {
535
+ if (delivery?.isLastAttempt) await this.failRun(data.processId, error);
536
+ throw error;
537
+ }
538
+ }
539
+ ```
540
+
541
+ The single source of the max receive count is
542
+ `INTEGRATION_QUEUE_MAX_RECEIVE_COUNT` in `queues/queue-delivery.js`. The
543
+ devtools integration builder uses it for the queue's `RedrivePolicy` and sets
544
+ it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
545
+
417
546
  ### 8. Error Handling (`/errors`)
418
547
 
419
548
  **Purpose**: Standardized error types with proper HTTP semantics.
@@ -421,6 +550,7 @@ const decrypted = await cryptor.decrypt(encrypted);
421
550
  **Error Types**:
422
551
  - `BaseError` - Base error class
423
552
  - `FetchError` - HTTP request failures
553
+ - `RateLimitError` - A `FetchError` for a limit the provider or the module's policy says when to retry (`retryAt`)
424
554
  - `HaltError` - Stop processing without retry
425
555
  - `RequiredPropertyError` - Missing required parameters
426
556
  - `ParameterTypeError` - Invalid parameter type
@@ -663,6 +793,7 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
663
793
 
664
794
  - `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
665
795
  - `FRIGG_LOG_LEVEL` - Minimum log level (`TRACE` … `FATAL`, default `INFO`; `DEBUG` on local runs)
796
+ - `FRIGG_QUEUE_MAX_RECEIVE_COUNT` - Set by devtools on integration queue workers whose queue the stack owns; feeds `delivery.maxReceiveCount`
666
797
 
667
798
  ## Version Information
668
799
 
package/core/CLAUDE.md CHANGED
@@ -52,9 +52,10 @@ const handler = createHandler({
52
52
  **Usage Pattern**:
53
53
  ```javascript
54
54
  class MyWorker extends Worker {
55
- async _run(params, context = {}) {
55
+ async _run(params, context = {}, delivery) {
56
56
  // Your job processing logic here
57
57
  // params are already JSON.parsed from SQS message body
58
+ // delivery is { receiveCount, maxReceiveCount, isLastAttempt }
58
59
  }
59
60
 
60
61
  _validateParams(params) {
@@ -129,7 +130,7 @@ class MyIntegration extends Delegate {
129
130
  ### Lambda Handler Lifecycle
130
131
  1. **Pre-Execution Setup**:
131
132
  ```javascript
132
- runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope for the invocation
133
+ runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope + invocation deadline (remainingInvocationMs())
133
134
  log.info('Handler invoked', { eventName: 'frigg.handler.invoked' });
134
135
  await secretsToEnv(); // Secrets Manager injection
135
136
  await parametersToEnv(); // SSM Parameter Store fetch (only when SSM_PARAMETER_PREFIX + FRIGG_SSM_OFFLOADED_KEYS are set)
@@ -225,6 +226,8 @@ const handler = createHandler({
225
226
  - Logs error but returns success
226
227
  - Prevents infinite retries for known issues
227
228
  - Used for graceful degradation scenarios
229
+ - The queue worker sets it on a 4xx except 408, 429 and errors with
230
+ `isRateLimited` (a provider limit signalled with 403 clears with time)
228
231
 
229
232
  ### Logging Strategy
230
233
  ```javascript
package/core/Worker.js CHANGED
@@ -2,6 +2,7 @@ const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/
2
2
  const _ = require('lodash');
3
3
  const { RequiredPropertyError } = require('../errors');
4
4
  const { get } = require('../assertions');
5
+ const { readQueueDelivery } = require('../queues/queue-delivery');
5
6
  const { runMessageScope } = require('./invocation-scope');
6
7
  const { getLogger } = require('../logs');
7
8
 
@@ -35,7 +36,7 @@ class Worker {
35
36
  try {
36
37
  const runParams = JSON.parse(record.body);
37
38
  this._validateParams(runParams);
38
- await this._run(runParams, context);
39
+ await this._run(runParams, context, readQueueDelivery(record));
39
40
  log.debug('Record succeeded', { eventName: 'frigg.worker.record_succeeded' });
40
41
  } catch (error) {
41
42
  if (error.isHaltError) {
package/core/index.js CHANGED
@@ -3,6 +3,10 @@ const { Worker } = require('./Worker');
3
3
  const { loadInstalledModules } = require('./load-installed-modules');
4
4
  const { createHandler } = require('./create-handler');
5
5
  const { runInvocationScope } = require('./invocation-scope');
6
+ const {
7
+ runWithInvocationDeadline,
8
+ remainingInvocationMs,
9
+ } = require('./invocation-deadline');
6
10
 
7
11
  module.exports = {
8
12
  Delegate,
@@ -10,4 +14,6 @@ module.exports = {
10
14
  loadInstalledModules,
11
15
  createHandler,
12
16
  runInvocationScope,
17
+ runWithInvocationDeadline,
18
+ remainingInvocationMs,
13
19
  };
@@ -0,0 +1,56 @@
1
+ const { AsyncLocalStorage } = require('node:async_hooks');
2
+
3
+ const STORE_KEY = Symbol.for('@friggframework/core.invocation-deadline');
4
+
5
+ function storage() {
6
+ if (!globalThis[STORE_KEY]) {
7
+ globalThis[STORE_KEY] = new AsyncLocalStorage();
8
+ }
9
+ return globalThis[STORE_KEY];
10
+ }
11
+
12
+ /**
13
+ * Runs fn with the time the invocation ends, as epoch milliseconds. A nested
14
+ * scope can shorten the deadline and never extend it. A deadline that is not
15
+ * a finite number runs fn with no limit.
16
+ */
17
+ function runWithInvocationDeadline(deadlineAt, fn) {
18
+ if (typeof deadlineAt !== 'number' || !Number.isFinite(deadlineAt)) {
19
+ return fn();
20
+ }
21
+ const outer = storage().getStore();
22
+ const effective = outer
23
+ ? Math.min(outer.deadlineAt, deadlineAt)
24
+ : deadlineAt;
25
+ return storage().run(Object.freeze({ deadlineAt: effective }), fn);
26
+ }
27
+
28
+ /**
29
+ * The end of a Lambda invocation, from its context. Undefined when the
30
+ * context cannot tell.
31
+ */
32
+ function deadlineFromContext(context) {
33
+ try {
34
+ const remaining = context?.getRemainingTimeInMillis?.();
35
+ return typeof remaining === 'number' && Number.isFinite(remaining)
36
+ ? Date.now() + remaining
37
+ : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Milliseconds left in the invocation. Infinity outside a deadline scope
45
+ * (tests, `frigg start`, scripts).
46
+ */
47
+ function remainingInvocationMs(now = Date.now()) {
48
+ const store = storage().getStore();
49
+ return store ? Math.max(0, store.deadlineAt - now) : Infinity;
50
+ }
51
+
52
+ module.exports = {
53
+ deadlineFromContext,
54
+ remainingInvocationMs,
55
+ runWithInvocationDeadline,
56
+ };
@@ -1,6 +1,10 @@
1
1
  const { runInContext, LOGGER_SCOPE_KEY } = require('../logs/context');
2
2
  const { summarizeMessageBody } = require('../logs/summarize-event');
3
3
  const { flushSinks, hasFlushableSinks } = require('../logs/logger-runtime');
4
+ const {
5
+ deadlineFromContext,
6
+ runWithInvocationDeadline,
7
+ } = require('./invocation-deadline');
4
8
 
5
9
  // Bounds the tail latency telemetry adds to every warm invocation. Kept low so
6
10
  // an unreachable OTLP endpoint (e.g. a VPC Lambda with no NAT/egress) costs at
@@ -168,21 +172,26 @@ async function runInvocationScope(fields, fn, opts = {}) {
168
172
  flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
169
173
  context,
170
174
  } = opts;
171
- return runInContext({ [LOGGER_SCOPE_KEY]: { ...(fields || {}) } }, async () => {
172
- try {
173
- return await fn();
174
- } finally {
175
- // Every step is guarded, so a flush never changes the result.
176
- await flushInvocation({
177
- telemetry,
178
- usageRollup,
179
- eventSummary,
180
- shouldUseDatabase,
181
- flushTimeoutMs,
182
- context,
183
- });
184
- }
185
- });
175
+ return runWithInvocationDeadline(deadlineFromContext(context), () =>
176
+ runInContext(
177
+ { [LOGGER_SCOPE_KEY]: { ...(fields || {}) } },
178
+ async () => {
179
+ try {
180
+ return await fn();
181
+ } finally {
182
+ // Every step is guarded, so a flush never changes the result.
183
+ await flushInvocation({
184
+ telemetry,
185
+ usageRollup,
186
+ eventSummary,
187
+ shouldUseDatabase,
188
+ flushTimeoutMs,
189
+ context,
190
+ });
191
+ }
192
+ }
193
+ )
194
+ );
186
195
  }
187
196
 
188
197
  function toCount(value) {
@@ -1,5 +1,9 @@
1
1
  const { Cryptor } = require('../encrypt/Cryptor');
2
- const { getEncryptedFields, loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
2
+ const {
3
+ getEncryptedFields,
4
+ getFieldsToEncryptOnWrite,
5
+ loadCustomEncryptionSchema,
6
+ } = require('./encryption/encryption-schema-registry');
3
7
 
4
8
  /**
5
9
  * Encryption service specifically for DocumentDB repositories
@@ -107,7 +111,7 @@ class DocumentDBEncryptionService {
107
111
  }
108
112
 
109
113
  // Get encrypted fields from registry
110
- const encryptedFieldsConfig = getEncryptedFields(modelName);
114
+ const encryptedFieldsConfig = getFieldsToEncryptOnWrite(modelName);
111
115
  if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
112
116
  return document;
113
117
  }
@@ -166,11 +166,12 @@ async function findManyDrained(client, collection, filter = {}, options = {}) {
166
166
  return drainCursor(client, collection, first);
167
167
  }
168
168
 
169
- async function aggregateDrained(client, collection, pipeline) {
169
+ async function aggregateDrained(client, collection, pipeline, options = {}) {
170
170
  const first = await client.$runCommandRaw({
171
171
  aggregate: collection,
172
172
  pipeline,
173
173
  cursor: { batchSize: DRAIN_BATCH_SIZE },
174
+ ...options,
174
175
  });
175
176
  return drainCursor(client, collection, first);
176
177
  }
@@ -818,6 +818,26 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
818
818
  ❌ Query on encrypted fields (not supported)
819
819
  ❌ Manually decrypt data (use extension)
820
820
 
821
+ The integration mapping repositories' `queryMappings()` (PostgreSQL, MongoDB
822
+ and DocumentDB) filters and sorts inside the `mapping` JSON, so it refuses to
823
+ run while field-level encryption still encrypts `IntegrationMapping.mapping` on
824
+ write. Opt the field out in the app definition, together with any nested
825
+ `mapping.*` path that `encryption.schema` encrypts; the Prisma extension and
826
+ `DocumentDBEncryptionService` both honor the opt-out:
827
+
828
+ ```javascript
829
+ encryption: {
830
+ disable: { IntegrationMapping: ['mapping'] },
831
+ }
832
+ ```
833
+
834
+ The opt-out applies to writes only. Rows written encrypted before it stay
835
+ readable, but `queryMappings()` does not match them until they are written
836
+ again. It returns the rows it matches decrypted, like
837
+ `findMappingsByIntegration()`: a nested `mapping.*` path written encrypted
838
+ before its opt-out reads plain, though conditions and ordering on that path
839
+ still see the ciphertext until the row is written again.
840
+
821
841
  ## Future Enhancements
822
842
 
823
843
  ### Planned
@@ -0,0 +1,97 @@
1
+ const { getEncryptionConfig } = require('../prisma');
2
+ const { Cryptor } = require('../../encrypt/Cryptor');
3
+ const {
4
+ getFieldsToDecryptOnRead,
5
+ getFieldsToEncryptOnWrite,
6
+ loadCustomEncryptionSchema,
7
+ } = require('./encryption-schema-registry');
8
+ const {
9
+ createFieldEncryptionService,
10
+ } = require('./prisma-encryption-extension');
11
+
12
+ let plainMappingEncryption = null;
13
+
14
+ /**
15
+ * The `IntegrationMapping` fields, `mapping` itself or a nested `mapping.*`
16
+ * path, that field-level encryption encrypts on write. An empty result is
17
+ * kept for the life of the process.
18
+ *
19
+ * @returns {string[]} Empty when every mapping path is written as plain JSON
20
+ */
21
+ function getMappingFieldsEncryptedOnWrite() {
22
+ return mappingEncryption().encryptedOnWrite;
23
+ }
24
+
25
+ /**
26
+ * @throws {Error} When field-level encryption still encrypts `mapping`, or a
27
+ * nested `mapping.*` path, on write, naming the opt-out that lifts it
28
+ */
29
+ function assertMappingWrittenUnencrypted() {
30
+ const fields = getMappingFieldsEncryptedOnWrite();
31
+ if (fields.length === 0) return;
32
+
33
+ const encrypted = fields
34
+ .map((field) => `IntegrationMapping.${field}`)
35
+ .join(', ');
36
+ const optOut = fields.map((field) => `'${field}'`).join(', ');
37
+ throw new Error(
38
+ `queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Decrypts rows that `queryMappings` read around the Prisma encryption
44
+ * extension, the way reads through the extension do: every
45
+ * `IntegrationMapping` field the schema lists, opted-out paths included, so a
46
+ * path written encrypted before its opt-out comes back plain.
47
+ *
48
+ * @param {Object[]} rows - Rows whose `mapping` is a JSON object
49
+ * @returns {Promise<Object[]>} `rows` itself when encryption is off or the
50
+ * schema lists no field besides `mapping`
51
+ */
52
+ async function decryptQueriedMappings(rows) {
53
+ const { decryptor } = mappingEncryption();
54
+ if (!decryptor) return rows;
55
+ return decryptor.decryptFieldsInBulk('IntegrationMapping', rows);
56
+ }
57
+
58
+ function mappingEncryption() {
59
+ if (plainMappingEncryption) return plainMappingEncryption;
60
+
61
+ const encryption = currentMappingEncryption();
62
+ if (encryption.encryptedOnWrite.length === 0) {
63
+ plainMappingEncryption = encryption;
64
+ }
65
+ return encryption;
66
+ }
67
+
68
+ function currentMappingEncryption() {
69
+ const config = getEncryptionConfig();
70
+ if (!config.enabled) return { encryptedOnWrite: [], decryptor: null };
71
+
72
+ loadCustomEncryptionSchema();
73
+ const encryptedOnWrite = getFieldsToEncryptOnWrite(
74
+ 'IntegrationMapping'
75
+ ).filter((field) => field === 'mapping' || field.startsWith('mapping.'));
76
+ const decryptsBesidesMapping = getFieldsToDecryptOnRead(
77
+ 'IntegrationMapping'
78
+ ).some((field) => field !== 'mapping');
79
+ const decryptor = decryptsBesidesMapping
80
+ ? createFieldEncryptionService(
81
+ new Cryptor({ shouldUseAws: config.method === 'kms' })
82
+ )
83
+ : null;
84
+ return { encryptedOnWrite, decryptor };
85
+ }
86
+
87
+ /** Test helper: forget a kept result. */
88
+ function resetMappingEncryptionCheck() {
89
+ plainMappingEncryption = null;
90
+ }
91
+
92
+ module.exports = {
93
+ assertMappingWrittenUnencrypted,
94
+ decryptQueriedMappings,
95
+ getMappingFieldsEncryptedOnWrite,
96
+ resetMappingEncryptionCheck,
97
+ };
@@ -10,6 +10,24 @@ const {
10
10
  } = require('./encryption-schema-registry');
11
11
  const { FieldEncryptionService } = require('./field-encryption-service');
12
12
 
13
+ /**
14
+ * The FieldEncryptionService the extension runs, with field paths from the
15
+ * encryption schema registry.
16
+ *
17
+ * @param {import('../../encrypt/Cryptor').Cryptor} cryptor
18
+ * @returns {FieldEncryptionService}
19
+ */
20
+ function createFieldEncryptionService(cryptor) {
21
+ return new FieldEncryptionService({
22
+ cryptor,
23
+ schema: {
24
+ getEncryptedFields,
25
+ getFieldsToEncryptOnWrite,
26
+ getFieldsToDecryptOnRead,
27
+ },
28
+ });
29
+ }
30
+
13
31
  function createEncryptionExtension({ cryptor, enabled = true }) {
14
32
  if (!enabled) {
15
33
  return (client) => client;
@@ -21,14 +39,7 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
21
39
  );
22
40
  }
23
41
 
24
- const encryptionService = new FieldEncryptionService({
25
- cryptor,
26
- schema: {
27
- getEncryptedFields,
28
- getFieldsToEncryptOnWrite,
29
- getFieldsToDecryptOnRead,
30
- },
31
- });
42
+ const encryptionService = createFieldEncryptionService(cryptor);
32
43
 
33
44
  return {
34
45
  name: 'frigg-field-encryption',
@@ -227,4 +238,4 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
227
238
  };
228
239
  }
229
240
 
230
- module.exports = { createEncryptionExtension };
241
+ module.exports = { createEncryptionExtension, createFieldEncryptionService };
@@ -55,12 +55,18 @@ class FetchError extends BaseError {
55
55
 
56
56
  static async create(options = {}) {
57
57
  const { response } = options;
58
- let responseBody =
59
- response && !response.bodyUsed && typeof response.text === 'function'
60
- ? await response.text()
61
- : null;
62
- if (!responseBody) responseBody = options.responseBody ?? options.body;
63
- return new FetchError({ ...options, responseBody });
58
+ const provided = options.responseBody ?? options.body;
59
+ let responseBody = provided;
60
+ if (
61
+ !responseBody &&
62
+ response &&
63
+ !response.bodyUsed &&
64
+ typeof response.text === 'function'
65
+ ) {
66
+ responseBody = (await response.text()) || provided;
67
+ }
68
+ const ErrorClass = typeof this === 'function' ? this : FetchError;
69
+ return new ErrorClass({ ...options, responseBody });
64
70
  }
65
71
  }
66
72
 
package/errors/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const { BaseError } = require('./base-error');
2
2
  const { FetchError } = require('./fetch-error');
3
3
  const { HaltError } = require('./halt-error');
4
+ const { RateLimitError } = require('./rate-limit-error');
4
5
  const {
5
6
  RequiredPropertyError,
6
7
  ParameterTypeError,
@@ -11,6 +12,7 @@ module.exports = {
11
12
  BaseError,
12
13
  FetchError,
13
14
  HaltError,
15
+ RateLimitError,
14
16
  RequiredPropertyError,
15
17
  ParameterTypeError,
16
18
  ClientSafeError,
@@ -0,0 +1,44 @@
1
+ const { FetchError } = require('./fetch-error');
2
+
3
+ function resolveTiming({ hint, waitMs, now }) {
4
+ if (waitMs !== undefined) {
5
+ return { waitMs, retryAt: new Date(now + waitMs) };
6
+ }
7
+ if (hint?.retryAt) {
8
+ const retryAt = new Date(hint.retryAt);
9
+ if (!Number.isNaN(retryAt.getTime())) {
10
+ return { waitMs: Math.max(0, retryAt.getTime() - now), retryAt };
11
+ }
12
+ }
13
+ const fallbackWaitMs = hint?.waitMs ?? 0;
14
+ return { waitMs: fallbackWaitMs, retryAt: new Date(now + fallbackWaitMs) };
15
+ }
16
+
17
+ /**
18
+ * A FetchError for a response that said a limit was hit and told us, or the
19
+ * module's policy told us, when to call again. `retryAt` is that time.
20
+ */
21
+ class RateLimitError extends FetchError {
22
+ constructor({
23
+ hint,
24
+ waitMs,
25
+ module,
26
+ scopeKey,
27
+ now = Date.now(),
28
+ ...fetchErrorArgs
29
+ } = {}) {
30
+ super(fetchErrorArgs);
31
+
32
+ const timing = resolveTiming({ hint, waitMs, now });
33
+ this.isRateLimited = true;
34
+ this.waitMs = timing.waitMs;
35
+ this.retryAt = timing.retryAt;
36
+ this.reason = hint?.reason ?? 'unknown';
37
+ this.policy = hint?.policy;
38
+ this.source = hint?.source ?? 'unknown';
39
+ this.module = module;
40
+ this.scopeKey = scopeKey;
41
+ }
42
+ }
43
+
44
+ module.exports = { RateLimitError };
@@ -165,7 +165,7 @@ const createQueueWorker = (integrationClass) => {
165
165
  const integrationName = integrationClass.Definition.name;
166
166
 
167
167
  class QueueWorker extends Worker {
168
- async _run(params, context) {
168
+ async _run(params, context, delivery) {
169
169
  const logCtx = {
170
170
  integration: integrationName,
171
171
  event: params.event,
@@ -255,6 +255,7 @@ const createQueueWorker = (integrationClass) => {
255
255
  event: params.event,
256
256
  data: params.data,
257
257
  context: context,
258
+ delivery,
258
259
  });
259
260
  console.log(
260
261
  `[QueueWorker] ${params.event} dispatched ok`,
@@ -284,7 +285,8 @@ const createQueueWorker = (integrationClass) => {
284
285
  status >= 400 &&
285
286
  status < 500 &&
286
287
  status !== 408 &&
287
- status !== 429
288
+ status !== 429 &&
289
+ !error.isRateLimited
288
290
  ) {
289
291
  error.isHaltError = true;
290
292
  console.warn(