@mettlecast/domain-runtime 0.2.60 → 0.2.62
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/primitives/action.d.ts +3 -4
- package/dist/primitives/action.js +3 -2
- package/dist/primitives/index.d.ts +0 -1
- package/dist/primitives/index.js +0 -1
- package/dist/runtime/exposed-action-api-handler.d.ts +6 -7
- package/dist/runtime/exposed-action-api-handler.js +8 -9
- package/dist/runtime/index.d.ts +0 -2
- package/dist/runtime/index.js +0 -1
- package/package.json +1 -1
- package/dist/primitives/api.d.ts +0 -44
- package/dist/primitives/api.js +0 -70
- package/dist/runtime/api-handler.d.ts +0 -29
- package/dist/runtime/api-handler.js +0 -212
|
@@ -16,8 +16,7 @@ export type BackendAccess = 'private' | 'domain' | 'platform';
|
|
|
16
16
|
*/
|
|
17
17
|
export type ApiAuthMode = 'required' | 'none' | 'service';
|
|
18
18
|
/**
|
|
19
|
-
* Tenancy posture for an API-exposed action.
|
|
20
|
-
* so the same registry row can describe an action or a standalone API.
|
|
19
|
+
* Tenancy posture for an API-exposed action.
|
|
21
20
|
*/
|
|
22
21
|
export type ApiTenancyMode = 'required' | 'none' | 'system';
|
|
23
22
|
/**
|
|
@@ -37,8 +36,8 @@ export interface InternalExposure {
|
|
|
37
36
|
type: 'internal';
|
|
38
37
|
}
|
|
39
38
|
/**
|
|
40
|
-
* Action exposed as an HTTP endpoint. The
|
|
41
|
-
*
|
|
39
|
+
* Action exposed as an HTTP endpoint. The downstream CDK/registry
|
|
40
|
+
* pipeline generates API Gateway routes from this shape.
|
|
42
41
|
*/
|
|
43
42
|
export interface ApiExposure {
|
|
44
43
|
type: 'api';
|
|
@@ -62,12 +62,13 @@ const ActionExposureSchema = z.discriminatedUnion('type', [
|
|
|
62
62
|
InternalExposureSchema,
|
|
63
63
|
ApiExposureSchema,
|
|
64
64
|
]);
|
|
65
|
+
const ZodSchemaRefinement = z.custom((v) => typeof v === 'object' && v !== null && typeof v.parse === 'function', { message: 'input/output must be Zod schema objects' });
|
|
65
66
|
const ActionConfigSchema = z.object({
|
|
66
67
|
id: z.string().min(1),
|
|
67
68
|
backendAccess: z.nativeEnum(BackendAccessLiteral),
|
|
68
69
|
exposure: ActionExposureSchema,
|
|
69
|
-
input:
|
|
70
|
-
output:
|
|
70
|
+
input: ZodSchemaRefinement,
|
|
71
|
+
output: ZodSchemaRefinement,
|
|
71
72
|
idempotent: z.boolean().optional(),
|
|
72
73
|
allowedCallers: z.array(z.string().min(1)).optional(),
|
|
73
74
|
outboundAccess: z.enum(['internal', 'internet']).default('internal'),
|
package/dist/primitives/index.js
CHANGED
|
@@ -8,14 +8,13 @@
|
|
|
8
8
|
* (validation, auth/tenancy, idempotency, output validation, audit) to
|
|
9
9
|
* `executeAction`.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* This is the canonical HTTP adapter now that public endpoints are declared
|
|
12
|
+
* as API-exposed actions. It keeps HTTP concerns local while action-specific
|
|
13
|
+
* validation, auth/tenancy, idempotency, and output checks stay in
|
|
14
|
+
* `executeAction`.
|
|
15
15
|
*
|
|
16
|
-
* DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block
|
|
17
|
-
*
|
|
18
|
-
* default, so there is no double-release.
|
|
16
|
+
* DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block.
|
|
17
|
+
* `executeAction` does not auto-release by default, so there is no double-release.
|
|
19
18
|
*/
|
|
20
19
|
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
|
|
21
20
|
import type { ActionDefinition } from '../primitives/action.js';
|
|
@@ -8,14 +8,13 @@
|
|
|
8
8
|
* (validation, auth/tenancy, idempotency, output validation, audit) to
|
|
9
9
|
* `executeAction`.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* This is the canonical HTTP adapter now that public endpoints are declared
|
|
12
|
+
* as API-exposed actions. It keeps HTTP concerns local while action-specific
|
|
13
|
+
* validation, auth/tenancy, idempotency, and output checks stay in
|
|
14
|
+
* `executeAction`.
|
|
15
15
|
*
|
|
16
|
-
* DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block
|
|
17
|
-
*
|
|
18
|
-
* default, so there is no double-release.
|
|
16
|
+
* DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block.
|
|
17
|
+
* `executeAction` does not auto-release by default, so there is no double-release.
|
|
19
18
|
*/
|
|
20
19
|
import { hydrateCtx } from './hydrate.js';
|
|
21
20
|
import { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent, } from './extractors.js';
|
|
@@ -192,8 +191,8 @@ export function createExposedActionApiHandler(action, options = {}) {
|
|
|
192
191
|
// always reach it (it may be unset if hydration itself throws).
|
|
193
192
|
let ctx;
|
|
194
193
|
try {
|
|
195
|
-
// 2. Actor / tenant extraction —
|
|
196
|
-
//
|
|
194
|
+
// 2. Actor / tenant extraction — use shared helpers so JWT/path
|
|
195
|
+
// behaviour stays uniform across HTTP adapters.
|
|
197
196
|
const actor = extractActorFromApiGatewayEvent(event);
|
|
198
197
|
const tenant = extractTenantFromApiGatewayEvent(event);
|
|
199
198
|
// 3. Input parsing.
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -47,8 +47,6 @@ export { eventVersionMatches } from './event-version-matcher.js';
|
|
|
47
47
|
export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } from './extractors.js';
|
|
48
48
|
export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
|
|
49
49
|
export type { ParsedQuery } from './query-parser.js';
|
|
50
|
-
export { createApiLambdaHandler } from './api-handler.js';
|
|
51
|
-
export type { CreateApiHandlerOptions } from './api-handler.js';
|
|
52
50
|
export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
|
|
53
51
|
export type { CreateExposedActionHandlerOptions } from './exposed-action-api-handler.js';
|
|
54
52
|
export { createActionLambdaHandler } from './action-handler.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -53,7 +53,6 @@ export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } fro
|
|
|
53
53
|
// Query parsing
|
|
54
54
|
export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
|
|
55
55
|
// Lambda handler wrappers
|
|
56
|
-
export { createApiLambdaHandler } from './api-handler.js';
|
|
57
56
|
export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
|
|
58
57
|
export { createActionLambdaHandler } from './action-handler.js';
|
|
59
58
|
export { createSubscriberLambdaHandler } from './subscriber-handler.js';
|
package/package.json
CHANGED
package/dist/primitives/api.d.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { AuthConfig, RateLimitConfig, TenancyMode, DeploymentConfig, ObservabilityConfig, ApiVersion, OutboundAccess } from '../types/index.js';
|
|
2
|
-
/** Configuration for an API primitive. */
|
|
3
|
-
export interface ApiConfig {
|
|
4
|
-
/** Machine-readable API identifier (kebab-case). */
|
|
5
|
-
id: string;
|
|
6
|
-
/** HTTP route path relative to domain base, e.g. '/users/:id'. */
|
|
7
|
-
path: string;
|
|
8
|
-
/** HTTP method. Defaults to ANY. */
|
|
9
|
-
method?: string;
|
|
10
|
-
/** Tenancy requirement for this API. */
|
|
11
|
-
tenancy: TenancyMode;
|
|
12
|
-
/**
|
|
13
|
-
* Version map. Keys are version strings (e.g. 'v1', 'v2').
|
|
14
|
-
* At least one version is required.
|
|
15
|
-
*/
|
|
16
|
-
versions: Record<string, ApiVersion<any, any>>;
|
|
17
|
-
/** Authentication configuration. Defaults to jwt. */
|
|
18
|
-
auth?: AuthConfig;
|
|
19
|
-
/** Rate limiting configuration. */
|
|
20
|
-
rateLimit?: RateLimitConfig;
|
|
21
|
-
/** Deployment overrides. */
|
|
22
|
-
deployment?: DeploymentConfig;
|
|
23
|
-
/** Whether this handler may use NAT-backed public internet egress. Defaults to internal. */
|
|
24
|
-
outboundAccess?: OutboundAccess;
|
|
25
|
-
/** Observability thresholds. */
|
|
26
|
-
observability?: ObservabilityConfig;
|
|
27
|
-
/** Example request/response payloads for documentation (no runtime impact). */
|
|
28
|
-
examples?: {
|
|
29
|
-
request?: Record<string, unknown>;
|
|
30
|
-
response?: Record<string, unknown>;
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
/** Return type of defineApi. */
|
|
34
|
-
export interface ApiDefinition extends ApiConfig {
|
|
35
|
-
/** Discriminant. */
|
|
36
|
-
_kind: 'api';
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* Register an API primitive with versioned handlers.
|
|
40
|
-
* Validates that deprecated versions have a sunset date and that input/output
|
|
41
|
-
* schemas are wrapped with `.default(...)` (the Zod v4 example-data pattern).
|
|
42
|
-
* @throws {ZodError} if config is invalid
|
|
43
|
-
*/
|
|
44
|
-
export declare function defineApi(config: ApiConfig): ApiDefinition;
|
package/dist/primitives/api.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
// Native enum objects — Zod v4's recommended pattern over `z.enum([...])`.
|
|
3
|
-
// Each `as const` object becomes the source of truth; `z.nativeEnum(...)`
|
|
4
|
-
// derives the inferred string-literal union from the keys.
|
|
5
|
-
const TenancyModeLiteral = { required: 'required', none: 'none', system: 'system' };
|
|
6
|
-
const VersionStatusLiteral = { preview: 'preview', stable: 'stable', deprecated: 'deprecated', sunset: 'sunset' };
|
|
7
|
-
const AuthTypeLiteral = { jwt: 'jwt', 'api-key': 'api-key', none: 'none' };
|
|
8
|
-
const DeploymentTargetLiteral = { single: 'single', split: 'split' };
|
|
9
|
-
const OutboundAccessLiteral = { internal: 'internal', internet: 'internet' };
|
|
10
|
-
/**
|
|
11
|
-
* Returns true when a Zod schema has been wrapped with `.default(...)` (Zod v4
|
|
12
|
-
* pattern). The wrapped schema exposes a `_def.typeName` of `ZodDefault`.
|
|
13
|
-
* `defineApi` requires every version's `input` and `output` to be wrapped this
|
|
14
|
-
* way so that the runtime has a concrete example payload for documentation and
|
|
15
|
-
* schema-publish tooling.
|
|
16
|
-
*/
|
|
17
|
-
const hasDefault = (schema) => {
|
|
18
|
-
if (typeof schema !== 'object' || schema === null)
|
|
19
|
-
return false;
|
|
20
|
-
const def = schema._def;
|
|
21
|
-
if (!def)
|
|
22
|
-
return false;
|
|
23
|
-
if (def.typeName === 'ZodDefault' || def.type === 'default')
|
|
24
|
-
return true;
|
|
25
|
-
// Unwrap ZodOptional / ZodNullable — e.g. z.object({}).default({}).optional()
|
|
26
|
-
if (def.type === 'optional' || def.type === 'nullable') {
|
|
27
|
-
return hasDefault(schema._def.innerType);
|
|
28
|
-
}
|
|
29
|
-
return false;
|
|
30
|
-
};
|
|
31
|
-
const ApiConfigSchema = z.object({
|
|
32
|
-
id: z.string().min(1),
|
|
33
|
-
path: z.string().startsWith('/'),
|
|
34
|
-
method: z.string().optional(),
|
|
35
|
-
tenancy: z.nativeEnum(TenancyModeLiteral),
|
|
36
|
-
versions: z.record(z.string(), z.object({
|
|
37
|
-
status: z.nativeEnum(VersionStatusLiteral),
|
|
38
|
-
sunset: z.string().optional(),
|
|
39
|
-
input: z.unknown().refine(hasDefault, { message: 'input schema must have .default()' }),
|
|
40
|
-
output: z.unknown().refine(hasDefault, { message: 'output schema must have .default()' }),
|
|
41
|
-
handler: z.function(),
|
|
42
|
-
}).refine(v => v.status !== VersionStatusLiteral.deprecated || v.sunset !== undefined, {
|
|
43
|
-
message: 'sunset date is required when status is deprecated',
|
|
44
|
-
})),
|
|
45
|
-
auth: z.object({
|
|
46
|
-
type: z.nativeEnum(AuthTypeLiteral),
|
|
47
|
-
roles: z.array(z.string()).optional(),
|
|
48
|
-
}).optional(),
|
|
49
|
-
rateLimit: z.object({
|
|
50
|
-
perTenant: z.string().optional(),
|
|
51
|
-
perApiKey: z.string().optional(),
|
|
52
|
-
perIp: z.string().optional(),
|
|
53
|
-
}).optional(),
|
|
54
|
-
deployment: z.object({ target: z.nativeEnum(DeploymentTargetLiteral) }).passthrough().optional(),
|
|
55
|
-
outboundAccess: z.nativeEnum(OutboundAccessLiteral).default(OutboundAccessLiteral.internal),
|
|
56
|
-
observability: z.object({
|
|
57
|
-
sloP99Ms: z.number().positive().optional(),
|
|
58
|
-
alertOnErrorRate: z.number().min(0).max(1).optional(),
|
|
59
|
-
}).optional(),
|
|
60
|
-
}).refine(c => Object.keys(c.versions).length > 0, { message: 'at least one version required' });
|
|
61
|
-
/**
|
|
62
|
-
* Register an API primitive with versioned handlers.
|
|
63
|
-
* Validates that deprecated versions have a sunset date and that input/output
|
|
64
|
-
* schemas are wrapped with `.default(...)` (the Zod v4 example-data pattern).
|
|
65
|
-
* @throws {ZodError} if config is invalid
|
|
66
|
-
*/
|
|
67
|
-
export function defineApi(config) {
|
|
68
|
-
ApiConfigSchema.parse(config);
|
|
69
|
-
return { ...config, outboundAccess: config.outboundAccess ?? 'internal', _kind: 'api' };
|
|
70
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
|
|
2
|
-
import type { ApiDefinition } from '../primitives/api.js';
|
|
3
|
-
/**
|
|
4
|
-
* Options for createApiLambdaHandler.
|
|
5
|
-
*/
|
|
6
|
-
export interface CreateApiHandlerOptions {
|
|
7
|
-
/** Default version when no Accept-Version header. Defaults to highest stable. */
|
|
8
|
-
defaultVersion?: number;
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* Wrap an ApiDefinition into an AWS Lambda HTTP API v2 handler with full
|
|
12
|
-
* version negotiation, sunset enforcement, validation, ctx hydration, tenancy
|
|
13
|
-
* guard, and error formatting.
|
|
14
|
-
*
|
|
15
|
-
* The handler performs the following steps in order:
|
|
16
|
-
* 1. Version negotiation from Accept-Version or Accept headers
|
|
17
|
-
* 2. Sunset check and response header decoration
|
|
18
|
-
* 3. Tenancy validation (if required)
|
|
19
|
-
* 4. Input validation via Zod schema
|
|
20
|
-
* 5. Context hydration with actor, tenant, DB, secrets, etc.
|
|
21
|
-
* 6. Handler invocation with typed input and ctx
|
|
22
|
-
* 7. Output validation via Zod schema
|
|
23
|
-
* 8. DB connection release
|
|
24
|
-
*
|
|
25
|
-
* @param api - ApiDefinition with versioned handlers.
|
|
26
|
-
* @param options - Options for version negotiation and defaults.
|
|
27
|
-
* @returns AWS Lambda HTTP API v2 handler function.
|
|
28
|
-
*/
|
|
29
|
-
export declare function createApiLambdaHandler(api: ApiDefinition, options?: CreateApiHandlerOptions): (event: APIGatewayProxyEventV2WithJWTAuthorizer) => Promise<APIGatewayProxyStructuredResultV2>;
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { negotiateVersion } from './version-negotiator.js';
|
|
2
|
-
import { checkSunset, applySunsetHeaders } from './sunset-guard.js';
|
|
3
|
-
import { hydrateCtx } from './hydrate.js';
|
|
4
|
-
import { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } from './extractors.js';
|
|
5
|
-
import { formatError, extractTraceId, TibError } from './error-formatter.js';
|
|
6
|
-
import { createMetrics } from './metrics.js';
|
|
7
|
-
import { checkRateLimit } from './rate-limiter.js';
|
|
8
|
-
/**
|
|
9
|
-
* Wrap an ApiDefinition into an AWS Lambda HTTP API v2 handler with full
|
|
10
|
-
* version negotiation, sunset enforcement, validation, ctx hydration, tenancy
|
|
11
|
-
* guard, and error formatting.
|
|
12
|
-
*
|
|
13
|
-
* The handler performs the following steps in order:
|
|
14
|
-
* 1. Version negotiation from Accept-Version or Accept headers
|
|
15
|
-
* 2. Sunset check and response header decoration
|
|
16
|
-
* 3. Tenancy validation (if required)
|
|
17
|
-
* 4. Input validation via Zod schema
|
|
18
|
-
* 5. Context hydration with actor, tenant, DB, secrets, etc.
|
|
19
|
-
* 6. Handler invocation with typed input and ctx
|
|
20
|
-
* 7. Output validation via Zod schema
|
|
21
|
-
* 8. DB connection release
|
|
22
|
-
*
|
|
23
|
-
* @param api - ApiDefinition with versioned handlers.
|
|
24
|
-
* @param options - Options for version negotiation and defaults.
|
|
25
|
-
* @returns AWS Lambda HTTP API v2 handler function.
|
|
26
|
-
*/
|
|
27
|
-
export function createApiLambdaHandler(api, options) {
|
|
28
|
-
return async (event) => {
|
|
29
|
-
const responseHeaders = { 'Content-Type': 'application/json' };
|
|
30
|
-
// G7: Extract W3C traceparent header and propagate as traceId
|
|
31
|
-
const traceparent = event.headers?.['traceparent'];
|
|
32
|
-
const traceId = traceparent ?? extractTraceId(event.headers ?? {});
|
|
33
|
-
// G6: Initialize EMF metrics (TODO: extract domainId from api definition or context)
|
|
34
|
-
const metrics = createMetrics({
|
|
35
|
-
domainId: api.id, // TODO: domainId should come from api definition or deployment context
|
|
36
|
-
endpointId: api.id,
|
|
37
|
-
primitiveClass: 'api',
|
|
38
|
-
// tenantId will be added after tenant is extracted
|
|
39
|
-
});
|
|
40
|
-
const requestStart = Date.now();
|
|
41
|
-
metrics.recordColdStart();
|
|
42
|
-
try {
|
|
43
|
-
// 1. Version negotiation
|
|
44
|
-
const versionKeys = Object.keys(api.versions).sort();
|
|
45
|
-
const stableVersions = versionKeys.filter(k => api.versions[k].status === 'stable');
|
|
46
|
-
const defaultVersionKey = options?.defaultVersion !== undefined
|
|
47
|
-
? `v${options.defaultVersion}`
|
|
48
|
-
: (stableVersions[stableVersions.length - 1] ?? versionKeys[versionKeys.length - 1] ?? 'v1');
|
|
49
|
-
const defaultVersionNum = parseInt(defaultVersionKey.replace(/^v/, ''), 10);
|
|
50
|
-
const negotiation = negotiateVersion(event.headers ?? {}, isNaN(defaultVersionNum) ? 1 : defaultVersionNum);
|
|
51
|
-
const versionKey = `v${negotiation.requestedVersion}`;
|
|
52
|
-
const apiVersion = api.versions[versionKey];
|
|
53
|
-
if (!apiVersion) {
|
|
54
|
-
// G1: Use standard error envelope for unsupported version
|
|
55
|
-
return {
|
|
56
|
-
statusCode: 406,
|
|
57
|
-
headers: responseHeaders,
|
|
58
|
-
body: JSON.stringify(formatError(new TibError('Unsupported version', 'UNSUPPORTED_VERSION'), traceId)),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
// 2. Sunset check
|
|
62
|
-
const lifecycle = {
|
|
63
|
-
version: negotiation.requestedVersion,
|
|
64
|
-
status: apiVersion.status,
|
|
65
|
-
...(apiVersion.sunset && { sunsetAt: apiVersion.sunset }),
|
|
66
|
-
};
|
|
67
|
-
const sunsetResult = checkSunset(lifecycle, new Date());
|
|
68
|
-
applySunsetHeaders(responseHeaders, sunsetResult);
|
|
69
|
-
if (sunsetResult.blocked) {
|
|
70
|
-
// G1: Use standard error envelope
|
|
71
|
-
return {
|
|
72
|
-
statusCode: 410,
|
|
73
|
-
headers: responseHeaders,
|
|
74
|
-
body: JSON.stringify(formatError(new TibError('Version sunset', 'VERSION_SUNSET'), traceId)),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
// 3. Tenancy guard (before validation, before hydrate — fail fast)
|
|
78
|
-
const actor = extractActorFromApiGatewayEvent(event);
|
|
79
|
-
const tenant = extractTenantFromApiGatewayEvent(event);
|
|
80
|
-
if (api.tenancy === 'required') {
|
|
81
|
-
if (tenant.id === 'unknown') {
|
|
82
|
-
// G1: Use standard error envelope for auth errors
|
|
83
|
-
return {
|
|
84
|
-
statusCode: 401,
|
|
85
|
-
headers: responseHeaders,
|
|
86
|
-
body: JSON.stringify(formatError(new TibError('Tenant context required', 'TENANT_REQUIRED'), traceId)),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
const pathTenantId = event.pathParameters?.tenantId;
|
|
90
|
-
if (pathTenantId && pathTenantId !== tenant.id) {
|
|
91
|
-
// G1: Use standard error envelope for auth errors
|
|
92
|
-
return {
|
|
93
|
-
statusCode: 403,
|
|
94
|
-
headers: responseHeaders,
|
|
95
|
-
body: JSON.stringify(formatError(new TibError('Tenant mismatch', 'TENANT_MISMATCH'), traceId)),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// Rate limiting — per-tenant token bucket (in-memory, resets on cold start)
|
|
100
|
-
if (api.rateLimit?.perTenant) {
|
|
101
|
-
const tenantId = extractTenantFromApiGatewayEvent(event)?.id ?? 'anonymous';
|
|
102
|
-
const rateLimitKey = `${api.id}:tenant:${tenantId}`;
|
|
103
|
-
const allowed = checkRateLimit(rateLimitKey, api.rateLimit.perTenant);
|
|
104
|
-
if (!allowed) {
|
|
105
|
-
return {
|
|
106
|
-
statusCode: 429,
|
|
107
|
-
headers: { ...responseHeaders, 'Retry-After': '60' },
|
|
108
|
-
body: JSON.stringify(formatError(new TibError('Rate limit exceeded', 'RATE_LIMIT_EXCEEDED'), traceId)),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (api.rateLimit?.perIp) {
|
|
113
|
-
const sourceIp = event.requestContext?.http?.sourceIp ?? 'unknown';
|
|
114
|
-
const rateLimitKey = `${api.id}:ip:${sourceIp}`;
|
|
115
|
-
const allowed = checkRateLimit(rateLimitKey, api.rateLimit.perIp);
|
|
116
|
-
if (!allowed) {
|
|
117
|
-
return {
|
|
118
|
-
statusCode: 429,
|
|
119
|
-
headers: { ...responseHeaders, 'Retry-After': '60' },
|
|
120
|
-
body: JSON.stringify(formatError(new TibError('Rate limit exceeded', 'RATE_LIMIT_EXCEEDED'), traceId)),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
// 4. Validate input
|
|
125
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
126
|
-
let parsedInput;
|
|
127
|
-
try {
|
|
128
|
-
const rawBody = event.body ? (event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body) : undefined;
|
|
129
|
-
const inputJson = rawBody ? JSON.parse(rawBody) : undefined;
|
|
130
|
-
parsedInput = apiVersion.input.parse(inputJson);
|
|
131
|
-
}
|
|
132
|
-
catch (err) {
|
|
133
|
-
// G1: Use standard error envelope for validation errors
|
|
134
|
-
return {
|
|
135
|
-
statusCode: 400,
|
|
136
|
-
headers: responseHeaders,
|
|
137
|
-
body: JSON.stringify(formatError(err, traceId)),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
// G5: Auto-trigger idempotency check for POST/PUT/PATCH
|
|
141
|
-
const idempotencyKey = event.headers?.['idempotency-key'] ?? event.headers?.['Idempotency-Key'];
|
|
142
|
-
const httpMethod = event.requestContext?.http?.method ?? '';
|
|
143
|
-
// 5. Hydrate context
|
|
144
|
-
const { tenantId: _tenantId, ...remainingPathParams } = event.pathParameters ?? {};
|
|
145
|
-
const ctx = await hydrateCtx(event, {
|
|
146
|
-
actor,
|
|
147
|
-
tenant,
|
|
148
|
-
databaseUrl: process.env['DATABASE_URL'],
|
|
149
|
-
eventBusName: process.env['EVENT_BUS_NAME'],
|
|
150
|
-
traceId,
|
|
151
|
-
pathParams: remainingPathParams,
|
|
152
|
-
});
|
|
153
|
-
// G5: After hydration, check idempotency cache for POST/PUT/PATCH
|
|
154
|
-
if (idempotencyKey && ['POST', 'PUT', 'PATCH'].includes(httpMethod)) {
|
|
155
|
-
const cached = await ctx.idempotency.isProcessed();
|
|
156
|
-
if (cached) {
|
|
157
|
-
// Return cached response — for now return 200 with a replay indicator
|
|
158
|
-
return {
|
|
159
|
-
statusCode: 200,
|
|
160
|
-
headers: { ...responseHeaders, 'Idempotency-Replayed': 'true' },
|
|
161
|
-
body: JSON.stringify({ replayed: true, idempotencyKey }),
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
// 6. Call handler
|
|
167
|
-
const result = await apiVersion.handler(parsedInput, ctx);
|
|
168
|
-
// 7. Validate output
|
|
169
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
170
|
-
let parsedOutput;
|
|
171
|
-
try {
|
|
172
|
-
parsedOutput = apiVersion.output.parse(result);
|
|
173
|
-
}
|
|
174
|
-
catch (err) {
|
|
175
|
-
ctx.logger.error('Output validation failed');
|
|
176
|
-
// G1: Use standard error envelope for output validation errors
|
|
177
|
-
return {
|
|
178
|
-
statusCode: 500,
|
|
179
|
-
headers: responseHeaders,
|
|
180
|
-
body: JSON.stringify(formatError(err, traceId, ctx.logger)),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
// G5: Mark as processed after successful handler completion
|
|
184
|
-
if (idempotencyKey && ['POST', 'PUT', 'PATCH'].includes(httpMethod)) {
|
|
185
|
-
await ctx.idempotency.markProcessed();
|
|
186
|
-
}
|
|
187
|
-
return { statusCode: 200, headers: responseHeaders, body: JSON.stringify(parsedOutput) };
|
|
188
|
-
}
|
|
189
|
-
finally {
|
|
190
|
-
// 8. Always release DB
|
|
191
|
-
try {
|
|
192
|
-
await ctx.db.release();
|
|
193
|
-
}
|
|
194
|
-
catch { /* swallow */ }
|
|
195
|
-
// G6: Record latency and flush metrics
|
|
196
|
-
metrics.recordLatency(Date.now() - requestStart);
|
|
197
|
-
metrics.flush();
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
catch (err) {
|
|
201
|
-
// Last-resort error handler
|
|
202
|
-
// G1: Use standard error envelope for unhandled errors
|
|
203
|
-
// eslint-disable-next-line no-console
|
|
204
|
-
console.error('Unhandled handler error', err);
|
|
205
|
-
return {
|
|
206
|
-
statusCode: 500,
|
|
207
|
-
headers: responseHeaders,
|
|
208
|
-
body: JSON.stringify(formatError(err, traceId)),
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
}
|