@karmaniverous/smoz 0.2.3 → 0.2.5

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/dist/cjs/index.js CHANGED
@@ -2,21 +2,9 @@
2
2
 
3
3
  var path = require('node:path');
4
4
  var node_url = require('node:url');
5
- var baseEventTypeMapSchema$1 = require('@/src/core/baseEventTypeMapSchema');
6
- var buildStageArtifacts = require('@/src/core/buildStageArtifacts');
7
- var httpTokens = require('@/src/core/httpTokens');
8
- var registry = require('@/src/core/registry');
9
- var serverlessConfig = require('@/src/core/serverlessConfig');
10
- var buildOpenApi = require('@/src/openapi/buildOpenApi');
11
- var buildServerless = require('@/src/serverless/buildServerless');
12
5
  var zod = require('zod');
13
- var stagesFactory = require('@/src/serverless/stagesFactory');
6
+ var radash = require('radash');
14
7
  var middy = require('@middy/core');
15
- var defineFunctionConfig = require('@/src/core/defineFunctionConfig');
16
- var httpStackCustomization = require('@/src/http/middleware/httpStackCustomization');
17
- var envBuilder = require('@/src/runtime/envBuilder');
18
- require('@/src/http/middleware/combine');
19
- var transformUtils = require('@/src/http/middleware/transformUtils');
20
8
  var httpContentNegotiation = require('@middy/http-content-negotiation');
21
9
  var httpCors = require('@middy/http-cors');
22
10
  var httpErrorHandler = require('@middy/http-error-handler');
@@ -24,157 +12,7 @@ var httpEventNormalizer = require('@middy/http-event-normalizer');
24
12
  var httpHeaderNormalizer = require('@middy/http-header-normalizer');
25
13
  var httpJsonBodyParser = require('@middy/http-json-body-parser');
26
14
  var httpResponseSerializer = require('@middy/http-response-serializer');
27
- var asApiMiddleware = require('@/src/http/middleware/asApiMiddleware');
28
- var httpZodValidator = require('@/src/http/middleware/httpZodValidator');
29
- var shortCircuitHead = require('@/src/http/middleware/shortCircuitHead');
30
- var wrapSerializer = require('@/src/http/middleware/wrapSerializer');
31
-
32
- /**
33
- * App (schema‑first)
34
- *
35
- * Central orchestrator for a SMOZ application. You provide:
36
- * - Global/stage parameter schemas and env exposure keys
37
- * - Serverless defaults (handler filename/export and context map)
38
- * - Event‑type map schema (extendable: e.g., add 'step')
39
- *
40
- * The instance:
41
- * - Validates configuration
42
- * - Exposes env and stage artifacts for Serverless (provider.environment and params)
43
- * - Provides a registry to define functions (HTTP and non‑HTTP)
44
- * - Aggregates artifacts:
45
- * - buildAllServerlessFunctions(): AWS['functions']
46
- * - buildAllOpenApiPaths(): ZodOpenApiPathsObject
47
- *
48
- * @remarks See README for a full quick‑start, and typedoc for detailed API docs.
49
- */
50
- /**
51
- * Application class. *
52
- * @typeParam GlobalParamsSchema - Zod object schema for global parameters
53
- * @typeParam StageParamsSchema - Zod object schema for per‑stage parameters
54
- * @typeParam EventTypeMapSchema - Zod object schema mapping event tokens to runtime types
55
- */ class App {
56
- /** Helper alias for stage artifacts type */
57
- static _stageArtifactsType = null;
58
- appRootAbs;
59
- // Schemas
60
- globalParamsSchema;
61
- stageParamsSchema;
62
- eventTypeMapSchema; // Serverless config
63
- serverless;
64
- // Env exposure
65
- global;
66
- stage;
67
- // Derived stage artifacts
68
- stages;
69
- environment;
70
- buildFnEnv;
71
- http;
72
- // HTTP tokens for runtime decision
73
- httpEventTypeTokens;
74
- // Registry (delegated to src/app/registry)
75
- registry;
76
- constructor(init) {
77
- this.appRootAbs = init.appRootAbs.replace(/\\/g, '/');
78
- this.globalParamsSchema = init.globalParamsSchema;
79
- this.stageParamsSchema = init.stageParamsSchema;
80
- // Default to base schema when omitted (apply default INSIDE the function)
81
- this.eventTypeMapSchema = (init.eventTypeMapSchema ??
82
- baseEventTypeMapSchema$1.baseEventTypeMapSchema);
83
- // Parse serverless input internally
84
- this.serverless = serverlessConfig.serverlessConfigSchema.parse(init.serverless);
85
- // Validate that eventTypeMapSchema includes base keys at runtime
86
- httpTokens.validateEventTypeMapSchemaIncludesBase(this.eventTypeMapSchema.shape);
87
- // Env exposure nodes
88
- this.global = {
89
- paramsSchema: this.globalParamsSchema,
90
- envKeys: init.global.envKeys,
91
- };
92
- this.stage = {
93
- paramsSchema: this.stageParamsSchema,
94
- envKeys: init.stage.envKeys,
95
- };
96
- // Build stages/environment/fn-env via helper (applies “stage extends global” and parsing)
97
- const { stages, environment, buildFnEnv } = buildStageArtifacts.buildStageArtifacts(this.globalParamsSchema, this.stageParamsSchema, { params: init.global.params, envKeys: init.global.envKeys }, { params: init.stage.params, envKeys: init.stage.envKeys });
98
- this.stages = stages;
99
- this.environment = environment;
100
- this.buildFnEnv = buildFnEnv;
101
- // HTTP tokens (runtime decision)
102
- this.httpEventTypeTokens = (init.httpEventTypeTokens ??
103
- httpTokens.defaultHttpEventTypeTokens);
104
- // App-level HTTP customization
105
- this.http = init.http ?? {};
106
- // Initialize function registry
107
- this.registry = registry.createRegistry({
108
- httpEventTypeTokens: this.httpEventTypeTokens,
109
- env: { global: this.global, stage: this.stage },
110
- http: this.http,
111
- // With exactOptionalPropertyTypes, do not pass an explicit undefined.
112
- ...(init.functionDefaults
113
- ? { functionDefaults: init.functionDefaults }
114
- : {}),
115
- });
116
- } /**
117
- * Ergonomic constructor for schema‑first inference.
118
- *
119
- * @param init - initialization object (schemas, serverless defaults, params/envKeys)
120
- * @returns a new App instance
121
- */
122
- static create(init) {
123
- return new App(init);
124
- }
125
- /**
126
- * Register a function (HTTP or non‑HTTP).
127
- *
128
- * @typeParam EventType - A key from your eventTypeMapSchema (e.g., 'rest' | 'http' | 'sqs' | 'step')
129
- * @typeParam EventSchema - Optional Zod schema validated BEFORE the handler (refines event shape)
130
- * @typeParam ResponseSchema - Optional Zod schema validated AFTER the handler (refines response shape)
131
- * @param options - per‑function configuration (method/basePath/httpContexts for HTTP; serverless extras for non‑HTTP)
132
- * @returns a per‑function API: { handler(business), openapi(baseOperation), serverless(extras) }
133
- */
134
- defineFunction(options) {
135
- const functionName = options.functionName ??
136
- (() => {
137
- const callerDir = path.dirname(node_url.fileURLToPath(options.callerModuleUrl));
138
- const rel = path.relative(this.appRootAbs, callerDir).split(path.sep).join('/');
139
- const parts = rel.split('/').filter(Boolean);
140
- // Drop leading 'app' segment if present, per repo convention
141
- if (parts[0] === 'app')
142
- parts.shift();
143
- return parts.join('_'); // underscore formatting
144
- })();
145
- return this.registry.defineFunction({
146
- functionName,
147
- eventType: options.eventType,
148
- ...(options.method ? { method: options.method } : {}),
149
- ...(options.basePath ? { basePath: options.basePath } : {}),
150
- ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
151
- ...(options.contentType ? { contentType: options.contentType } : {}),
152
- ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
153
- ...(options.responseSchema
154
- ? { responseSchema: options.responseSchema }
155
- : {}),
156
- ...(options.fnEnvKeys ? { fnEnvKeys: options.fnEnvKeys } : {}),
157
- callerModuleUrl: options.callerModuleUrl,
158
- endpointsRootAbs: options.endpointsRootAbs,
159
- });
160
- }
161
- /**
162
- * Aggregate Serverless function definitions across the registry.
163
- *
164
- * @returns An AWS['functions'] object suitable for serverless.ts
165
- */
166
- buildAllServerlessFunctions() {
167
- return buildServerless.buildAllServerlessFunctions(this.registry.values(), this.serverless, this.buildFnEnv);
168
- }
169
- /**
170
- * Aggregate OpenAPI path items across the registry.
171
- *
172
- * @returns ZodOpenApiPathsObject to be embedded in a full OpenAPI document
173
- */
174
- buildAllOpenApiPaths() {
175
- return buildOpenApi.buildAllOpenApiPaths(this.registry.values());
176
- }
177
- }
15
+ var packageDirectory = require('package-directory');
178
16
 
