@friggframework/core 2.0.0-next.101 → 2.0.0-next.103

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 (59) hide show
  1. package/README.md +40 -0
  2. package/application/commands/usage-commands.js +56 -0
  3. package/application/index.js +10 -9
  4. package/core/CLAUDE.md +10 -0
  5. package/core/create-handler.js +116 -10
  6. package/core/parameters-to-env.js +257 -0
  7. package/core/ssm-preload.mjs +34 -0
  8. package/generated/prisma-mongodb/edge.js +16 -4
  9. package/generated/prisma-mongodb/index-browser.js +13 -1
  10. package/generated/prisma-mongodb/index.d.ts +1503 -105
  11. package/generated/prisma-mongodb/index.js +16 -4
  12. package/generated/prisma-mongodb/package.json +1 -1
  13. package/generated/prisma-mongodb/schema.prisma +23 -0
  14. package/generated/prisma-mongodb/wasm.js +16 -4
  15. package/generated/prisma-postgresql/edge.js +16 -4
  16. package/generated/prisma-postgresql/index-browser.js +13 -1
  17. package/generated/prisma-postgresql/index.d.ts +1540 -91
  18. package/generated/prisma-postgresql/index.js +16 -4
  19. package/generated/prisma-postgresql/package.json +1 -1
  20. package/generated/prisma-postgresql/schema.prisma +22 -0
  21. package/generated/prisma-postgresql/wasm.js +16 -4
  22. package/handlers/app-definition-loader.js +26 -3
  23. package/handlers/integration-event-dispatcher.js +29 -15
  24. package/handlers/routers/integration-webhook-routers.js +20 -7
  25. package/index.js +16 -9
  26. package/integrations/EXTENSIONS.md +2 -2
  27. package/integrations/integration-base.js +64 -7
  28. package/modules/requester/requester.js +106 -5
  29. package/package.json +13 -5
  30. package/prisma-mongodb/schema.prisma +23 -0
  31. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
  32. package/prisma-postgresql/schema.prisma +22 -0
  33. package/reporting/README.md +8 -1
  34. package/reporting/reporting-router.js +8 -1
  35. package/reporting/use-cases/list-integrations-report.js +53 -6
  36. package/telemetry/README.md +331 -0
  37. package/telemetry/bind-telemetry-context.js +73 -0
  38. package/telemetry/canonical-counters.js +52 -0
  39. package/telemetry/exporters.js +85 -0
  40. package/telemetry/index.js +26 -0
  41. package/telemetry/instrument-handler.js +87 -0
  42. package/telemetry/no-op-telemetry.js +67 -0
  43. package/telemetry/north-star.js +103 -0
  44. package/telemetry/otel-telemetry.js +213 -0
  45. package/telemetry/plugin-subscribers.js +77 -0
  46. package/telemetry/telemetry-config.js +120 -0
  47. package/telemetry/telemetry-context.js +40 -0
  48. package/telemetry/telemetry-event-bus.js +58 -0
  49. package/telemetry/telemetry-runtime.js +147 -0
  50. package/telemetry/telemetry-service.js +51 -0
  51. package/telemetry/usage-rollup-subscriber.js +116 -0
  52. package/usage/README.md +54 -0
  53. package/usage/index.js +17 -0
  54. package/usage/repositories/usage-repository-documentdb.js +194 -0
  55. package/usage/repositories/usage-repository-factory.js +25 -0
  56. package/usage/repositories/usage-repository-interface.js +37 -0
  57. package/usage/repositories/usage-repository-prisma.js +146 -0
  58. package/usage/tracked-metrics.js +38 -0
  59. package/usage/usage-windows.js +24 -0
package/README.md CHANGED
@@ -200,6 +200,46 @@ const secureData = cryptor.encrypt(JSON.stringify({
200
200
  }));
