@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.
- package/CLAUDE.md +132 -1
- package/core/CLAUDE.md +5 -2
- package/core/Worker.js +2 -1
- package/core/index.js +6 -0
- package/core/invocation-deadline.js +56 -0
- package/core/invocation-scope.js +24 -15
- package/database/documentdb-encryption-service.js +6 -2
- package/database/documentdb-utils.js +2 -1
- package/database/encryption/README.md +20 -0
- package/database/encryption/integration-mapping-encryption.js +97 -0
- package/database/encryption/prisma-encryption-extension.js +20 -9
- package/errors/fetch-error.js +12 -6
- package/errors/index.js +2 -0
- package/errors/rate-limit-error.js +44 -0
- package/handlers/backend-utils.js +4 -2
- package/handlers/integration-event-dispatcher.js +2 -2
- package/index.js +18 -0
- package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
- package/integrations/repositories/integration-mapping-query.js +193 -0
- package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
- package/logs/record.js +32 -7
- package/modules/requester/rate-limit/index.js +4 -0
- package/modules/requester/rate-limit/parsers.js +268 -0
- package/modules/requester/rate-limit/policy.js +323 -0
- package/modules/requester/requester.js +174 -6
- package/package.json +5 -5
- package/queues/queue-delivery.js +41 -0
- package/types/core/index.d.ts +42 -0
- package/types/errors/index.d.ts +56 -0
- 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
|
|
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
|
+
};
|
package/core/invocation-scope.js
CHANGED
|
@@ -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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 };
|
package/errors/fetch-error.js
CHANGED
|
@@ -55,12 +55,18 @@ class FetchError extends BaseError {
|
|
|
55
55
|
|
|
56
56
|
static async create(options = {}) {
|
|
57
57
|
const { response } = options;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|