@mettlecast/domain-runtime 0.2.59 → 0.2.61
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/README.md +57 -40
- package/dist/primitives/action.d.ts +80 -8
- package/dist/primitives/action.js +71 -4
- package/dist/primitives/index.d.ts +0 -1
- package/dist/primitives/index.js +0 -1
- package/dist/runtime/action-executor.d.ts +164 -0
- package/dist/runtime/action-executor.js +408 -0
- package/dist/runtime/action-handler.d.ts +110 -6
- package/dist/runtime/action-handler.js +110 -10
- package/dist/runtime/actions.d.ts +65 -5
- package/dist/runtime/actions.js +118 -13
- package/dist/runtime/audit.d.ts +23 -3
- package/dist/runtime/audit.js +20 -3
- package/dist/runtime/db.d.ts +23 -4
- package/dist/runtime/db.js +27 -6
- package/dist/runtime/error-formatter.d.ts +63 -0
- package/dist/runtime/error-formatter.js +68 -1
- package/dist/runtime/exposed-action-api-handler.d.ts +87 -0
- package/dist/runtime/exposed-action-api-handler.js +287 -0
- package/dist/runtime/extractors.d.ts +12 -0
- package/dist/runtime/extractors.js +21 -13
- package/dist/runtime/files.d.ts +8 -0
- package/dist/runtime/files.js +76 -8
- package/dist/runtime/hydrate.js +17 -2
- package/dist/runtime/index.d.ts +10 -2
- package/dist/runtime/index.js +18 -1
- package/dist/runtime/observability-bindings.d.ts +110 -0
- package/dist/runtime/observability-bindings.js +94 -0
- package/dist/runtime/store.d.ts +8 -0
- package/dist/runtime/store.js +29 -0
- package/dist/runtime/tracer.d.ts +1 -1
- package/dist/runtime/tracer.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/rls.d.ts +31 -0
- package/dist/schema/rls.js +44 -0
- package/dist/types/tenant.d.ts +12 -0
- 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
package/dist/schema/rls.js
CHANGED
|
@@ -9,6 +9,20 @@
|
|
|
9
9
|
* if a query is made outside a request context — those queries return zero rows
|
|
10
10
|
* instead of throwing.
|
|
11
11
|
*
|
|
12
|
+
* **Session variable contract.** This policy reads `app.current_tenant`, which
|
|
13
|
+
* is bound by `createDb` (Wave 5 Task 5.1) via
|
|
14
|
+
* `SELECT set_config('app.current_tenant', $1, false)` on every per-request
|
|
15
|
+
* client checkout. The bound value comes from `ctx.tenant.id` (see
|
|
16
|
+
* `hydrate.ts`). Any domain code that issues raw SQL against a RLS-protected
|
|
17
|
+
* table therefore relies on the runtime having already bound that variable;
|
|
18
|
+
* if a query is made before `createDb` finishes binding (or after the client
|
|
19
|
+
* is released back to the pool), the policy falls back to a zero-row result
|
|
20
|
+
* because the setting is unset and `current_setting(..., true)` returns NULL.
|
|
21
|
+
*
|
|
22
|
+
* For sys-admin / cross-tenant queries, callers must additionally run
|
|
23
|
+
* `SELECT set_config('app.bypass_rls', 'true', false)` on the same client.
|
|
24
|
+
* This contract is enforced by `tenantAdminBypassSql` below.
|
|
25
|
+
*
|
|
12
26
|
* @param opts - Table and tenant column configuration.
|
|
13
27
|
* @returns A multi-statement SQL string suitable for inclusion in a migration.
|
|
14
28
|
*/
|
|
@@ -25,6 +39,36 @@ export function tenantIsolationSql(opts) {
|
|
|
25
39
|
` WITH CHECK (${tenantColumn} = current_setting('app.current_tenant', true)::uuid);`,
|
|
26
40
|
].join('\n');
|
|
27
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Generate the SQL to add a permissive RLS bypass policy that fires when
|
|
44
|
+
* `current_setting('app.bypass_rls', true) = 'true'`. Intended to be added
|
|
45
|
+
* alongside `tenantIsolationSql` for sys-admin / cross-tenant queries that
|
|
46
|
+
* must read rows belonging to any tenant (for example, the data-management
|
|
47
|
+
* domain's schema explorer). The runtime sets `app.bypass_rls` via
|
|
48
|
+
* `SELECT set_config('app.bypass_rls', 'true', false)` on the same per-request
|
|
49
|
+
* client that hydrates with the sys-admin role.
|
|
50
|
+
*
|
|
51
|
+
* The generated policy is idempotent: it only creates the policy if no policy
|
|
52
|
+
* with the same name already exists.
|
|
53
|
+
*
|
|
54
|
+
* @param opts - Table and tenant column configuration (same shape as
|
|
55
|
+
* `tenantIsolationSql` so the two helpers compose cleanly).
|
|
56
|
+
* @returns A multi-statement SQL string suitable for inclusion in a migration.
|
|
57
|
+
*/
|
|
58
|
+
export function tenantAdminBypassSql(opts) {
|
|
59
|
+
const schema = opts.schema ?? 'public';
|
|
60
|
+
const policyName = `tenant_admin_bypass_${opts.table}`;
|
|
61
|
+
const qualified = `"${schema}"."${opts.table}"`;
|
|
62
|
+
return [
|
|
63
|
+
`DO $$ BEGIN`,
|
|
64
|
+
` IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = '${policyName}') THEN`,
|
|
65
|
+
` CREATE POLICY "${policyName}" ON ${qualified}`,
|
|
66
|
+
` AS PERMISSIVE FOR ALL`,
|
|
67
|
+
` USING (current_setting('app.bypass_rls', true) = 'true');`,
|
|
68
|
+
` END IF;`,
|
|
69
|
+
`END $$;`,
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
28
72
|
/**
|
|
29
73
|
* Generate the SQL to drop a tenant isolation policy and disable RLS.
|
|
30
74
|
* Useful for down-migrations.
|
package/dist/types/tenant.d.ts
CHANGED
|
@@ -8,6 +8,18 @@ export interface Actor {
|
|
|
8
8
|
email?: string;
|
|
9
9
|
/** Tenant the actor belongs to. */
|
|
10
10
|
tenantId?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Role claims attached to the actor (e.g. `['sys_admin']`).
|
|
13
|
+
* Populated from JWT `cognito:groups` / `custom:roles` claims.
|
|
14
|
+
* Optional — actors from non-user sources (system, action, schedule)
|
|
15
|
+
* may not carry roles.
|
|
16
|
+
*/
|
|
17
|
+
roles?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Scope claims attached to the actor (e.g. Cognito OAuth scopes).
|
|
20
|
+
* Optional — system/derived actors may not carry scopes.
|
|
21
|
+
*/
|
|
22
|
+
scopes?: string[];
|
|
11
23
|
}
|
|
12
24
|
/** Tenant record. Always populated; never null in a valid request. */
|
|
13
25
|
export interface Tenant {
|
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
|
-
}
|