@friggframework/core 2.0.0-next.100 → 2.0.0-next.102
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/CLAUDE.md +10 -0
- package/core/create-handler.js +4 -0
- package/core/parameters-to-env.js +257 -0
- package/core/ssm-preload.mjs +34 -0
- package/integrations/EXTENSIONS.md +2 -2
- package/integrations/integration-base.js +18 -4
- package/modules/module.js +15 -2
- package/modules/requester/requester.js +62 -13
- package/package.json +6 -5
package/core/CLAUDE.md
CHANGED
|
@@ -131,6 +131,7 @@ class MyIntegration extends Delegate {
|
|
|
131
131
|
```javascript
|
|
132
132
|
initDebugLog(eventName, event); // Debug logging setup
|
|
133
133
|
await secretsToEnv(); // Secrets Manager injection
|
|
134
|
+
await parametersToEnv(); // SSM Parameter Store fetch (only when SSM_PARAMETER_PREFIX + FRIGG_SSM_OFFLOADED_KEYS are set)
|
|
134
135
|
context.callbackWaitsForEmptyEventLoop = false; // Connection pooling
|
|
135
136
|
```
|
|
136
137
|
|
|
@@ -165,6 +166,15 @@ class MyIntegration extends Delegate {
|
|
|
165
166
|
- **Security**: No secrets logging or exposure in error messages
|
|
166
167
|
- **Caching**: Secrets cached for Lambda container lifetime
|
|
167
168
|
|
|
169
|
+
### SSM Parameter Store Loader (`parameters-to-env.js`)
|
|
170
|
+
Offloads env vars that would otherwise exceed Lambda's 4KB env limit. Runs right after `secretsToEnv()` and is a no-op unless both `SSM_PARAMETER_PREFIX` and `FRIGG_SSM_OFFLOADED_KEYS` (comma-separated env var names) are set.
|
|
171
|
+
|
|
172
|
+
- **Precedence**: real `process.env` > Secrets Manager > SSM. A key already present in `process.env` at load time is never fetched or overwritten (a documented local-debugging escape hatch); only keys the loader itself set are refreshed.
|
|
173
|
+
- **Fetch**: `GetParametersCommand` with `WithDecryption: true`, batched in groups of 10 (the GetParameters max). Parameter name for key `K` is `${SSM_PARAMETER_PREFIX}/${K}`.
|
|
174
|
+
- **TTL cache**: successful loads cache for `FRIGG_SSM_CACHE_TTL` seconds (default 300; `0` = forever). Concurrent callers share one in-flight promise. A failed initial load is never cached, so the next invocation retries; a failed TTL *refresh* keeps serving the stale values (warn + 30s backoff) instead of erroring a warm container. `ThrottlingException` is retried with short exponential backoff.
|
|
175
|
+
- **Fail-fast**: throws listing every missing parameter name (and the prefix) if a required key is absent from SSM. Keys already satisfied by real `process.env` never trigger a failure.
|
|
176
|
+
- **Security**: logs parameter names and versions only, never values.
|
|
177
|
+
|
|
168
178
|
## Database Connection Patterns
|
|
169
179
|
|
|
170
180
|
### Connection Pooling Strategy
|
package/core/create-handler.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const { initDebugLog, flushDebugLog } = require('../logs');
|
|
6
6
|
const { secretsToEnv } = require('./secrets-to-env');
|
|
7
|
+
const { parametersToEnv } = require('./parameters-to-env');
|
|
7
8
|
|
|
8
9
|
// Best-effort extraction of correlation identifiers from a Lambda event.
|
|
9
10
|
// For SQS: pulls messageIds + parsed event/processId/integrationId from each
|
|
@@ -80,6 +81,9 @@ const createHandler = (optionByName = {}) => {
|
|
|
80
81
|
// If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
|
|
81
82
|
await secretsToEnv();
|
|
82
83
|
|
|
84
|
+
// If enabled (i.e. if SSM_PARAMETER_PREFIX and FRIGG_SSM_OFFLOADED_KEYS are set) fetch offloaded params from SSM Parameter Store into process.env.
|
|
85
|
+
await parametersToEnv();
|
|
86
|
+
|
|
83
87
|
// Lazy-required so DB-free handlers never load the Prisma client.
|
|
84
88
|
if (shouldUseDatabase) {
|
|
85
89
|
const { connectPrisma } = require('../database/prisma');
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Runtime loader that hydrates process.env from SSM Parameter Store. Used to
|
|
2
|
+
// offload env vars that would otherwise blow past Lambda's 4KB env limit.
|
|
3
|
+
// Real process.env always wins over SSM (a documented local-debugging escape
|
|
4
|
+
// hatch); only keys this loader sets are ever refreshed.
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CACHE_TTL_SECONDS = 300;
|
|
7
|
+
const BATCH_SIZE = 10;
|
|
8
|
+
const THROTTLE_BACKOFF_MS = [100, 200, 400];
|
|
9
|
+
const REFRESH_RETRY_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
let client = null;
|
|
12
|
+
let cacheExpiresAt = null;
|
|
13
|
+
let inflight = null;
|
|
14
|
+
const ownedKeys = new Set();
|
|
15
|
+
|
|
16
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
const chunk = (items, size) => {
|
|
19
|
+
const batches = [];
|
|
20
|
+
for (let i = 0; i < items.length; i += size) {
|
|
21
|
+
batches.push(items.slice(i, i + size));
|
|
22
|
+
}
|
|
23
|
+
return batches;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const getCacheTtlSeconds = () => {
|
|
27
|
+
const raw = process.env.FRIGG_SSM_CACHE_TTL;
|
|
28
|
+
if (raw === undefined || raw === '') {
|
|
29
|
+
return DEFAULT_CACHE_TTL_SECONDS;
|
|
30
|
+
}
|
|
31
|
+
const parsed = Number(raw);
|
|
32
|
+
return Number.isNaN(parsed) ? DEFAULT_CACHE_TTL_SECONDS : parsed;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const parseKeys = (raw) =>
|
|
36
|
+
raw
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((key) => key.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
|
|
41
|
+
const sendWithRetry = async (ssmClient, command) => {
|
|
42
|
+
let attempt = 0;
|
|
43
|
+
for (;;) {
|
|
44
|
+
try {
|
|
45
|
+
return await ssmClient.send(command);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (
|
|
48
|
+
err.name === 'ThrottlingException' &&
|
|
49
|
+
attempt < THROTTLE_BACKOFF_MS.length
|
|
50
|
+
) {
|
|
51
|
+
await sleep(THROTTLE_BACKOFF_MS[attempt]);
|
|
52
|
+
attempt += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fetch offloaded parameters and return a plain { key: value } map. Fetch-only:
|
|
62
|
+
* no process.env mutation, no caching, no ownership tracking. Shared by the
|
|
63
|
+
* runtime loader and the INIT-phase preload (ssm-preload.js). Throws (listing
|
|
64
|
+
* every missing name) if any requested key is absent from SSM.
|
|
65
|
+
*/
|
|
66
|
+
const fetchOffloadedParameters = async (prefix, keys, { region } = {}) => {
|
|
67
|
+
const { SSMClient, GetParametersCommand } = require('@aws-sdk/client-ssm');
|
|
68
|
+
const ssmClient = new SSMClient({
|
|
69
|
+
region: region || process.env.AWS_REGION,
|
|
70
|
+
});
|
|
71
|
+
const nameFor = (key) => `${prefix}/${key}`;
|
|
72
|
+
|
|
73
|
+
const found = new Map();
|
|
74
|
+
for (const batch of chunk(keys, BATCH_SIZE)) {
|
|
75
|
+
const { Parameters = [] } = await sendWithRetry(
|
|
76
|
+
ssmClient,
|
|
77
|
+
new GetParametersCommand({
|
|
78
|
+
Names: batch.map(nameFor),
|
|
79
|
+
WithDecryption: true,
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
for (const param of Parameters) {
|
|
83
|
+
found.set(param.Name, param);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const missing = keys.map(nameFor).filter((name) => !found.has(name));
|
|
88
|
+
if (missing.length > 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`missing SSM parameters under ${prefix}: ${missing.join(', ')}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const values = {};
|
|
95
|
+
for (const key of keys) {
|
|
96
|
+
values[key] = found.get(nameFor(key)).Value;
|
|
97
|
+
}
|
|
98
|
+
return values;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const loadParameters = async (prefix, keys) => {
|
|
102
|
+
const { SSMClient, GetParametersCommand } = require('@aws-sdk/client-ssm');
|
|
103
|
+
if (!client) {
|
|
104
|
+
client = new SSMClient({ region: process.env.AWS_REGION });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A key already living in process.env wins over SSM, except keys we set
|
|
108
|
+
// ourselves — those we refresh so later invocations pick up rotations.
|
|
109
|
+
const keysToFetch = keys.filter(
|
|
110
|
+
(key) => ownedKeys.has(key) || process.env[key] === undefined
|
|
111
|
+
);
|
|
112
|
+
const nameFor = (key) => `${prefix}/${key}`;
|
|
113
|
+
|
|
114
|
+
// A refresh means every key already has a served value; a warm container
|
|
115
|
+
// must keep serving stale values through an SSM blip instead of erroring.
|
|
116
|
+
const isRefresh =
|
|
117
|
+
keysToFetch.length > 0 &&
|
|
118
|
+
keysToFetch.every((key) => ownedKeys.has(key));
|
|
119
|
+
|
|
120
|
+
const found = new Map();
|
|
121
|
+
try {
|
|
122
|
+
for (const batch of chunk(keysToFetch, BATCH_SIZE)) {
|
|
123
|
+
const { Parameters = [] } = await sendWithRetry(
|
|
124
|
+
client,
|
|
125
|
+
new GetParametersCommand({
|
|
126
|
+
Names: batch.map(nameFor),
|
|
127
|
+
WithDecryption: true,
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
for (const param of Parameters) {
|
|
131
|
+
found.set(param.Name, param);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const missing = keysToFetch
|
|
136
|
+
.map(nameFor)
|
|
137
|
+
.filter((name) => !found.has(name));
|
|
138
|
+
if (missing.length > 0) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`parametersToEnv: missing SSM parameters under ${prefix}: ${missing.join(
|
|
141
|
+
', '
|
|
142
|
+
)}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (!isRefresh) {
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
console.warn(
|
|
150
|
+
`parametersToEnv: refresh failed, keeping cached values: ${err.message}`
|
|
151
|
+
);
|
|
152
|
+
cacheExpiresAt = Date.now() + REFRESH_RETRY_MS;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const loaded = keysToFetch.map((key) => {
|
|
157
|
+
const param = found.get(nameFor(key));
|
|
158
|
+
process.env[key] = param.Value;
|
|
159
|
+
ownedKeys.add(key);
|
|
160
|
+
return `${param.Name} (v${param.Version})`;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const ttlSeconds = getCacheTtlSeconds();
|
|
164
|
+
cacheExpiresAt =
|
|
165
|
+
ttlSeconds === 0 ? Infinity : Date.now() + ttlSeconds * 1000;
|
|
166
|
+
|
|
167
|
+
if (loaded.length > 0) {
|
|
168
|
+
console.log('parametersToEnv: loaded', loaded);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Hydrate process.env from SSM Parameter Store.
|
|
174
|
+
*
|
|
175
|
+
* No-op unless both SSM_PARAMETER_PREFIX and FRIGG_SSM_OFFLOADED_KEYS are set.
|
|
176
|
+
* Results are cached for FRIGG_SSM_CACHE_TTL seconds (default 300; 0 = forever).
|
|
177
|
+
* A failed fetch is never cached, so the next invocation retries.
|
|
178
|
+
*/
|
|
179
|
+
const parametersToEnv = async () => {
|
|
180
|
+
const prefix = process.env.SSM_PARAMETER_PREFIX;
|
|
181
|
+
const rawKeys = process.env.FRIGG_SSM_OFFLOADED_KEYS;
|
|
182
|
+
if (!prefix || !rawKeys) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const keys = parseKeys(rawKeys);
|
|
186
|
+
if (keys.length === 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (cacheExpiresAt !== null && Date.now() < cacheExpiresAt) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!inflight) {
|
|
195
|
+
inflight = loadParameters(prefix, keys).finally(() => {
|
|
196
|
+
inflight = null;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return inflight;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Adopt keys the INIT preload (ssm-preload.mjs) already populated into
|
|
204
|
+
* process.env, so the handler-time loader treats them as its own: it seeds
|
|
205
|
+
* the cache TTL and marks the keys owned so the TTL-refresh path re-fetches
|
|
206
|
+
* them. The preload runs in-process (NODE_OPTIONS=--import), sharing this
|
|
207
|
+
* module singleton. Pass ONLY the keys the preload actually set (not keys
|
|
208
|
+
* already present as real env vars) so the real-env-wins rule is preserved.
|
|
209
|
+
*/
|
|
210
|
+
const adoptPreloadedKeys = (keys) => {
|
|
211
|
+
for (const key of keys) {
|
|
212
|
+
ownedKeys.add(key);
|
|
213
|
+
}
|
|
214
|
+
const ttlSeconds = getCacheTtlSeconds();
|
|
215
|
+
cacheExpiresAt =
|
|
216
|
+
ttlSeconds === 0 ? Infinity : Date.now() + ttlSeconds * 1000;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* INIT-phase preload used by ssm-preload.mjs: fetch offloaded parameters and
|
|
221
|
+
* set them into process.env, then adopt the keys it set so the handler-time
|
|
222
|
+
* loader refreshes them on TTL. Real env wins — a key already present is never
|
|
223
|
+
* fetched or overwritten, so a console override keeps working even if that
|
|
224
|
+
* key's parameter is absent from SSM (fetching it would fail INIT). Returns the
|
|
225
|
+
* keys actually set (empty when every key was already in real env). Throws,
|
|
226
|
+
* listing the names, only for a genuinely-needed parameter that is missing.
|
|
227
|
+
*/
|
|
228
|
+
const preloadOffloadedParameters = async (prefix, keys, options = {}) => {
|
|
229
|
+
const keysToFetch = keys.filter((key) => process.env[key] === undefined);
|
|
230
|
+
if (keysToFetch.length === 0) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const values = await fetchOffloadedParameters(prefix, keysToFetch, options);
|
|
235
|
+
const setKeys = [];
|
|
236
|
+
for (const [key, value] of Object.entries(values)) {
|
|
237
|
+
process.env[key] = value;
|
|
238
|
+
setKeys.push(key);
|
|
239
|
+
}
|
|
240
|
+
adoptPreloadedKeys(setKeys);
|
|
241
|
+
return setKeys;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const _resetCache = () => {
|
|
245
|
+
client = null;
|
|
246
|
+
cacheExpiresAt = null;
|
|
247
|
+
inflight = null;
|
|
248
|
+
ownedKeys.clear();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
parametersToEnv,
|
|
253
|
+
fetchOffloadedParameters,
|
|
254
|
+
preloadOffloadedParameters,
|
|
255
|
+
adoptPreloadedKeys,
|
|
256
|
+
_resetCache,
|
|
257
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// INIT-phase SSM loader (ADR-027).
|
|
2
|
+
//
|
|
3
|
+
// Loaded via NODE_OPTIONS=--import BEFORE the Lambda handler and any api-module
|
|
4
|
+
// is required, so SSM-offloaded values are present in process.env at
|
|
5
|
+
// module-load time — when api-modules capture their OAuth client credentials
|
|
6
|
+
// in a top-level `const Definition = { env: { client_secret: process.env.X } }`.
|
|
7
|
+
// The runtime loader (parameters-to-env.js) runs inside the handler, which is
|
|
8
|
+
// too late for those module-load reads; this preload closes that gap.
|
|
9
|
+
//
|
|
10
|
+
// Top-level await here is awaited by Node before the entry module loads, so the
|
|
11
|
+
// fetch completes first. A fetch failure rejects the preload, failing Lambda
|
|
12
|
+
// INIT loudly (fail-fast) rather than starting with undefined credentials.
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
const prefix = process.env.SSM_PARAMETER_PREFIX;
|
|
19
|
+
const rawKeys = process.env.FRIGG_SSM_OFFLOADED_KEYS;
|
|
20
|
+
|
|
21
|
+
if (prefix && rawKeys) {
|
|
22
|
+
const keys = rawKeys
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((key) => key.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
|
|
27
|
+
if (keys.length > 0) {
|
|
28
|
+
// The fetch/real-env-wins/adopt logic lives in the CJS module (tested
|
|
29
|
+
// there); this preload is a thin INIT-phase shim over it.
|
|
30
|
+
const { preloadOffloadedParameters } = require('./parameters-to-env');
|
|
31
|
+
const setKeys = await preloadOffloadedParameters(prefix, keys);
|
|
32
|
+
console.log(`frigg-ssm-preload: loaded ${setKeys.length} parameter(s) at INIT under ${prefix}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Integration Extensions Quick Start
|
|
2
2
|
|
|
3
|
-
Tier 3 **Integration Extensions** let an API module ship reusable handler bundles — receiver routes, event handlers, queues, workers — that an integration consumes declaratively via `Definition.extensions`. See [ADR-
|
|
3
|
+
Tier 3 **Integration Extensions** let an API module ship reusable handler bundles — receiver routes, event handlers, queues, workers — that an integration consumes declaratively via `Definition.extensions`. See [ADR-015: Extensions Taxonomy](../../../docs/architecture-decisions/015-extensions-taxonomy.md) for the full taxonomy.
|
|
4
4
|
|
|
5
5
|
## When to use this vs `Definition.webhooks: true`
|
|
6
6
|
|
|
@@ -235,6 +235,6 @@ This keeps core platform-neutral and reusable while keeping the api-module code
|
|
|
235
235
|
|
|
236
236
|
## See also
|
|
237
237
|
|
|
238
|
-
- [ADR-
|
|
238
|
+
- [ADR-015: Extensions Taxonomy](../../../docs/architecture-decisions/015-extensions-taxonomy.md) — the three-tier taxonomy (Core Plugins / Application Extensions / Integration Extensions)
|
|
239
239
|
- [WEBHOOK-QUICKSTART](./WEBHOOK-QUICKSTART.md) — per-account `Definition.webhooks: true` pattern
|
|
240
240
|
- `extension.js` — the validation + flattening helpers (`validateExtensionBinding`, `getExtensionRoutes`, `getExtensionWorkers`)
|
|
@@ -220,11 +220,11 @@ class IntegrationBase {
|
|
|
220
220
|
/**
|
|
221
221
|
* Returns the modules as object with keys as module names.
|
|
222
222
|
* Uses the keys from Definition.modules to attach modules correctly.
|
|
223
|
-
*
|
|
223
|
+
*
|
|
224
224
|
* Example:
|
|
225
225
|
* Definition.modules = { attio: {...}, quo: { definition: { getName: () => 'quo-attio' } } }
|
|
226
226
|
* Module with getName()='quo-attio' gets attached as this.quo (not this['quo-attio'])
|
|
227
|
-
*
|
|
227
|
+
*
|
|
228
228
|
* @private
|
|
229
229
|
* @param {Array} integrationModules - Array of module instances
|
|
230
230
|
* @returns {Object} The modules object
|
|
@@ -791,8 +791,18 @@ class IntegrationBase {
|
|
|
791
791
|
if (!this.id) return;
|
|
792
792
|
|
|
793
793
|
if (delegateString === 'CREDENTIAL_INVALIDATED') {
|
|
794
|
+
const detail =
|
|
795
|
+
object?.reason || object?.statusCode
|
|
796
|
+
? ` (status ${object?.statusCode ?? '?'}: ${
|
|
797
|
+
object?.reason ?? 'no reason given'
|
|
798
|
+
})`
|
|
799
|
+
: '';
|
|
794
800
|
console.log(
|
|
795
|
-
`[Frigg] Module ${
|
|
801
|
+
`[Frigg] Module ${
|
|
802
|
+
notifier?.name || '?'
|
|
803
|
+
} reported invalid credentials for integration ${
|
|
804
|
+
this.id
|
|
805
|
+
} — marking ERROR${detail}`
|
|
796
806
|
);
|
|
797
807
|
await this.persistStatus('ERROR');
|
|
798
808
|
return;
|
|
@@ -801,7 +811,11 @@ class IntegrationBase {
|
|
|
801
811
|
if (delegateString === 'CREDENTIAL_VALIDATED') {
|
|
802
812
|
if (this.status !== 'ERROR') return;
|
|
803
813
|
console.log(
|
|
804
|
-
`[Frigg] Module ${
|
|
814
|
+
`[Frigg] Module ${
|
|
815
|
+
notifier?.name || '?'
|
|
816
|
+
} reported valid credentials for integration ${
|
|
817
|
+
this.id
|
|
818
|
+
} — clearing ERROR → ENABLED`
|
|
805
819
|
);
|
|
806
820
|
await this.persistStatus('ENABLED');
|
|
807
821
|
}
|
package/modules/module.js
CHANGED
|
@@ -147,15 +147,24 @@ class Module extends Delegate {
|
|
|
147
147
|
} else if (delegateString === this.api.DLGT_TOKEN_DEAUTHORIZED) {
|
|
148
148
|
await this.deauthorize();
|
|
149
149
|
} else if (delegateString === this.api.DLGT_INVALID_AUTH) {
|
|
150
|
-
await this.markCredentialsInvalid();
|
|
150
|
+
await this.markCredentialsInvalid(object);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
async markCredentialsInvalid() {
|
|
154
|
+
async markCredentialsInvalid(diagnosticInfo = null) {
|
|
155
155
|
if (!this.credential) return;
|
|
156
156
|
|
|
157
157
|
if (!this.credential.id) return;
|
|
158
158
|
|
|
159
|
+
if (diagnosticInfo) {
|
|
160
|
+
console.error(
|
|
161
|
+
`[Frigg] Module ${this.name} credentials rejected (status ${
|
|
162
|
+
diagnosticInfo.statusCode ?? '?'
|
|
163
|
+
}):`,
|
|
164
|
+
diagnosticInfo.message ?? diagnosticInfo
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
await this.credentialRepository.updateAuthenticationStatus(
|
|
160
169
|
this.credential.id,
|
|
161
170
|
false
|
|
@@ -181,6 +190,10 @@ class Module extends Delegate {
|
|
|
181
190
|
await this.notify(this.DLGT_CREDENTIAL_INVALIDATED, {
|
|
182
191
|
credentialId: this.credential.id,
|
|
183
192
|
moduleName: this.name,
|
|
193
|
+
...(diagnosticInfo && {
|
|
194
|
+
reason: diagnosticInfo.message,
|
|
195
|
+
statusCode: diagnosticInfo.statusCode,
|
|
196
|
+
}),
|
|
184
197
|
});
|
|
185
198
|
} catch (err) {
|
|
186
199
|
console.error(
|
|
@@ -4,6 +4,7 @@ const { FetchError } = require('../../errors');
|
|
|
4
4
|
const { get } = require('../../assertions');
|
|
5
5
|
|
|
6
6
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
7
|
+
const MAX_AUTH_RETRIES = 3;
|
|
7
8
|
|
|
8
9
|
class Requester extends Delegate {
|
|
9
10
|
constructor(params) {
|
|
@@ -11,6 +12,7 @@ class Requester extends Delegate {
|
|
|
11
12
|
this.backOff = get(params, 'backOff', [1, 3, 10, 30, 60, 180]);
|
|
12
13
|
this.isRefreshable = false;
|
|
13
14
|
this.refreshCount = 0;
|
|
15
|
+
this.authGraceRetryCount = 0;
|
|
14
16
|
this.DLGT_INVALID_AUTH = 'INVALID_AUTH';
|
|
15
17
|
this.delegateTypes.push(this.DLGT_INVALID_AUTH);
|
|
16
18
|
this.agent = get(params, 'agent', null);
|
|
@@ -58,7 +60,16 @@ class Requester extends Delegate {
|
|
|
58
60
|
return resp.text();
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} url - The request URL, relative or absolute.
|
|
65
|
+
* @param {Object} options - Fetch options (method, headers, body, query,
|
|
66
|
+
* returnFullRes, etc.) built by the `_get`/`_post`/`_patch`/`_put`/
|
|
67
|
+
* `_delete` wrappers.
|
|
68
|
+
* @param {number} attempt - 0-based count of retries already made for
|
|
69
|
+
* this call. Indexes `this.backOff` for the next delay and is passed
|
|
70
|
+
* back in on each recursive retry.
|
|
71
|
+
*/
|
|
72
|
+
async _request(url, options, attempt = 0) {
|
|
62
73
|
let encodedUrl = encodeURI(url);
|
|
63
74
|
if (options.query) {
|
|
64
75
|
let queryBuild = '?';
|
|
@@ -116,11 +127,11 @@ class Requester extends Delegate {
|
|
|
116
127
|
// stall at batch scale.
|
|
117
128
|
const isTimeout =
|
|
118
129
|
e?.name === 'AbortError' || e?.type === 'aborted';
|
|
119
|
-
if (e?.code === 'ECONNRESET' &&
|
|
130
|
+
if (e?.code === 'ECONNRESET' && attempt < this.backOff.length) {
|
|
120
131
|
clearRequestTimer();
|
|
121
|
-
const delay = this.backOff[
|
|
132
|
+
const delay = this.backOff[attempt] * 1000;
|
|
122
133
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
123
|
-
return this._request(url, options,
|
|
134
|
+
return this._request(url, options, attempt + 1);
|
|
124
135
|
}
|
|
125
136
|
const fetchError = await FetchError.create({
|
|
126
137
|
resource: encodedUrl,
|
|
@@ -143,30 +154,57 @@ class Requester extends Delegate {
|
|
|
143
154
|
const { status } = response;
|
|
144
155
|
|
|
145
156
|
// If the status is retriable and there are back off requests left, retry the request
|
|
146
|
-
if (
|
|
157
|
+
if (
|
|
158
|
+
(status === 429 || status >= 500) &&
|
|
159
|
+
attempt < this.backOff.length
|
|
160
|
+
) {
|
|
147
161
|
clearRequestTimer();
|
|
148
|
-
const delay = this.backOff[
|
|
162
|
+
const delay = this.backOff[attempt] * 1000;
|
|
149
163
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
150
|
-
return this._request(url, options,
|
|
164
|
+
return this._request(url, options, attempt + 1);
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
if (status === 401) {
|
|
154
168
|
if (!this.isRefreshable) {
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
// Up to MAX_AUTH_RETRIES grace retries before invalidating
|
|
170
|
+
// — a 401 alone isn't proof the credential is bad.
|
|
171
|
+
if (
|
|
172
|
+
this.authGraceRetryCount < MAX_AUTH_RETRIES &&
|
|
173
|
+
this.authGraceRetryCount < this.backOff.length
|
|
174
|
+
) {
|
|
175
|
+
const delay =
|
|
176
|
+
this.backOff[this.authGraceRetryCount] * 1000;
|
|
177
|
+
this.authGraceRetryCount++;
|
|
178
|
+
clearRequestTimer();
|
|
179
|
+
await new Promise((resolve) =>
|
|
180
|
+
setTimeout(resolve, delay)
|
|
181
|
+
);
|
|
182
|
+
return this._request(url, options, attempt + 1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw await this._invalidateAuth(
|
|
186
|
+
encodedUrl,
|
|
187
|
+
options,
|
|
188
|
+
response
|
|
189
|
+
);
|
|
157
190
|
}
|
|
158
191
|
|
|
159
|
-
if (this.refreshCount
|
|
192
|
+
if (this.refreshCount < MAX_AUTH_RETRIES) {
|
|
160
193
|
this.refreshCount++;
|
|
161
194
|
const refreshSucceeded = await this.refreshAuth();
|
|
162
195
|
if (refreshSucceeded) {
|
|
163
196
|
clearRequestTimer();
|
|
164
|
-
return this._request(url, options,
|
|
197
|
+
return this._request(url, options, attempt + 1);
|
|
165
198
|
}
|
|
166
199
|
|
|
167
|
-
await this.
|
|
168
|
-
|
|
200
|
+
throw await this._invalidateAuth(
|
|
201
|
+
encodedUrl,
|
|
202
|
+
options,
|
|
203
|
+
response
|
|
204
|
+
);
|
|
169
205
|
}
|
|
206
|
+
|
|
207
|
+
throw await this._invalidateAuth(encodedUrl, options, response);
|
|
170
208
|
}
|
|
171
209
|
|
|
172
210
|
// If the error wasn't retried, throw. FetchError.create reads
|
|
@@ -188,6 +226,7 @@ class Requester extends Delegate {
|
|
|
188
226
|
// a later 401 in the same Requester lifetime can attempt refresh
|
|
189
227
|
// again instead of silently falling through.
|
|
190
228
|
this.refreshCount = 0;
|
|
229
|
+
this.authGraceRetryCount = 0;
|
|
191
230
|
|
|
192
231
|
// parsedBody consumes the response body stream. If the server
|
|
193
232
|
// stalls mid-stream the timer (still armed) aborts it.
|
|
@@ -204,6 +243,16 @@ class Requester extends Delegate {
|
|
|
204
243
|
}
|
|
205
244
|
}
|
|
206
245
|
|
|
246
|
+
async _invalidateAuth(encodedUrl, options, response) {
|
|
247
|
+
const fetchError = await FetchError.create({
|
|
248
|
+
resource: encodedUrl,
|
|
249
|
+
init: options,
|
|
250
|
+
response,
|
|
251
|
+
});
|
|
252
|
+
await this.notify(this.DLGT_INVALID_AUTH, fetchError);
|
|
253
|
+
return fetchError;
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
_maybeFlagTimeoutDuringBodyRead(err, timeoutMs) {
|
|
208
257
|
if (!err || typeof err !== 'object') return err;
|
|
209
258
|
if (err.isTimeout) return err;
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.102",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
8
8
|
"@aws-sdk/client-lambda": "^3.714.0",
|
|
9
9
|
"@aws-sdk/client-sqs": "^3.588.0",
|
|
10
|
+
"@aws-sdk/client-ssm": "^3.588.0",
|
|
10
11
|
"@hapi/boom": "^10.0.1",
|
|
11
12
|
"bcryptjs": "^2.4.3",
|
|
12
13
|
"body-parser": "^1.20.5",
|
|
@@ -38,9 +39,9 @@
|
|
|
38
39
|
}
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
43
|
-
"@friggframework/test": "2.0.0-next.
|
|
42
|
+
"@friggframework/eslint-config": "2.0.0-next.102",
|
|
43
|
+
"@friggframework/prettier-config": "2.0.0-next.102",
|
|
44
|
+
"@friggframework/test": "2.0.0-next.102",
|
|
44
45
|
"@prisma/client": "^6.19.3",
|
|
45
46
|
"@types/lodash": "4.17.15",
|
|
46
47
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +81,5 @@
|
|
|
80
81
|
"publishConfig": {
|
|
81
82
|
"access": "public"
|
|
82
83
|
},
|
|
83
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "88c3879ce502f9a5eb0e08d41ec55e2559e1fca8"
|
|
84
85
|
}
|