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