@friggframework/core 2.0.0--canary.623.b39b42d.0 → 2.0.0--canary.625.950ba82.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/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
@@ -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,170 @@
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 (command) => {
42
+ let attempt = 0;
43
+ for (;;) {
44
+ try {
45
+ return await client.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
+ const loadParameters = async (prefix, keys) => {
61
+ const { SSMClient, GetParametersCommand } = require('@aws-sdk/client-ssm');
62
+ if (!client) {
63
+ client = new SSMClient({ region: process.env.AWS_REGION });
64
+ }
65
+
66
+ // A key already living in process.env wins over SSM, except keys we set
67
+ // ourselves — those we refresh so later invocations pick up rotations.
68
+ const keysToFetch = keys.filter(
69
+ (key) => ownedKeys.has(key) || process.env[key] === undefined
70
+ );
71
+ const nameFor = (key) => `${prefix}/${key}`;
72
+
73
+ // A refresh means every key already has a served value; a warm container
74
+ // must keep serving stale values through an SSM blip instead of erroring.
75
+ const isRefresh =
76
+ keysToFetch.length > 0 &&
77
+ keysToFetch.every((key) => ownedKeys.has(key));
78
+
79
+ const found = new Map();
80
+ try {
81
+ for (const batch of chunk(keysToFetch, BATCH_SIZE)) {
82
+ const { Parameters = [] } = await sendWithRetry(
83
+ new GetParametersCommand({
84
+ Names: batch.map(nameFor),
85
+ WithDecryption: true,
86
+ })
87
+ );
88
+ for (const param of Parameters) {
89
+ found.set(param.Name, param);
90
+ }
91
+ }
92
+
93
+ const missing = keysToFetch
94
+ .map(nameFor)
95
+ .filter((name) => !found.has(name));
96
+ if (missing.length > 0) {
97
+ throw new Error(
98
+ `parametersToEnv: missing SSM parameters under ${prefix}: ${missing.join(
99
+ ', '
100
+ )}`
101
+ );
102
+ }
103
+ } catch (err) {
104
+ if (!isRefresh) {
105
+ throw err;
106
+ }
107
+ console.warn(
108
+ `parametersToEnv: refresh failed, keeping cached values: ${err.message}`
109
+ );
110
+ cacheExpiresAt = Date.now() + REFRESH_RETRY_MS;
111
+ return;
112
+ }
113
+
114
+ const loaded = keysToFetch.map((key) => {
115
+ const param = found.get(nameFor(key));
116
+ process.env[key] = param.Value;
117
+ ownedKeys.add(key);
118
+ return `${param.Name} (v${param.Version})`;
119
+ });
120
+
121
+ const ttlSeconds = getCacheTtlSeconds();
122
+ cacheExpiresAt =
123
+ ttlSeconds === 0 ? Infinity : Date.now() + ttlSeconds * 1000;
124
+
125
+ if (loaded.length > 0) {
126
+ console.log('parametersToEnv: loaded', loaded);
127
+ }
128
+ };
129
+
130
+ /**
131
+ * Hydrate process.env from SSM Parameter Store.
132
+ *
133
+ * No-op unless both SSM_PARAMETER_PREFIX and FRIGG_SSM_OFFLOADED_KEYS are set.
134
+ * Results are cached for FRIGG_SSM_CACHE_TTL seconds (default 300; 0 = forever).
135
+ * A failed fetch is never cached, so the next invocation retries.
136
+ */
137
+ const parametersToEnv = async () => {
138
+ const prefix = process.env.SSM_PARAMETER_PREFIX;
139
+ const rawKeys = process.env.FRIGG_SSM_OFFLOADED_KEYS;
140
+ if (!prefix || !rawKeys) {
141
+ return;
142
+ }
143
+ const keys = parseKeys(rawKeys);
144
+ if (keys.length === 0) {
145
+ return;
146
+ }
147
+
148
+ if (cacheExpiresAt !== null && Date.now() < cacheExpiresAt) {
149
+ return;
150
+ }
151
+
152
+ if (!inflight) {
153
+ inflight = loadParameters(prefix, keys).finally(() => {
154
+ inflight = null;
155
+ });
156
+ }
157
+ return inflight;
158
+ };
159
+
160
+ const _resetCache = () => {
161
+ client = null;
162
+ cacheExpiresAt = null;
163
+ inflight = null;
164
+ ownedKeys.clear();
165
+ };
166
+
167
+ module.exports = {
168
+ parametersToEnv,
169
+ _resetCache,
170
+ };
@@ -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-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) for the full taxonomy.
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-EXTENSIONS](../../../docs/architecture/ADR-EXTENSIONS.md) — the three-tier taxonomy (Core Plugins / Application Extensions / Integration Extensions)
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`)
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--canary.623.b39b42d.0",
4
+ "version": "2.0.0--canary.625.950ba82.0",
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--canary.623.b39b42d.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.623.b39b42d.0",
43
- "@friggframework/test": "2.0.0--canary.623.b39b42d.0",
42
+ "@friggframework/eslint-config": "2.0.0--canary.625.950ba82.0",
43
+ "@friggframework/prettier-config": "2.0.0--canary.625.950ba82.0",
44
+ "@friggframework/test": "2.0.0--canary.625.950ba82.0",
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": "b39b42dc96ec3dfe4963a63b27468cd1b9c107c6"
84
+ "gitHead": "950ba828b2885a01768dbfbee5a1fd531efb89ab"
84
85
  }