179
17
  const baseEventTypeMapSchema = zod.z.object({
180
18
  rest: zod.z.custom(),
@@ -200,234 +38,335 @@ const baseEventTypeMapSchema = zod.z.object({
200
38
  // - Only tokens listed in your App's httpEventTypeTokens are treated as HTTP by the runtime.
201
39
 
202
40
  /**
203
- * Define a complete application configuration from schemas and authoring input.
41
+ * Stage artifacts factory.
204
42
  *
205
- * @typeParam GlobalParamsSchema - Global params schema
206
- * @typeParam StageParamsSchema - Per‑stage params schema
207
- * @param globalParamsSchema - schema for global params
208
- * @param stageParamsSchema - schema for stage params
209
- * @param input - serverless defaults and concrete params + env exposure
210
- * @returns Typed configuration nodes and stage artifacts
211
- * * @throws Error if envKeys include keys not present in their corresponding schema
43
+ * Validates per‑stage configurations, composes them with global params,
44
+ * and produces:
45
+ * - Serverless `stages` (params), * - provider‑level `environment` mapping,
46
+ * - a helper to build per‑function env mappings (excluding globally exposed keys).
212
47
  */
213
- function defineAppConfig(globalParamsSchema, stageParamsSchema, input) {
214
- const assertKeysSubset = (schema, keys, label) => {
215
- const allowed = new Set(Object.keys(schema.shape));
216
- const bad = keys.filter((k) => !allowed.has(k));
217
- if (bad.length)
218
- throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
48
+ /**
49
+ * Create all stage artifacts from provided configs. This is generic and can
50
+ * be used by both production and tests.
51
+ *
52
+ * @typeParam GlobalParams - global params record
53
+ * @typeParam StageParams - stage params record
54
+ * @param input - schemas, concrete params, env exposure, and per‑stage values
55
+ * @returns stage params object, provider environment, and per‑function env builder
56
+ *
57
+ * @throws Error if a stage fails validation or a required global key is missing
58
+ */
59
+ const stagesFactory = (input) => {
60
+ const { globalParamsSchema, stageParamsSchema, globalParams, globalEnvKeys, stageEnvKeys, stages, } = input;
61
+ // Validate each stage configuration:
62
+ // 1) Stage object conforms to stage schema
63
+ // 2) Stage merged with global satisfies required global keys
64
+ const entries = Object.entries(stages);
65
+ for (const [, stage] of entries) {
66
+ stageParamsSchema.parse(stage);
67
+ globalParamsSchema.strip().parse({ ...globalParams, ...stage });
68
+ }
69
+ // Build Serverless 'params' structure
70
+ const stagesOut = entries.reduce((acc, [name, params]) => {
71
+ acc[name] = { params };
72
+ return acc;
73
+ }, { default: { params: globalParams } });
74
+ // Build provider.environment mapping for globally exposed keys
75
+ const globallyExposed = radash.unique([].concat(globalEnvKeys, stageEnvKeys));
76
+ const environment = Object.fromEntries(globallyExposed.map((k) => [k, `\${param:${k}}`]));
77
+ // Helper for function-level environment: include only non-globally-exposed
78
+ const buildFnEnv = (fnEnvKeys = []) => {
79
+ const extras = radash.diff(fnEnvKeys, globallyExposed);
80
+ return Object.fromEntries(extras.map((k) => [k, `\${param:${k}}`]));
219
81
  };
220
- assertKeysSubset(globalParamsSchema, input.global.envKeys, 'global.envKeys');
221
- assertKeysSubset(stageParamsSchema, input.stage.envKeys, 'stage.envKeys');
222
- const sf = stagesFactory.stagesFactory({
82
+ return { stages: stagesOut, environment, buildFnEnv };
83
+ };
84
+
85
+ /**
86
+ * Compose stage artifacts (effective schema + parsed stages + env helpers).
87
+ * Extracted from App.ts to keep the class focused on orchestration.
88
+ */
89
+ function buildStageArtifacts(globalParamsSchema, stageParamsSchema, global, stage) {
90
+ const effectiveStageParamsSchema = globalParamsSchema
91
+ .partial()
92
+ .extend(stageParamsSchema.shape);
93
+ const typedStages = Object.fromEntries(Object.entries(stage.params).map(([name, params]) => {
94
+ const parsed = effectiveStageParamsSchema.parse(params);
95
+ return [name, parsed];
96
+ }));
97
+ return stagesFactory({
223
98
  globalParamsSchema,
224
- stageParamsSchema,
225
- globalParams: input.global.params,
226
- globalEnvKeys: input.global.envKeys,
227
- stageEnvKeys: input.stage.envKeys,
228
- stages: input.stage.params,
99
+ stageParamsSchema: effectiveStageParamsSchema,
100
+ globalParams: global.params,
101
+ globalEnvKeys: global.envKeys,
102
+ stageEnvKeys: stage.envKeys,
103
+ stages: typedStages,
229
104
  });
230
- return {
231
- serverless: input.serverless,
232
- global: { paramsSchema: globalParamsSchema, envKeys: input.global.envKeys },
233
- stage: { paramsSchema: stageParamsSchema, envKeys: input.stage.envKeys },
234
- stages: sf.stages,
235
- environment: sf.environment,
236
- buildFnEnv: sf.buildFnEnv,
237
- };
238
105
  }
239
106
 
240
- /** Narrow to API Gateway v1 events with safe property checks. */
107
+ /** Default HTTP event tokens used by the runtime. */
108
+ const defaultHttpEventTypeTokens = [
109
+ 'rest',
110
+ 'http',
111
+ ];
112
+ /** Base event type keys that MUST be present in any extended map schema. */
113
+ const BASE_EVENT_TYPE_KEYS = ['rest', 'http', 'sqs'];
241
114
  /**
242
- * Type guard for API Gateway v1 events. *
243
- * @param evt - unknown event
244
- * @returns true if the event looks like a v1 APIGatewayProxyEvent
115
+ * Runtime guard: ensure an extended eventTypeMapSchema includes base keys.
116
+ * Pass the .shape object of a ZodObject for checking.
117
+ *
118
+ * @param shape - Zod object `shape` for an extended event type map
119
+ * @throws Error when any base key is missing from the shape
245
120
  */
246
- const isV1 = (evt) => {
247
- if (typeof evt !== 'object' || evt === null)
248
- return false;
249
- return 'httpMethod' in evt && 'requestContext' in evt;
121
+ const validateEventTypeMapSchemaIncludesBase = (shape) => {
122
+ for (const k of BASE_EVENT_TYPE_KEYS) {
123
+ if (!(k in shape))
124
+ throw new Error(`eventTypeMapSchema is missing base key "${k}". Ensure it extends baseEventTypeMapSchema.`);
125
+ }
250
126
  };
251
- /** Narrow to API Gateway v2 events with safe property checks. */
127
+
128
+ const ENV_CONFIG = Symbol.for('szo.envConfig');
129
+ /** @see {@link runtime/wrapHandler.wrapHandler | wrapHandler} */
130
+ function getEnvFromFunctionConfig(fc) {
131
+ const env = fc[ENV_CONFIG];
132
+ if (!env) {
133
+ throw new Error('FunctionConfig is missing env attachment. Use defineFunctionConfig(env)(...) when authoring.');
134
+ }
135
+ return env;
136
+ }
137
+
252
138
  /**
253
- * Type guard for API Gateway v2 events.
139
+ * Combine several middlewares into one (order-preserving).
140
+ *
141
+ * @param parts - middlewares to compose
142
+ * @returns a middleware that calls before/after/onError in sequence
143
+ * @remarks
144
+ * If a previous middleware sets `request.response` during `before`, the
145
+ * sequence will stop early so the base handler is skipped (e.g. HEAD).
254
146
  */
255
- const isV2 = (evt) => {
256
- if (typeof evt !== 'object' || evt === null)
257
- return false;
258
- if (!('version' in evt) || !('requestContext' in evt))
259
- return false;
260
- const rc = evt.requestContext;
261
- return (!!rc && typeof rc === 'object' && 'http' in rc);
262
- };
263
- /** Case-insensitive single-value header lookup. */
264
- const getHeader = (headers, key) => {
265
- if (!headers)
266
- return undefined;
267
- const direct = headers[key];
268
- if (typeof direct === 'string')
269
- return direct;
270
- const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
271
- return found ? headers[found] : undefined;
147
+ const combine = (...parts) => {
148
+ const befores = parts.map((m) => m.before).filter(Boolean);
149
+ const afters = parts.map((m) => m.after).filter(Boolean);
150
+ const onErrors = parts.map((m) => m.onError).filter(Boolean);
151
+ return {
152
+ before: async (request) => {
153
+ for (const fn of befores) {
154
+ await fn(request);
155
+ // If a prior middleware has produced a response (e.g., HEAD short-circuit),
156
+ // exit early so the base handler will be skipped by Middy.
157
+ if (request.response !== undefined)
158
+ return;
159
+ }
160
+ },
161
+ after: async (request) => {
162
+ for (const fn of afters)
163
+ await fn(request);
164
+ },
165
+ onError: async (request) => {
166
+ for (const fn of onErrors)
167
+ await fn(request);
168
+ },
169
+ };
272
170
  };
273
- /** Case-insensitive multi-value header lookup. */
274
- const getMultiHeader = (headers, key) => {
275
- if (!headers)
276
- return undefined;
277
- const direct = headers[key];
278
- if (Array.isArray(direct) && direct.length > 0)
279
- return direct[0];
280
- const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
281
- return found ? headers[found]?.[0] : undefined;
171
+
172
+ const ID_PROP = '__id';
173
+ /** Attach a non-enumerable __id to a middleware step. */
174
+ const tagStep = (mw, id) => {
175
+ if (!Object.prototype.hasOwnProperty.call(mw, ID_PROP)) {
176
+ Object.defineProperty(mw, ID_PROP, { value: id, enumerable: false });
177
+ }
178
+ return mw;
282
179
  };
283
- /** Get a header value from either single- or multi-value maps. */
284
- const getHeaderFromEvent = (evt, key) => {
285
- const single = getHeader(evt.headers, key);
286
- if (typeof single === 'string')
287
- return single;
288
- const multi = getMultiHeader(evt
289
- .multiValueHeaders, key);
290
- return multi;
180
+ /** Retrieve a step's id, if present. */
181
+ const getId = (mw) => mw[ID_PROP];
182
+ /** Find index of a step by id. */
183
+ const findIndex = (list, id) => list.findIndex((m) => getId(m) === id);
184
+ /** Insert a step before the step with given id. */
185
+ const insertBefore = (list, id, mw) => {
186
+ const i = findIndex(list, id);
187
+ if (i < 0)
188
+ return list.slice();
189
+ return [...list.slice(0, i), mw, ...list.slice(i)];
291
190
  };
292
- const hasAwsSig = (auth) => typeof auth === 'string' && auth.startsWith('AWS4-HMAC-SHA256');
293
- const hasV1AccessKey = (evt) => {
294
- const rc = evt.requestContext;
295
- const ak = rc.identity?.accessKey;
296
- return typeof ak === 'string' && ak.length > 0;
191
+ /** Insert a step after the step with given id. */
192
+ const insertAfter = (list, id, mw) => {
193
+ const i = findIndex(list, id);
194
+ if (i < 0)
195
+ return list.slice();
196
+ return [...list.slice(0, i + 1), mw, ...list.slice(i + 1)];
297
197
  };
298
- const hasAuthorizer = (evt) => {
299
- // V1: any truthy authorizer object indicates an authenticated request
300
- if (isV1(evt)) {
301
- const rc = evt.requestContext;
302
- return !!rc.authorizer;
303
- }
304
- // V2: authorizer may include jwt/iam/lambda shapes
305
- const authHeader = getHeaderFromEvent(evt, 'authorization');
306
- if (hasAwsSig(authHeader))
307
- return true;
308
- const rc = evt
309
- .requestContext;
310
- const { authorizer } = rc;
311
- if (!authorizer || typeof authorizer !== 'object')
312
- return false;
313
- const a = authorizer;
314
- return !!(a.jwt || a.iam || a.lambda);
198
+ /** Replace a step with given id. */
199
+ const replaceStep = (list, id, mw) => {
200
+ const i = findIndex(list, id);
201
+ if (i < 0)
202
+ return list.slice();
203
+ const out = list.slice();
204
+ out[i] = mw;
205
+ return out;
315
206
  };
316
- /** Detect API key via header or v1 identity.apiKey. */
317
- const hasApiKey = (evt) => {
318
- const fromHeader = getHeaderFromEvent(evt, 'x-api-key');
319
- if (typeof fromHeader === 'string')
320
- return true;
321
- // Some v1 events surface API key on requestContext.identity.apiKey
322
- if (isV1(evt)) {
323
- const rc = evt.requestContext;
324
- const identKey = rc.identity?.apiKey;
325
- if (typeof identKey === 'string' && identKey.length > 0)
326
- return true;
327
- const identKeyId = rc.identity?.apiKeyId;
328
- if (typeof identKeyId === 'string' && identKeyId.length > 0)
329
- return true;
207
+ /** Remove a step with given id. */
208
+ const removeStep = (list, id) => {
209
+ const i = findIndex(list, id);
210
+ if (i < 0)
211
+ return list.slice();
212
+ return [...list.slice(0, i), ...list.slice(i + 1)];
213
+ };
214
+ /** Invariant validation helpers */
215
+ const assertInvariants = (phases) => {
216
+ const { before, after } = phases;
217
+ if (before.length === 0 || getId(before[0]) !== 'head') {
218
+ throw new Error("Invariant violation: 'head' must be first in before.");
219
+ }
220
+ if (after.length === 0 ||
221
+ getId(after[after.length - 1]) !== 'serializer') {
222
+ throw new Error("Invariant violation: 'serializer' must be last in after.");
223
+ }
224
+ const shapeIdx = findIndex(after, 'shape');
225
+ const serialIdx = findIndex(after, 'serializer');
226
+ if (shapeIdx < 0 || serialIdx < 0 || shapeIdx >= serialIdx) {
227
+ throw new Error("Invariant violation: 'shape' must precede 'serializer' in after.");
228
+ }
229
+ const illegal = (arr) => arr.find((m) => getId(m) === 'error-handler');
230
+ if (illegal(before) || illegal(after)) {
231
+ throw new Error("Illegal placement: 'error-handler' may only appear in onError phase.");
330
232
  }
331
- return false;
332
233
  };
333
- /** Classify the security context from either API Gateway event version. */
234
+
334
235
  /**
335
- * Detect security context from API Gateway v1/v2 events.
236
+ * Coerce a generic Middy middleware into an APIGateway‑typed middleware.
336
237
  *
337
- * @param evt - unknown event
338
- * @returns 'my' when authorized (Cognito/JWT/IAM), 'private' when API key present, else 'public'
238
+ * @param m - middleware object (any event/context signature)
239
+ * @returns middleware object typed to (APIGatewayProxyEvent, Context) */
240
+ const asApiMiddleware = (m) => m;
241
+
242
+ /**
243
+ * Create a Plain‑Old‑JSON clone of an arbitrary input.
244
+ *
245
+ * - Drops non‑JSON scalars and containers (Map/Set/etc.)
246
+ * - Avoids circular references
247
+ * - Preserves Date via toJSON
339
248
  */
340
- const detectSecurityContext = (evt) => {
341
- if (isV1(evt)) {
342
- const auth = getHeaderFromEvent(evt, 'authorization');
343
- if (hasAwsSig(auth) || hasV1AccessKey(evt) || hasAuthorizer(evt))
344
- return 'my';
345
- if (hasApiKey(evt))
346
- return 'private';
347
- return 'public';
348
- }
349
- if (isV2(evt)) {
350
- const auth = getHeaderFromEvent(evt, 'authorization');
351
- if (hasAwsSig(auth) || hasAuthorizer(evt))
352
- return 'my';
353
- if (hasApiKey(evt))
354
- return 'private';
355
- return 'public';
249
+ const pojofy = (input) => {
250
+ const seen = new WeakSet();
251
+ const replacer = (_key, value) => {
252
+ if (value === null)
253
+ return null;
254
+ const t = typeof value;
255
+ if (t === 'string' || t === 'number' || t === 'boolean')
256
+ return value;
257
+ // Eliminate non-JSON scalars
258
+ if (t === 'undefined' ||
259
+ t === 'function' ||
260
+ t === 'symbol' ||
261
+ t === 'bigint') {
262
+ return undefined;
263
+ }
264
+ // Keep Date as ISO via its built-in toJSON
265
+ if (value instanceof Date)
266
+ return value;
267
+ // Drop common non-JSON containers
268
+ if (value instanceof Map ||
269
+ value instanceof Set ||
270
+ value instanceof WeakMap ||
271
+ value instanceof WeakSet) {
272
+ return undefined;
273
+ }
274
+ // Drop raw binary; convert typed arrays (incl. Buffer) to JSON-safe arrays
275
+ if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer)
276
+ return undefined;
277
+ if (ArrayBuffer.isView(value))
278
+ return Array.from(value);
279
+ const obj = value;
280
+ if (seen.has(obj))
281
+ return undefined; // eliminate circular refs
282
+ seen.add(obj);
283
+ return obj; // let JSON.stringify walk own-enumerable props
284
+ };
285
+ return JSON.parse(JSON.stringify(input, replacer));
286
+ };
287
+
288
+ const assertWithZod = (value, schema, logger, kind) => {
289
+ if (!schema)
290
+ return;
291
+ logger.debug('validating with zod', value);
292
+ const result = schema.safeParse(value);
293
+ if (result.success) {
294
+ logger.debug('zod validation succeeded', pojofy(result));
295
+ return;
356
296
  }
357
- return 'public';
297
+ // Log a structured object for easier debugging.
298
+ logger.error('zod validation failed', pojofy(result));
299
+ // Throw a readable error that upstream layers can interpret.
300
+ // We intentionally use "Invalid event"/"Invalid response" so HTTP mode can map to 400,
301
+ // while internal mode simply throws.
302
+ const msg = kind === 'event' ? 'Invalid event' : 'Invalid response';
303
+ const err = Object.assign(new Error(msg), {
304
+ name: 'ZodError',
305
+ issues: result.error.issues,
306
+ });
307
+ throw err;
358
308
  };
309
+ /**
310
+ * Validate the *incoming* event before business logic runs, and the *outgoing*
311
+ * response after it returns (unless a shaped HTTP envelope or a raw string).
312
+ */
313
+ const httpZodValidator = ({ eventSchema, responseSchema, logger = console, }) => ({
314
+ before: async (request) => {
315
+ const event = request.event;
316
+ assertWithZod(event, eventSchema, logger, 'event');
317
+ },
318
+ after: async (request) => {
319
+ const container = request;
320
+ const res = container.response;
321
+ // Skip if the handler already returned a shaped HTTP response...
322
+ const looksShaped = typeof res === 'object' &&
323
+ res !== null &&
324
+ 'statusCode' in res &&
325
+ 'headers' in res &&
326
+ 'body' in res;
327
+ // ...or a raw string (serializer will pass it through).
328
+ if (looksShaped || typeof res === 'string')
329
+ return;
330
+ assertWithZod(res, responseSchema, logger, 'response');
331
+ },
332
+ });
359
333
 
360
334
  /**
361
- * Wrap a business handler with SMOZ runtime. *
362
- * - HTTP event tokens receive the full Middy pipeline (validation, shaping, CORS, etc.)
363
- * - Non‑HTTP tokens bypass Middy and call the business function directly.
335
+ * If the request is HEAD, short-circuit the handler and let the serializer
336
+ * produce an empty JSON body. This keeps tests and clients predictable.
364
337
  *
365
- * @typeParam GlobalParamsSchema - global params schema type
366
- * @typeParam StageParamsSchema - stage params schema type
367
- * @typeParam EventTypeMap - event token runtime type map
368
- * @typeParam EventType - a key of EventTypeMap
369
- * @typeParam EventSchema - optional Zod schema for event (validated before handler)
370
- * @typeParam ResponseSchema - optional Zod schema for response (validated after handler)
371
- * @param functionConfig - per‑function configuration (branded with env nodes)
372
- * @param business - the business handler implementation
373
- * @param opts - optional runtime overrides (e.g., widen HTTP tokens)
374
- * @returns a Lambda‑compatible handler function
338
+ * @remarks
339
+ * This is placed very early in the pipeline. When it sets `request.response`
340
+ * during `before`, the composed middleware sequence will skip the base handler.
375
341
  */
376
- function wrapHandler(functionConfig, business, opts) {
377
- const assertKeysSubset = (schema, keys, label) => {
378
- const allowed = new Set(Object.keys(schema.shape));
379
- const bad = keys.filter((k) => !allowed.has(k));
380
- if (bad.length)
381
- throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
382
- };
383
- const envConfig = defineFunctionConfig.getEnvFromFunctionConfig(functionConfig);
384
- assertKeysSubset(envConfig.global.paramsSchema, envConfig.global.envKeys, 'global.envKeys');
385
- assertKeysSubset(envConfig.stage.paramsSchema, envConfig.stage.envKeys, 'stage.envKeys');
386
- return async (event, context) => {
387
- // Compose typed env schema and parse process.env
388
- const all = envBuilder.deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, (functionConfig.fnEnvKeys ?? []));
389
- const { globalPick, stagePick } = envBuilder.splitKeysBySchema(all, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
390
- const envSchema = envBuilder.buildEnvSchema(globalPick, stagePick, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
391
- const env = envBuilder.parseTypedEnv(envSchema, process.env);
392
- const logger = console;
393
- // Non-HTTP: call business directly
394
- const httpTokens$1 = opts?.httpEventTypeTokens ??
395
- httpTokens.defaultHttpEventTypeTokens;
396
- const isHttp = httpTokens$1.includes(functionConfig.eventType);
397
- if (!isHttp) {
398
- return business(event, context, { env, logger });
342
+ const shortCircuitHead = {
343
+ before: async (request) => {
344
+ const evt = request.event;
345
+ const method = (evt.httpMethod ??
346
+ evt.requestContext?.http?.method ??
347
+ '').toUpperCase();
348
+ if (method === 'HEAD') {
349
+ request.response = {
350
+ statusCode: 200,
351
+ headers: {},
352
+ body: {},
353
+ };
399
354
  }
400
- // HTTP: build middleware stack
401
- const fnHttp = functionConfig.http;
402
- const maybeContentType = functionConfig
403
- .contentType;
404
- const args = {
405
- functionName: functionConfig.functionName,
406
- logger,
407
- ...(functionConfig.eventSchema
408
- ? { eventSchema: functionConfig.eventSchema }
409
- : {}),
410
- ...(functionConfig.responseSchema
411
- ? { responseSchema: functionConfig.responseSchema }
412
- : {}),
413
- ...(typeof maybeContentType === 'string' && maybeContentType.length > 0
414
- ? { contentType: maybeContentType }
415
- : {}),
416
- ...(opts?.httpConfig ? { app: opts.httpConfig } : {}),
417
- ...(fnHttp ? { fn: fnHttp } : {}),
418
- };
419
- const http = httpStackCustomization.computeHttpMiddleware(args);
420
- const wrapped = middy(async (e, c) => business(e, c, {
421
- env,
422
- logger,
423
- })).use(http);
424
- return wrapped(event, context);
425
- };
426
- }
355
+ },
356
+ };
357
+
358
+ const wrapSerializer = (fn, opts) => {
359
+ return ((args) => {
360
+ opts.logger.debug(`serializing ${opts.label} response`);
361
+ const serialized = fn(args);
362
+ opts.logger.debug('serialized response', { serialized });
363
+ return serialized;
364
+ });
365
+ };
427
366
 
428
- const makeHead = () => transformUtils.tagStep(shortCircuitHead.shortCircuitHead, 'head');
429
- const makeHeaderNormalizer = (opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpHeaderNormalizer(opts?.headerNormalizer ?? { canonical: true })), 'header-normalizer');
430
- const makeEventNormalizer = () => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpEventNormalizer()), 'event-normalizer');
367
+ const makeHead = () => tagStep(shortCircuitHead, 'head');
368
+ const makeHeaderNormalizer = (opts) => tagStep(asApiMiddleware(httpHeaderNormalizer(opts?.headerNormalizer ?? { canonical: true })), 'header-normalizer');
369
+ const makeEventNormalizer = () => tagStep(asApiMiddleware(httpEventNormalizer()), 'event-normalizer');
431
370
  const makeContentNegotiation = (contentType, opts) => {
432
371
  const availableMediaTypes = [contentType];
433
372
  const merged = {
@@ -437,10 +376,10 @@ const makeContentNegotiation = (contentType, opts) => {
437
376
  availableMediaTypes,
438
377
  ...(opts?.contentNegotiation ?? {}),
439
378
  };
440
- return transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpContentNegotiation(merged)), 'content-negotiation');
379
+ return tagStep(asApiMiddleware(httpContentNegotiation(merged)), 'content-negotiation');
441
380
  };
442
381
  const makeJsonBodyParser = (opts) => {
443
- const inner = asApiMiddleware.asApiMiddleware(httpJsonBodyParser({
382
+ const inner = asApiMiddleware(httpJsonBodyParser({
444
383
  disableContentTypeError: true,
445
384
  ...(opts?.jsonBodyParser ?? {}),
446
385
  }));
@@ -461,29 +400,29 @@ const makeJsonBodyParser = (opts) => {
461
400
  if (inner.before)
462
401
  await inner.before(request);
463
402
  };
464
- return transformUtils.tagStep(mw, 'json-body-parser');
403
+ return tagStep(mw, 'json-body-parser');
465
404
  };
466
405
  const makeZodBefore = (logger, eventSchema) => {
467
- const base = httpZodValidator.httpZodValidator({
406
+ const base = httpZodValidator({
468
407
  logger,
469
408
  ...(eventSchema ? { eventSchema } : {}),
470
409
  });
471
410
  const mw = {};
472
411
  if (base.before)
473
412
  mw.before = base.before;
474
- return transformUtils.tagStep(mw, 'zod-before');
413
+ return tagStep(mw, 'zod-before');
475
414
  };
476
415
  const makeZodAfter = (logger, responseSchema) => {
477
- const base = httpZodValidator.httpZodValidator({
416
+ const base = httpZodValidator({
478
417
  logger,
479
418
  ...(responseSchema ? { responseSchema } : {}),
480
419
  });
481
420
  const mw = {};
482
421
  if (base.after)
483
422
  mw.after = base.after;
484
- return transformUtils.tagStep(mw, 'zod-after');
423
+ return tagStep(mw, 'zod-after');
485
424
  };
486
- const makeHeadFinalize = (contentType) => transformUtils.tagStep({
425
+ const makeHeadFinalize = (contentType) => tagStep({
487
426
  after: (request) => {
488
427
  const evt = request
489
428
  .event;
@@ -501,7 +440,7 @@ const makeHeadFinalize = (contentType) => transformUtils.tagStep({
501
440
  };
502
441
  },
503
442
  }, 'head-finalize');
504
- const makePreferredMedia = (contentType) => transformUtils.tagStep({
443
+ const makePreferredMedia = (contentType) => tagStep({
505
444
  before: (request) => {
506
445
  const req = request;
507
446
  if (!Array.isArray(req.preferredMediaTypes))
@@ -524,7 +463,7 @@ const makePreferredMedia = (contentType) => transformUtils.tagStep({
524
463
  req.preferredMediaTypes = [contentType];
525
464
  },
526
465
  }, 'preferred-media');
527
- const makeErrorExpose = (logger) => transformUtils.tagStep({
466
+ const makeErrorExpose = (logger) => tagStep({
528
467
  onError: (request) => {
529
468
  const maybe = request.error;
530
469
  if (!(maybe instanceof Error))
@@ -539,7 +478,7 @@ const makeErrorExpose = (logger) => transformUtils.tagStep({
539
478
  }
540
479
  },
541
480
  }, 'error-expose');
542
- const makeErrorHandler = (opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpErrorHandler({
481
+ const makeErrorHandler = (opts) => tagStep(asApiMiddleware(httpErrorHandler({
543
482
  ...(opts?.errorHandler ?? {}),
544
483
  logger: (o) => {
545
484
  const lg = opts?.logger ?? console;
@@ -547,12 +486,12 @@ const makeErrorHandler = (opts) => transformUtils.tagStep(asApiMiddleware.asApiM
547
486
  lg.error(o);
548
487
  },
549
488
  })), 'error-handler');
550
- const makeCors = (opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpCors({
489
+ const makeCors = (opts) => tagStep(asApiMiddleware(httpCors({
551
490
  credentials: true,
552
491
  getOrigin: (o) => o,
553
492
  ...(opts?.cors ?? {}),
554
493
  })), 'cors');
555
- const makeShapeAndContentType = (contentType) => transformUtils.tagStep({
494
+ const makeShapeAndContentType = (contentType) => tagStep({
556
495
  after: (request) => {
557
496
  const container = request;
558
497
  const current = container.response;
@@ -582,11 +521,11 @@ const makeShapeAndContentType = (contentType) => transformUtils.tagStep({
582
521
  request.response = res;
583
522
  },
584
523
  }, 'shape');
585
- const makeSerializer = (contentType, opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpResponseSerializer({
524
+ const makeSerializer = (contentType, opts) => tagStep(asApiMiddleware(httpResponseSerializer({
586
525
  serializers: [
587
526
  {
588
527
  regex: /^application\/(?:[a-z0-9.+-]*\+)?json$/i,
589
- serializer: wrapSerializer.wrapSerializer(({ body }) => typeof body === 'string'
528
+ serializer: wrapSerializer(({ body }) => typeof body === 'string'
590
529
  ? body
591
530
  : (opts?.serializer?.json?.stringify ?? JSON.stringify)(body), {
592
531
  label: opts?.serializer?.json?.label ?? 'json',
@@ -620,47 +559,790 @@ const buildDefaultPhases = (args) => {
620
559
  };
621
560
  const buildSafeDefaults = (args) => buildDefaultPhases(args);
622
561
 
623
- const ID_PROP = '__id';
624
- /** Attach a non-enumerable __id to a middleware step. */
625
- const tagStep = (mw, id) => {
626
- if (!Object.prototype.hasOwnProperty.call(mw, ID_PROP)) {
627
- Object.defineProperty(mw, ID_PROP, { value: id, enumerable: false });
628
- }
629
- return mw;
562
+ /** Shallow merge HttpStackOptions left→right. */
563
+ const mergeOptions = (a, b) => ({ ...(a ?? {}), ...(b ?? {}) });
564
+ /** Apply extend lists (append in order) */
565
+ const applyExtend = (phases, ext) => {
566
+ if (!ext)
567
+ return;
568
+ if (ext.before?.length)
569
+ phases.before = [...phases.before, ...ext.before];
570
+ if (ext.after?.length)
571
+ phases.after = [...phases.after, ...ext.after];
572
+ if (ext.onError?.length)
573
+ phases.onError = [...phases.onError, ...ext.onError];
630
574
  };
631
- /** Retrieve a step's id, if present. */
632
- const getId = (mw) => mw[ID_PROP];
633
- /** Find index of a step by id. */
634
- const findIndex = (list, id) => list.findIndex((m) => getId(m) === id);
635
- /** Insert a step before the step with given id. */
636
- const insertBefore = (list, id, mw) => {
637
- const i = findIndex(list, id);
638
- if (i < 0)
639
- return list.slice();
640
- return [...list.slice(0, i), mw, ...list.slice(i)];
575
+ /** Apply transform callback and validate invariants. */
576
+ const applyTransform = (phases, transform) => {
577
+ if (!transform)
578
+ return phases;
579
+ const next = transform({
580
+ before: phases.before.slice(),
581
+ after: phases.after.slice(),
582
+ onError: phases.onError.slice(),
583
+ });
584
+ const out = {
585
+ before: next.before ?? phases.before,
586
+ after: next.after ?? phases.after,
587
+ onError: next.onError ?? phases.onError,
588
+ };
589
+ assertInvariants(out);
590
+ return out;
641
591
  };
642
- /** Insert a step after the step with given id. */
643
- const insertAfter = (list, id, mw) => {
644
- const i = findIndex(list, id);
645
- if (i < 0)
646
- return list.slice();
647
- return [...list.slice(0, i + 1), mw, ...list.slice(i + 1)];
592
+ /** Zod enforcement per spec. */
593
+ const enforceZod = (phases, hasSchemas, fnName) => {
594
+ if (!hasSchemas)
595
+ return;
596
+ const hasBefore = phases.before.some((m) => getId(m) === 'zod-before');
597
+ const hasAfter = phases.after.some((m) => getId(m) === 'zod-after');
598
+ if (!hasBefore || !hasAfter) {
599
+ throw new Error(`Zod validation is required (schemas provided) but is missing after stack customization on function '${fnName}'. ` +
600
+ `Include the standard httpZodValidator or tag your custom validator steps as 'zod-before' and 'zod-after'.`);
601
+ }
648
602
  };
649
- /** Replace a step with given id. */
650
- const replaceStep = (list, id, mw) => {
651
- const i = findIndex(list, id);
652
- if (i < 0)
653
- return list.slice();
654
- const out = list.slice();
655
- out[i] = mw;
603
+ /** Compute final stack given app-level and function-level customization. */
604
+ const computeHttpMiddleware = (args) => {
605
+ const { functionName, eventSchema, responseSchema, logger: maybeLogger, contentType: maybeContentType, app, fn, } = args;
606
+ const baseContentType = (maybeContentType ?? 'application/json').toLowerCase();
607
+ let effective = {
608
+ contentType: baseContentType,
609
+ logger: maybeLogger ?? console,
610
+ };
611
+ // Layer A: app.defaults.options
612
+ effective = mergeOptions(effective, app?.defaults);
613
+ // Apply profile (options)
614
+ const profile = fn?.profile ? app?.profiles?.[fn.profile] : undefined;
615
+ effective = mergeOptions(effective, profile);
616
+ // Function-level options
617
+ effective = mergeOptions(effective, fn?.options);
618
+ const contentType = (effective.contentType ?? baseContentType).toLowerCase();
619
+ const logger = effective.logger ?? console;
620
+ // Build default phases with resolved options
621
+ let phases = buildDefaultPhases({
622
+ contentType,
623
+ logger,
624
+ opts: effective,
625
+ eventSchema,
626
+ responseSchema,
627
+ });
628
+ // Layer B: extend (app.defaults → profile → function)
629
+ applyExtend(phases, app?.defaults?.extend);
630
+ applyExtend(phases, profile?.extend);
631
+ applyExtend(phases, fn?.extend);
632
+ // Layer C: transform (app.defaults → profile → function)
633
+ phases = applyTransform(phases, app?.defaults?.transform);
634
+ phases = applyTransform(phases, profile?.transform);
635
+ phases = applyTransform(phases, fn?.transform);
636
+ // Invariants (pre-replace)
637
+ assertInvariants(phases);
638
+ // Zod enforcement (pre-replace)
639
+ enforceZod(phases, !!(eventSchema || responseSchema), functionName);
640
+ // Layer D: replace (full override)
641
+ if (fn?.replace?.stack) {
642
+ const rep = fn.replace.stack;
643
+ if (typeof rep === 'object' &&
644
+ 'before' in rep) {
645
+ const p = rep;
646
+ const final = {
647
+ before: p.before ?? [],
648
+ after: p.after ?? [],
649
+ onError: p.onError ?? [],
650
+ };
651
+ assertInvariants(final);
652
+ enforceZod(final, !!(eventSchema || responseSchema), functionName);
653
+ return combine(...final.before, ...final.after, ...final.onError);
654
+ }
655
+ // Single middleware replacement: cannot validate presence of zod steps.
656
+ if (eventSchema || responseSchema) {
657
+ throw new Error(`Full replace provided as a single middleware object on function '${functionName}', but schemas are present. ` +
658
+ `To satisfy Zod enforcement, provide phased arrays including steps tagged as 'zod-before' and 'zod-after'.`);
659
+ }
660
+ return rep;
661
+ }
662
+ // Compose final combined stack
663
+ return combine(...phases.before, ...phases.after, ...phases.onError);
664
+ };
665
+
666
+ /** Union all env keys we plan to expose into a single set. */
667
+ /**
668
+ * Derive the union of env keys we plan to expose.
669
+ *
670
+ * @param globalEnv - keys exposed from global params schema
671
+ * @param stageEnv - keys exposed from stage params schema
672
+ * @param fnEnv - optional per‑function extra keys
673
+ */
674
+ const deriveAllKeys = (globalEnv, stageEnv, fnEnv = []) => {
675
+ const out = new Set();
676
+ globalEnv.forEach((k) => out.add(k));
677
+ stageEnv.forEach((k) => out.add(k));
678
+ fnEnv.forEach((k) => out.add(k));
656
679
  return out;
657
680
  };
658
- /** Remove a step with given id. */
659
- const removeStep = (list, id) => {
660
- const i = findIndex(list, id);
661
- if (i < 0)
662
- return list.slice();
663
- return [...list.slice(0, i), ...list.slice(i + 1)];
681
+ /** Split the unioned keys by which Zod schema defines them. */
682
+ /**
683
+ * Partition a union of env keys by which schema defines each key.
684
+ *
685
+ * @typeParam G - global params Zod object schema
686
+ * @typeParam S - stage params Zod object schema
687
+ * @param allKeys - union set from {@link deriveAllKeys}
688
+ * @param globalParamsSchema - global schema
689
+ * @param stageParamsSchema - stage schema
690
+ * @returns lists of keys to pick from each schema
691
+ */
692
+ const splitKeysBySchema = (allKeys, globalParamsSchema, stageParamsSchema) => {
693
+ const gKeySet = new Set(Object.keys(globalParamsSchema.shape));
694
+ const sKeySet = new Set(Object.keys(stageParamsSchema.shape));
695
+ const globalPick = [...allKeys].filter((k) => gKeySet.has(String(k)));
696
+ const stagePick = [...allKeys].filter((k) => {
697
+ const key = String(k);
698
+ return sKeySet.has(key) && !gKeySet.has(key);
699
+ });
700
+ return { globalPick, stagePick };
701
+ };
702
+ /** Build a Zod schema that picks only the requested keys from both schemas. */
703
+ /**
704
+ * Build a composed Zod schema to parse process.env, selecting keys from global+stage schemas.
705
+ *
706
+ * @typeParam G - global params Zod schema
707
+ * @typeParam S - stage params Zod schema
708
+ * @param globalPick - keys to select from global schema
709
+ * @param stagePick - keys to select from stage schema
710
+ * @param globalParamsSchema - global params schema
711
+ * @param stageParamsSchema - stage params schema
712
+ * @returns Zod object schema accepting the union of selected keys
713
+ */
714
+ const buildEnvSchema = (globalPick, stagePick, globalParamsSchema, stageParamsSchema) => {
715
+ const toPick = (keys) => Object.fromEntries(keys.map((k) => [k, true]));
716
+ const gPicked = globalParamsSchema.pick(toPick(globalPick));
717
+ const sPicked = stageParamsSchema.pick(toPick(stagePick));
718
+ return zod.z.object({}).extend(gPicked.shape).extend(sPicked.shape);
719
+ };
720
+ /** Parse an arbitrary source (e.g., process.env) with a provided env schema. */
721
+ /**
722
+ * Parse an arbitrary env source (e.g., process.env) with a provided schema.
723
+ *
724
+ * @param envSchema - Zod schema from {@link buildEnvSchema}
725
+ * @param envSource - map of environment variables
726
+ */
727
+ const parseTypedEnv = (envSchema, envSource) => envSchema.parse(envSource);
728
+
729
+ /**
730
+ * Wrap a business handler with SMOZ runtime. *
731
+ * - HTTP event tokens receive the full Middy pipeline (validation, shaping, CORS, etc.)
732
+ * - Non‑HTTP tokens bypass Middy and call the business function directly.
733
+ *
734
+ * @typeParam GlobalParamsSchema - global params schema type
735
+ * @typeParam StageParamsSchema - stage params schema type
736
+ * @typeParam EventTypeMap - event token → runtime type map
737
+ * @typeParam EventType - a key of EventTypeMap
738
+ * @typeParam EventSchema - optional Zod schema for event (validated before handler)
739
+ * @typeParam ResponseSchema - optional Zod schema for response (validated after handler)
740
+ * @param functionConfig - per‑function configuration (branded with env nodes)
741
+ * @param business - the business handler implementation
742
+ * @param opts - optional runtime overrides (e.g., widen HTTP tokens)
743
+ * @returns a Lambda‑compatible handler function
744
+ */
745
+ function wrapHandler(functionConfig, business, opts) {
746
+ const assertKeysSubset = (schema, keys, label) => {
747
+ const allowed = new Set(Object.keys(schema.shape));
748
+ const bad = keys.filter((k) => !allowed.has(k));
749
+ if (bad.length)
750
+ throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
751
+ };
752
+ const envConfig = getEnvFromFunctionConfig(functionConfig);
753
+ assertKeysSubset(envConfig.global.paramsSchema, envConfig.global.envKeys, 'global.envKeys');
754
+ assertKeysSubset(envConfig.stage.paramsSchema, envConfig.stage.envKeys, 'stage.envKeys');
755
+ return async (event, context) => {
756
+ // Compose typed env schema and parse process.env
757
+ const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, (functionConfig.fnEnvKeys ?? []));
758
+ const { globalPick, stagePick } = splitKeysBySchema(all, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
759
+ const envSchema = buildEnvSchema(globalPick, stagePick, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
760
+ const env = parseTypedEnv(envSchema, process.env);
761
+ const logger = console;
762
+ // Non-HTTP: call business directly
763
+ const httpTokens = opts?.httpEventTypeTokens ??
764
+ defaultHttpEventTypeTokens;
765
+ const isHttp = httpTokens.includes(functionConfig.eventType);
766
+ if (!isHttp) {
767
+ return business(event, context, { env, logger });
768
+ }
769
+ // HTTP: build middleware stack
770
+ const fnHttp = functionConfig.http;
771
+ const maybeContentType = functionConfig
772
+ .contentType;
773
+ const args = {
774
+ functionName: functionConfig.functionName,
775
+ logger,
776
+ ...(functionConfig.eventSchema
777
+ ? { eventSchema: functionConfig.eventSchema }
778
+ : {}),
779
+ ...(functionConfig.responseSchema
780
+ ? { responseSchema: functionConfig.responseSchema }
781
+ : {}),
782
+ ...(typeof maybeContentType === 'string' && maybeContentType.length > 0
783
+ ? { contentType: maybeContentType }
784
+ : {}),
785
+ ...(opts?.httpConfig ? { app: opts.httpConfig } : {}),
786
+ ...(fnHttp ? { fn: fnHttp } : {}),
787
+ };
788
+ const http = computeHttpMiddleware(args);
789
+ const wrapped = middy(async (e, c) => business(e, c, {
790
+ env,
791
+ logger,
792
+ })).use(http);
793
+ return wrapped(event, context);
794
+ };
795
+ }
796
+
797
+ const handlerFactory = (httpEventTypeTokens, httpConfig) => {
798
+ return (functionConfig, business) => wrapHandler(functionConfig, business, {
799
+ httpEventTypeTokens,
800
+ httpConfig,
801
+ });
802
+ };
803
+
804
+ const createRegistry = (deps) => {
805
+ const map = new Map();
806
+ return {
807
+ defineFunction(options) {
808
+ const key = options.functionName;
809
+ if (map.has(key)) {
810
+ const other = map.get(key);
811
+ throw new Error(`Duplicate functionName "${key}". Existing: ${other.callerModuleUrl}. New: ${options.callerModuleUrl}. Provide a unique functionName.`);
812
+ }
813
+ // Merge default fnEnvKeys with per-function keys (unique, preserve order).
814
+ const mergedFnEnvKeys = (() => {
815
+ const out = new Set();
816
+ (deps.functionDefaults?.fnEnvKeys ?? []).forEach((k) => out.add(k));
817
+ (options.fnEnvKeys ?? []).forEach((k) => out.add(k));
818
+ return Array.from(out);
819
+ })();
820
+ const brandedConfig = {
821
+ functionName: options.functionName,
822
+ eventType: options.eventType,
823
+ ...(options.method ? { method: options.method } : {}),
824
+ ...(options.basePath ? { basePath: options.basePath } : {}),
825
+ ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
826
+ ...(options.contentType ? { contentType: options.contentType } : {}),
827
+ ...(mergedFnEnvKeys.length
828
+ ? { fnEnvKeys: mergedFnEnvKeys }
829
+ : {}),
830
+ ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
831
+ ...(options.responseSchema
832
+ ? { responseSchema: options.responseSchema }
833
+ : {}),
834
+ [ENV_CONFIG]: {
835
+ global: deps.env.global,
836
+ stage: deps.env.stage,
837
+ },
838
+ };
839
+ map.set(key, {
840
+ functionName: options.functionName,
841
+ eventType: options.eventType,
842
+ ...(options.method ? { method: options.method } : {}),
843
+ ...(options.basePath ? { basePath: options.basePath } : {}),
844
+ ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
845
+ ...(options.contentType ? { contentType: options.contentType } : {}),
846
+ ...(mergedFnEnvKeys.length
847
+ ? { fnEnvKeys: mergedFnEnvKeys }
848
+ : {}),
849
+ ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
850
+ ...(options.responseSchema
851
+ ? { responseSchema: options.responseSchema }
852
+ : {}),
853
+ callerModuleUrl: options.callerModuleUrl,
854
+ endpointsRootAbs: options.endpointsRootAbs,
855
+ brandedConfig,
856
+ });
857
+ return {
858
+ handler: (business) => {
859
+ const fnConfig = brandedConfig;
860
+ const make = handlerFactory(deps.httpEventTypeTokens, deps.http);
861
+ return make(fnConfig, business);
862
+ },
863
+ openapi: (baseOperation) => {
864
+ const r = map.get(key);
865
+ r.openapiBaseOperation = baseOperation;
866
+ },
867
+ serverless: (extras) => {
868
+ const r = map.get(key);
869
+ r.serverlessExtras = extras;
870
+ },
871
+ };
872
+ },
873
+ values() {
874
+ return map.values();
875
+ },
876
+ };
877
+ };
878
+
879
+ /**
880
+ * Zod schema for implementation-wide Serverless config.
881
+ *
882
+ * Extracted to keep App.ts slim and focused on orchestration.
883
+ */
884
+ const serverlessConfigSchema = zod.z.object({
885
+ /** Context -> event fragment to merge into generated http events */
886
+ httpContextEventMap: zod.z.custom(),
887
+ /** Used to construct default handler string if missing on a function */
888
+ defaultHandlerFileName: zod.z.string().min(1),
889
+ defaultHandlerFileExport: zod.z.string().min(1),
890
+ });
891
+
892
+ const splitPath = (basePath) => basePath.split('/').filter(Boolean);
893
+ /** Prefix non-public contexts and return path elements. */
894
+ /**
895
+ * Build OpenAPI/serverless path elements from a base path and context.
896
+ *
897
+ * @param basePath - base path (e.g., 'users/:id')
898
+ * @param context - 'my' | 'private' | 'public' (public is unprefixed)
899
+ * @returns array of path segments without leading/trailing slashes
900
+ */
901
+ const buildPathElements = (basePath, context) => {
902
+ const parts = splitPath(sanitizeBasePath(basePath));
903
+ return context === 'public' ? parts : [context, ...parts];
904
+ };
905
+ const sanitizeBasePath = (p) => p.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
906
+
907
+ const HTTP_METHODS = new Set([
908
+ 'get',
909
+ 'put',
910
+ 'post',
911
+ 'delete',
912
+ 'options',
913
+ 'head',
914
+ 'patch',
915
+ 'trace',
916
+ ]);
917
+ /**
918
+ * Resolve `(method, basePath, contexts)` for an HTTP function definition.
919
+ *
920
+ * @typeParam EventSchema - optional event Zod schema
921
+ * @typeParam ResponseSchema - optional response Zod schema
922
+ * @typeParam GlobalParams - app global params type
923
+ * @typeParam StageParams - app stage params type
924
+ * @typeParam EventTypeMap - event type token map
925
+ * @typeParam EventType - selected event token
926
+ * @param functionConfig - the per‑function config (may omit method/basePath)
927
+ * @param callerModuleUrl - import.meta.url of the registering module
928
+ * @param endpointsRootAbs - absolute root of endpoints
929
+ * @throws Error if method/basePath cannot be inferred under endpoints root
930
+ * @returns HTTP method key, basePath, and unique contexts list
931
+ */
932
+ const resolveHttpFromFunctionConfig = (functionConfig, callerModuleUrl, endpointsRootAbs) => {
933
+ const { method: maybeMethod, basePath: maybeBase, httpContexts, } = functionConfig;
934
+ // Compute relative path from endpoints root to the caller directory
935
+ const relPath = path.relative(endpointsRootAbs, path.dirname(node_url.fileURLToPath(callerModuleUrl)))
936
+ .split(path.sep)
937
+ .join('/');
938
+ const underEndpointsRoot = !relPath.startsWith('..');
939
+ let method;
940
+ if (maybeMethod && HTTP_METHODS.has(maybeMethod)) {
941
+ method = maybeMethod;
942
+ }
943
+ else {
944
+ if (!underEndpointsRoot) {
945
+ throw new Error('resolveHttpFromFunctionConfig: method missing and caller is not under endpoints root; provide method explicitly.');
946
+ }
947
+ const segs = relPath.split('/').filter(Boolean);
948
+ const tail = segs[segs.length - 1]?.toLowerCase();
949
+ if (HTTP_METHODS.has(tail))
950
+ method = tail;
951
+ else
952
+ throw new Error('resolveHttpFromFunctionConfig: cannot infer method from folder; provide method explicitly.');
953
+ }
954
+ let basePath = sanitizeBasePath(maybeBase ?? '');
955
+ if (!basePath) {
956
+ if (!underEndpointsRoot) {
957
+ throw new Error('resolveHttpFromFunctionConfig: basePath missing and caller is not under endpoints root; provide basePath explicitly.');
958
+ }
959
+ const segs = relPath.split('/').filter(Boolean);
960
+ if (segs.length && segs[segs.length - 1]?.toLowerCase() === method)
961
+ segs.pop();
962
+ basePath = segs.join('/');
963
+ }
964
+ if (!basePath) {
965
+ throw new Error('resolveHttpFromFunctionConfig: derived basePath is empty; ensure file is under endpoints root or set config.basePath.');
966
+ }
967
+ const contexts = radash.unique(httpContexts ?? []);
968
+ return { method, basePath, contexts };
969
+ };
970
+
971
+ const buildAllOpenApiPaths = (registry) => {
972
+ const paths = {};
973
+ for (const r of registry) {
974
+ if (!r.openapiBaseOperation)
975
+ continue;
976
+ const { method, basePath, contexts } = resolveHttpFromFunctionConfig({
977
+ functionName: r.functionName,
978
+ eventType: r.eventType,
979
+ ...(r.method ? { method: r.method } : {}),
980
+ ...(r.basePath ? { basePath: r.basePath } : {}),
981
+ ...(r.httpContexts ? { httpContexts: r.httpContexts } : {}),
982
+ }, r.callerModuleUrl, r.endpointsRootAbs);
983
+ const ctxs = contexts.length > 0 ? contexts : ['public'];
984
+ for (const context of ctxs) {
985
+ const elems = buildPathElements(basePath, context);
986
+ const pathKey = `/${elems.join('/')}`;
987
+ const op = {
988
+ ...r.openapiBaseOperation,
989
+ operationId: [...elems, method].join('_'),
990
+ summary: `${r.openapiBaseOperation.summary} (${context})`,
991
+ tags: Array.from(new Set([...(r.openapiBaseOperation.tags ?? []), context])),
992
+ };
993
+ const existing = pathKey in paths ? paths[pathKey] : {};
994
+ paths[pathKey] = { ...existing, [method]: op };
995
+ }
996
+ }
997
+ return paths;
998
+ };
999
+
1000
+ /**
1001
+ * Registry → Serverless functions aggregator.
1002
+ *
1003
+ * @returns NonNullable<AWS['functions']> with HTTP/non‑HTTP events attached.
1004
+ */
1005
+ const buildAllServerlessFunctions = (registry, serverless, buildFnEnv) => {
1006
+ const out = {};
1007
+ const repoRoot = packageDirectory.packageDirectorySync();
1008
+ for (const r of registry) {
1009
+ const callerDir = path.dirname(node_url.fileURLToPath(r.callerModuleUrl));
1010
+ const handlerFileAbs = path.join(callerDir, serverless.defaultHandlerFileName);
1011
+ const handlerFileRel = path.relative(repoRoot, handlerFileAbs)
1012
+ .split(path.sep)
1013
+ .join('/');
1014
+ const handler = `${handlerFileRel}.${serverless.defaultHandlerFileExport}`;
1015
+ let events = [];
1016
+ try {
1017
+ const { method, basePath, contexts } = resolveHttpFromFunctionConfig({
1018
+ functionName: r.functionName,
1019
+ eventType: r.eventType,
1020
+ ...(r.method ? { method: r.method } : {}),
1021
+ ...(r.basePath ? { basePath: r.basePath } : {}),
1022
+ ...(r.httpContexts ? { httpContexts: r.httpContexts } : {}),
1023
+ }, r.callerModuleUrl, r.endpointsRootAbs);
1024
+ const path = `/${basePath.replace(/^\/+/, '')}`;
1025
+ const ctxs = contexts.length > 0 ? contexts : ['public'];
1026
+ events = ctxs.map(() => ({
1027
+ http: { method, path },
1028
+ }));
1029
+ }
1030
+ catch {
1031
+ events = r.serverlessExtras ?? [];
1032
+ }
1033
+ const def = {
1034
+ handler,
1035
+ events,
1036
+ environment: buildFnEnv((r.fnEnvKeys ?? [])),
1037
+ };
1038
+ out[r.functionName] = def;
1039
+ }
1040
+ return out;
1041
+ };
1042
+
1043
+ /**
1044
+ * App (schema‑first)
1045
+ *
1046
+ * Central orchestrator for a SMOZ application. You provide:
1047
+ * - Global/stage parameter schemas and env exposure keys
1048
+ * - Serverless defaults (handler filename/export and context map)
1049
+ * - Event‑type map schema (extendable: e.g., add 'step')
1050
+ *
1051
+ * The instance:
1052
+ * - Validates configuration
1053
+ * - Exposes env and stage artifacts for Serverless (provider.environment and params)
1054
+ * - Provides a registry to define functions (HTTP and non‑HTTP)
1055
+ * - Aggregates artifacts:
1056
+ * - buildAllServerlessFunctions(): AWS['functions']
1057
+ * - buildAllOpenApiPaths(): ZodOpenApiPathsObject
1058
+ *
1059
+ * @remarks See README for a full quick‑start, and typedoc for detailed API docs.
1060
+ */
1061
+ /**
1062
+ * Application class. *
1063
+ * @typeParam GlobalParamsSchema - Zod object schema for global parameters
1064
+ * @typeParam StageParamsSchema - Zod object schema for per‑stage parameters
1065
+ * @typeParam EventTypeMapSchema - Zod object schema mapping event tokens to runtime types
1066
+ */ class App {
1067
+ /** Helper alias for stage artifacts type */
1068
+ static _stageArtifactsType = null;
1069
+ appRootAbs;
1070
+ // Schemas
1071
+ globalParamsSchema;
1072
+ stageParamsSchema;
1073
+ eventTypeMapSchema; // Serverless config
1074
+ serverless;
1075
+ // Env exposure
1076
+ global;
1077
+ stage;
1078
+ // Derived stage artifacts
1079
+ stages;
1080
+ environment;
1081
+ buildFnEnv;
1082
+ http;
1083
+ // HTTP tokens for runtime decision
1084
+ httpEventTypeTokens;
1085
+ // Registry (delegated to src/app/registry)
1086
+ registry;
1087
+ constructor(init) {
1088
+ this.appRootAbs = init.appRootAbs.replace(/\\/g, '/');
1089
+ this.globalParamsSchema = init.globalParamsSchema;
1090
+ this.stageParamsSchema = init.stageParamsSchema;
1091
+ // Default to base schema when omitted (apply default INSIDE the function)
1092
+ this.eventTypeMapSchema = (init.eventTypeMapSchema ??
1093
+ baseEventTypeMapSchema);
1094
+ // Parse serverless input internally
1095
+ this.serverless = serverlessConfigSchema.parse(init.serverless);
1096
+ // Validate that eventTypeMapSchema includes base keys at runtime
1097
+ validateEventTypeMapSchemaIncludesBase(this.eventTypeMapSchema.shape);
1098
+ // Env exposure nodes
1099
+ this.global = {
1100
+ paramsSchema: this.globalParamsSchema,
1101
+ envKeys: init.global.envKeys,
1102
+ };
1103
+ this.stage = {
1104
+ paramsSchema: this.stageParamsSchema,
1105
+ envKeys: init.stage.envKeys,
1106
+ };
1107
+ // Build stages/environment/fn-env via helper (applies “stage extends global” and parsing)
1108
+ const { stages, environment, buildFnEnv } = buildStageArtifacts(this.globalParamsSchema, this.stageParamsSchema, { params: init.global.params, envKeys: init.global.envKeys }, { params: init.stage.params, envKeys: init.stage.envKeys });
1109
+ this.stages = stages;
1110
+ this.environment = environment;
1111
+ this.buildFnEnv = buildFnEnv;
1112
+ // HTTP tokens (runtime decision)
1113
+ this.httpEventTypeTokens = (init.httpEventTypeTokens ??
1114
+ defaultHttpEventTypeTokens);
1115
+ // App-level HTTP customization
1116
+ this.http = init.http ?? {};
1117
+ // Initialize function registry
1118
+ this.registry = createRegistry({
1119
+ httpEventTypeTokens: this.httpEventTypeTokens,
1120
+ env: { global: this.global, stage: this.stage },
1121
+ http: this.http,
1122
+ // With exactOptionalPropertyTypes, do not pass an explicit undefined.
1123
+ ...(init.functionDefaults
1124
+ ? { functionDefaults: init.functionDefaults }
1125
+ : {}),
1126
+ });
1127
+ } /**
1128
+ * Ergonomic constructor for schema‑first inference.
1129
+ *
1130
+ * @param init - initialization object (schemas, serverless defaults, params/envKeys)
1131
+ * @returns a new App instance
1132
+ */
1133
+ static create(init) {
1134
+ return new App(init);
1135
+ }
1136
+ /**
1137
+ * Register a function (HTTP or non‑HTTP).
1138
+ *
1139
+ * @typeParam EventType - A key from your eventTypeMapSchema (e.g., 'rest' | 'http' | 'sqs' | 'step')
1140
+ * @typeParam EventSchema - Optional Zod schema validated BEFORE the handler (refines event shape)
1141
+ * @typeParam ResponseSchema - Optional Zod schema validated AFTER the handler (refines response shape)
1142
+ * @param options - per‑function configuration (method/basePath/httpContexts for HTTP; serverless extras for non‑HTTP)
1143
+ * @returns a per‑function API: { handler(business), openapi(baseOperation), serverless(extras) }
1144
+ */
1145
+ defineFunction(options) {
1146
+ const functionName = options.functionName ??
1147
+ (() => {
1148
+ const callerDir = path.dirname(node_url.fileURLToPath(options.callerModuleUrl));
1149
+ const rel = path.relative(this.appRootAbs, callerDir).split(path.sep).join('/');
1150
+ const parts = rel.split('/').filter(Boolean);
1151
+ // Drop leading 'app' segment if present, per repo convention
1152
+ if (parts[0] === 'app')
1153
+ parts.shift();
1154
+ return parts.join('_'); // underscore formatting
1155
+ })();
1156
+ return this.registry.defineFunction({
1157
+ functionName,
1158
+ eventType: options.eventType,
1159
+ ...(options.method ? { method: options.method } : {}),
1160
+ ...(options.basePath ? { basePath: options.basePath } : {}),
1161
+ ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
1162
+ ...(options.contentType ? { contentType: options.contentType } : {}),
1163
+ ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
1164
+ ...(options.responseSchema
1165
+ ? { responseSchema: options.responseSchema }
1166
+ : {}),
1167
+ ...(options.fnEnvKeys ? { fnEnvKeys: options.fnEnvKeys } : {}),
1168
+ callerModuleUrl: options.callerModuleUrl,
1169
+ endpointsRootAbs: options.endpointsRootAbs,
1170
+ });
1171
+ }
1172
+ /**
1173
+ * Aggregate Serverless function definitions across the registry.
1174
+ *
1175
+ * @returns An AWS['functions'] object suitable for serverless.ts
1176
+ */
1177
+ buildAllServerlessFunctions() {
1178
+ return buildAllServerlessFunctions(this.registry.values(), this.serverless, this.buildFnEnv);
1179
+ }
1180
+ /**
1181
+ * Aggregate OpenAPI path items across the registry.
1182
+ *
1183
+ * @returns ZodOpenApiPathsObject to be embedded in a full OpenAPI document
1184
+ */
1185
+ buildAllOpenApiPaths() {
1186
+ return buildAllOpenApiPaths(this.registry.values());
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * Define a complete application configuration from schemas and authoring input.
1192
+ *
1193
+ * @typeParam GlobalParamsSchema - Global params schema
1194
+ * @typeParam StageParamsSchema - Per‑stage params schema
1195
+ * @param globalParamsSchema - schema for global params
1196
+ * @param stageParamsSchema - schema for stage params
1197
+ * @param input - serverless defaults and concrete params + env exposure
1198
+ * @returns Typed configuration nodes and stage artifacts
1199
+ * * @throws Error if envKeys include keys not present in their corresponding schema
1200
+ */
1201
+ function defineAppConfig(globalParamsSchema, stageParamsSchema, input) {
1202
+ const assertKeysSubset = (schema, keys, label) => {
1203
+ const allowed = new Set(Object.keys(schema.shape));
1204
+ const bad = keys.filter((k) => !allowed.has(k));
1205
+ if (bad.length)
1206
+ throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
1207
+ };
1208
+ assertKeysSubset(globalParamsSchema, input.global.envKeys, 'global.envKeys');
1209
+ assertKeysSubset(stageParamsSchema, input.stage.envKeys, 'stage.envKeys');
1210
+ const sf = stagesFactory({
1211
+ globalParamsSchema,
1212
+ stageParamsSchema,
1213
+ globalParams: input.global.params,
1214
+ globalEnvKeys: input.global.envKeys,
1215
+ stageEnvKeys: input.stage.envKeys,
1216
+ stages: input.stage.params,
1217
+ });
1218
+ return {
1219
+ serverless: input.serverless,
1220
+ global: { paramsSchema: globalParamsSchema, envKeys: input.global.envKeys },
1221
+ stage: { paramsSchema: stageParamsSchema, envKeys: input.stage.envKeys },
1222
+ stages: sf.stages,
1223
+ environment: sf.environment,
1224
+ buildFnEnv: sf.buildFnEnv,
1225
+ };
1226
+ }
1227
+
1228
+ /** Narrow to API Gateway v1 events with safe property checks. */
1229
+ /**
1230
+ * Type guard for API Gateway v1 events. *
1231
+ * @param evt - unknown event
1232
+ * @returns true if the event looks like a v1 APIGatewayProxyEvent
1233
+ */
1234
+ const isV1 = (evt) => {
1235
+ if (typeof evt !== 'object' || evt === null)
1236
+ return false;
1237
+ return 'httpMethod' in evt && 'requestContext' in evt;
1238
+ };
1239
+ /** Narrow to API Gateway v2 events with safe property checks. */
1240
+ /**
1241
+ * Type guard for API Gateway v2 events.
1242
+ */
1243
+ const isV2 = (evt) => {
1244
+ if (typeof evt !== 'object' || evt === null)
1245
+ return false;
1246
+ if (!('version' in evt) || !('requestContext' in evt))
1247
+ return false;
1248
+ const rc = evt.requestContext;
1249
+ return (!!rc && typeof rc === 'object' && 'http' in rc);
1250
+ };
1251
+ /** Case-insensitive single-value header lookup. */
1252
+ const getHeader = (headers, key) => {
1253
+ if (!headers)
1254
+ return undefined;
1255
+ const direct = headers[key];
1256
+ if (typeof direct === 'string')
1257
+ return direct;
1258
+ const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
1259
+ return found ? headers[found] : undefined;
1260
+ };
1261
+ /** Case-insensitive multi-value header lookup. */
1262
+ const getMultiHeader = (headers, key) => {
1263
+ if (!headers)
1264
+ return undefined;
1265
+ const direct = headers[key];
1266
+ if (Array.isArray(direct) && direct.length > 0)
1267
+ return direct[0];
1268
+ const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
1269
+ return found ? headers[found]?.[0] : undefined;
1270
+ };
1271
+ /** Get a header value from either single- or multi-value maps. */
1272
+ const getHeaderFromEvent = (evt, key) => {
1273
+ const single = getHeader(evt.headers, key);
1274
+ if (typeof single === 'string')
1275
+ return single;
1276
+ const multi = getMultiHeader(evt
1277
+ .multiValueHeaders, key);
1278
+ return multi;
1279
+ };
1280
+ const hasAwsSig = (auth) => typeof auth === 'string' && auth.startsWith('AWS4-HMAC-SHA256');
1281
+ const hasV1AccessKey = (evt) => {
1282
+ const rc = evt.requestContext;
1283
+ const ak = rc.identity?.accessKey;
1284
+ return typeof ak === 'string' && ak.length > 0;
1285
+ };
1286
+ const hasAuthorizer = (evt) => {
1287
+ // V1: any truthy authorizer object indicates an authenticated request
1288
+ if (isV1(evt)) {
1289
+ const rc = evt.requestContext;
1290
+ return !!rc.authorizer;
1291
+ }
1292
+ // V2: authorizer may include jwt/iam/lambda shapes
1293
+ const authHeader = getHeaderFromEvent(evt, 'authorization');
1294
+ if (hasAwsSig(authHeader))
1295
+ return true;
1296
+ const rc = evt
1297
+ .requestContext;
1298
+ const { authorizer } = rc;
1299
+ if (!authorizer || typeof authorizer !== 'object')
1300
+ return false;
1301
+ const a = authorizer;
1302
+ return !!(a.jwt || a.iam || a.lambda);
1303
+ };
1304
+ /** Detect API key via header or v1 identity.apiKey. */
1305
+ const hasApiKey = (evt) => {
1306
+ const fromHeader = getHeaderFromEvent(evt, 'x-api-key');
1307
+ if (typeof fromHeader === 'string')
1308
+ return true;
1309
+ // Some v1 events surface API key on requestContext.identity.apiKey
1310
+ if (isV1(evt)) {
1311
+ const rc = evt.requestContext;
1312
+ const identKey = rc.identity?.apiKey;
1313
+ if (typeof identKey === 'string' && identKey.length > 0)
1314
+ return true;
1315
+ const identKeyId = rc.identity?.apiKeyId;
1316
+ if (typeof identKeyId === 'string' && identKeyId.length > 0)
1317
+ return true;
1318
+ }
1319
+ return false;
1320
+ };
1321
+ /** Classify the security context from either API Gateway event version. */
1322
+ /**
1323
+ * Detect security context from API Gateway v1/v2 events.
1324
+ *
1325
+ * @param evt - unknown event
1326
+ * @returns 'my' when authorized (Cognito/JWT/IAM), 'private' when API key present, else 'public'
1327
+ */
1328
+ const detectSecurityContext = (evt) => {
1329
+ if (isV1(evt)) {
1330
+ const auth = getHeaderFromEvent(evt, 'authorization');
1331
+ if (hasAwsSig(auth) || hasV1AccessKey(evt) || hasAuthorizer(evt))
1332
+ return 'my';
1333
+ if (hasApiKey(evt))
1334
+ return 'private';
1335
+ return 'public';
1336
+ }
1337
+ if (isV2(evt)) {
1338
+ const auth = getHeaderFromEvent(evt, 'authorization');
1339
+ if (hasAwsSig(auth) || hasAuthorizer(evt))
1340
+ return 'my';
1341
+ if (hasApiKey(evt))
1342
+ return 'private';
1343
+ return 'public';
1344
+ }
1345
+ return 'public';
664
1346
  };
665
1347
 
666
1348
  /**