201
201
  ```
202
202
 
203
+ ### 4b. Telemetry & Usage Tracking (`/telemetry`, `/usage`)
204
+
205
+ Vendor-neutral OpenTelemetry observability plus durable, per-integration usage
206
+ counters (ADR-011). No-op by default (zero cold-start cost); framework seams are
207
+ auto-instrumented so integrations get handler/API-module/webhook metrics for free.
208
+
209
+ **Usage:**
210
+ ```javascript
211
+ // App definition — turn on export + declare a North Star:
212
+ const Definition = {
213
+ name: 'my-app',
214
+ telemetry: {
215
+ exporter: { type: 'otlp', endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT },
216
+ northStar: { default: { name: 'records.synced' } },
217
+ },
218
+ };
219
+
220
+ // Integration code — custom metrics/spans (this.telemetry is auto-tagged):
221
+ await this.telemetry.span('delta_sync', async () => {
222
+ this.telemetry.count('records.synced', batch.length, { entity: 'contact' });
223
+ });
224
+
225
+ // Declare which usage counters an integration reports (opts into reporting):
226
+ class HubSpotIntegration extends IntegrationBase {
227
+ static Definition = {
228
+ name: 'hubspot',
229
+ usage: { canonical: ['records.synced', 'api.requests'] },
230
+ };
231
+ }
232
+
233
+ // Read the durable usage store (never an APM):
234
+ const frigg = createFriggCommands({ integrationClass: HubSpotIntegration });
235
+ await frigg.usage.getTotalsByDimension({ metric: 'records.synced', groupBy: 'integrationType' });
236
+ ```
237
+
238
+ **See:** [`telemetry/README.md`](telemetry/README.md) for the full guide
239
+ (exporters, custom metrics, the Usage-Counter contract, North Star, the plugin
240
+ tap, cardinality rules, and caveats) and [`usage/README.md`](usage/README.md) for
241
+ the store internals.
242
+
203
243
  ### 5. Error Handling (`/errors`)
204
244
 
205
245
  Standardized error types with proper HTTP status codes.
@@ -0,0 +1,56 @@
1
+ // `frigg.usage.*` — the read/write surface over the durable usage store.
2
+ const {
3
+ createUsageRepository,
4
+ } = require('../../usage/repositories/usage-repository-factory');
5
+ const { computeUsageWindows } = require('../../usage/usage-windows');
6
+ const { resolveNorthStarEntry } = require('../../telemetry/north-star');
7
+
8
+ function createUsageCommands({ usageRepository } = {}) {
9
+ const repository = usageRepository || createUsageRepository();
10
+
11
+ return {
12
+ // Increments both the day and hour window rows for `at`.
13
+ async recordUsageCounter({
14
+ integrationId,
15
+ integrationType,
16
+ metric,
17
+ value = 1,
18
+ at = new Date(),
19
+ }) {
20
+ const windows = computeUsageWindows(at);
21
+ for (const window of windows) {
22
+ await repository.increment({
23
+ integrationId,
24
+ integrationType,
25
+ metric,
26
+ window,
27
+ value,
28
+ });
29
+ }
30
+ },
31
+
32
+ async getTotalsByDimension(args) {
33
+ return repository.getTotalsByDimension(args);
34
+ },
35
+
36
+ async getTimeSeries(args) {
37
+ return repository.getTimeSeries(args);
38
+ },
39
+
40
+ // Caller supplies the North Star config (Definition.telemetry.northStar);
41
+ // resolves the counter for the type (byType > default), null if none.
42
+ async getNorthStarTotals({ northStar, integrationType, since, groupBy = 'integrationType', bucket } = {}) {
43
+ const entry = resolveNorthStarEntry(northStar, integrationType);
44
+ if (!entry) return null;
45
+ const totals = await repository.getTotalsByDimension({
46
+ metric: entry.name,
47
+ groupBy,
48
+ since,
49
+ bucket,
50
+ });
51
+ return { metric: entry.name, totals };
52
+ },
53
+ };
54
+ }
55
+
56
+ module.exports = { createUsageCommands };
@@ -4,15 +4,10 @@ const {
4
4
  } = require('./commands/integration-commands');
5
5
  const { createUserCommands } = require('./commands/user-commands');
6
6
  const { createEntityCommands } = require('./commands/entity-commands');
7
- const {
8
- createCredentialCommands,
9
- } = require('./commands/credential-commands');
10
- const {
11
- createProcessCommands,
12
- } = require('./commands/process-commands');
13
- const {
14
- createSchedulerCommands,
15
- } = require('./commands/scheduler-commands');
7
+ const { createCredentialCommands } = require('./commands/credential-commands');
8
+ const { createProcessCommands } = require('./commands/process-commands');
9
+ const { createSchedulerCommands } = require('./commands/scheduler-commands');
10
+ const { createUsageCommands } = require('./commands/usage-commands');
16
11
 
17
12
  /**
18
13
  * Create a unified command factory with all CRUD operations
@@ -56,6 +51,11 @@ function createFriggCommands({ integrationClass }) {
56
51
 
57
52
  // Process commands
58
53
  ...processCommands,
54
+
55
+ // Usage read/write — nested to match `frigg.usage.*`. The North Star
56
+ // read takes its config as a call argument, so nothing telemetry-specific
57
+ // is threaded through this general factory.
58
+ usage: createUsageCommands(),
59
59
  };
60
60
  }
61
61
 
@@ -70,6 +70,7 @@ module.exports = {
70
70
  createCredentialCommands,
71
71
  createProcessCommands,
72
72
  createSchedulerCommands,
73
+ createUsageCommands,
73
74
 
74
75
  // Legacy standalone function
75
76
  findIntegrationContextByExternalEntityId,
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,92 @@
4
4
 
5
5
  const { initDebugLog, flushDebugLog } = require('../logs');
6
6
  const { secretsToEnv } = require('./secrets-to-env');
7
+ const { parametersToEnv } = require('./parameters-to-env');
8
+ const {
9
+ getTelemetry,
10
+ getUsageRollupSubscriber,
11
+ getPluginTelemetrySubscribers,
12
+ } = require('../telemetry/telemetry-runtime');
13
+
14
+ // Bounds the tail latency telemetry adds to every warm invocation. Kept low so
15
+ // an unreachable OTLP endpoint (e.g. a VPC Lambda with no NAT/egress) costs at
16
+ // most this, not multiple seconds. Override with OTEL_FLUSH_TIMEOUT_MS.
17
+ const DEFAULT_FLUSH_TIMEOUT_MS =
18
+ Number(process.env.OTEL_FLUSH_TIMEOUT_MS) || 500;
19
+
20
+ /**
21
+ * Fold the invocation's buffered usage counters into the durable store, then
22
+ * clear the buffer. On an SQS redelivery (ApproximateReceiveCount > 1) we
23
+ * DISCARD rather than flush — the prior delivery already counted, and the usage
24
+ * accuracy contract is "approximate, skip obvious redeliveries". Fully guarded.
25
+ */
26
+ async function flushUsageRollup(subscriber, eventSummary, shouldUseDatabase) {
27
+ if (!subscriber) return;
28
+ try {
29
+ // Persisting usage requires a DB connection. DB-free handlers (e.g. the
30
+ // webhook-receipt route) never called connectPrisma, so drop the buffer
31
+ // instead of issuing a connectionless Prisma write.
32
+ if (!shouldUseDatabase) {
33
+ subscriber.discard();
34
+ return;
35
+ }
36
+ // Discard only when EVERY record in the batch is a redelivery. The buffer
37
+ // is invocation-scoped (not per-message), so discarding on *any*
38
+ // redelivery would drop the fresh records' counts too (silent
39
+ // under-count). For a mixed batch we flush: preserving fresh counts and
40
+ // at worst re-counting the one redelivered record is strictly better than
41
+ // losing fresh data for an approximate store. (Integration queue workers
42
+ // are batchSize:1 today, so a batch is all-or-nothing; this keeps it
43
+ // correct if batchSize is ever raised.)
44
+ const records = Array.isArray(eventSummary?.records)
45
+ ? eventSummary.records
46
+ : [];
47
+ const allRedelivered =
48
+ records.length > 0 &&
49
+ records.every((r) => Number(r.receiveCount) > 1);
50
+ if (allRedelivered) {
51
+ subscriber.discard();
52
+ } else {
53
+ await subscriber.flush();
54
+ }
55
+ } catch (_) {
56
+ // Usage rollup must never break the handler.
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Flush telemetry before the Lambda container freezes. Because
62
+ * `callbackWaitsForEmptyEventLoop=false` (below) stops the event loop the moment
63
+ * the handler returns, OTel's timer-driven batch processors would never fire —
64
+ * so spans/metrics must be flushed synchronously here. Bounded by a timeout so a
65
+ * stalled exporter can never block the response, and fully guarded so a flush
66
+ * failure never breaks the handler.
67
+ */
68
+ async function flushTelemetry(telemetry, timeoutMs) {
69
+ try {
70
+ if (
71
+ !telemetry ||
72
+ typeof telemetry.isEnabled !== 'function' ||
73
+ !telemetry.isEnabled()
74
+ ) {
75
+ return;
76
+ }
77
+ let timer;
78
+ const deadline = new Promise((resolve) => {
79
+ timer = setTimeout(resolve, timeoutMs);
80
+ });
81
+ try {
82
+ await Promise.race([
83
+ Promise.resolve(telemetry.forceFlush()),
84
+ deadline,
85
+ ]);
86
+ } finally {
87
+ clearTimeout(timer);
88
+ }
89
+ } catch (_) {
90
+ // Telemetry flush must never break the handler.
91
+ }
92
+ }
7
93
 
8
94
  // Best-effort extraction of correlation identifiers from a Lambda event.
9
95
  // For SQS: pulls messageIds + parsed event/processId/integrationId from each
@@ -36,8 +122,7 @@ const summarizeLambdaEvent = (event) => {
36
122
  if (event.httpMethod || event.requestContext?.http) {
37
123
  return {
38
124
  source: 'http',
39
- method:
40
- event.httpMethod || event.requestContext?.http?.method,
125
+ method: event.httpMethod || event.requestContext?.http?.method,
41
126
  path: event.path || event.rawPath,
42
127
  };
43
128
  }
@@ -50,6 +135,9 @@ const createHandler = (optionByName = {}) => {
50
135
  isUserFacingResponse = true,
51
136
  method,
52
137
  shouldUseDatabase = true,
138
+ telemetry,
139
+ flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
140
+ usageRollup,
53
141
  } = optionByName;
54
142
 
55
143
  if (!method) {
@@ -58,16 +146,23 @@ const createHandler = (optionByName = {}) => {
58
146
 
59
147
  return async (event, context) => {
60
148
  const eventSummary = summarizeLambdaEvent(event);
149
+ const activeTelemetry = telemetry || getTelemetry();
150
+ const activeUsageRollup =
151
+ usageRollup !== undefined
152
+ ? usageRollup
153
+ : getUsageRollupSubscriber();
154
+
155
+ // Wire adopter-declared telemetry subscribers once per cold start.
156
+ // Memoized in the singleton, so this is a cheap
157
+ // no-op after the first invocation.
158
+ getPluginTelemetrySubscribers();
61
159
 
62
160
  try {
63
- console.info(
64
- `[createHandler] ${eventName}: handler entry`,
65
- {
66
- eventName,
67
- awsRequestId: context?.awsRequestId,
68
- ...eventSummary,
69
- }
70
- );
161
+ console.info(`[createHandler] ${eventName}: handler entry`, {
162
+ eventName,
163
+ awsRequestId: context?.awsRequestId,
164
+ ...eventSummary,
165
+ });
71
166
 
72
167
  initDebugLog(eventName, event);
73
168
 
@@ -80,6 +175,9 @@ const createHandler = (optionByName = {}) => {
80
175
  // 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
176
  await secretsToEnv();
82
177
 
178
+ // 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.
179
+ await parametersToEnv();
180
+
83
181
  // Lazy-required so DB-free handlers never load the Prisma client.
84
182
  if (shouldUseDatabase) {
85
183
  const { connectPrisma } = require('../database/prisma');
@@ -139,6 +237,14 @@ const createHandler = (optionByName = {}) => {
139
237
 
140
238
  // Here we can just rethrow and let AWS build the response.
141
239
  throw error;
240
+ } finally {
241
+ // Flush telemetry + usage before the container freezes.
242
+ await flushTelemetry(activeTelemetry, flushTimeoutMs);
243
+ await flushUsageRollup(
244
+ activeUsageRollup,
245
+ eventSummary,
246
+ shouldUseDatabase
247
+ );
142
248
  }
143
249
  };
144
250
  };
@@ -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
+ }