@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.
Files changed (42) hide show
  1. package/README.md +57 -40
  2. package/dist/primitives/action.d.ts +80 -8
  3. package/dist/primitives/action.js +71 -4
  4. package/dist/primitives/index.d.ts +0 -1
  5. package/dist/primitives/index.js +0 -1
  6. package/dist/runtime/action-executor.d.ts +164 -0
  7. package/dist/runtime/action-executor.js +408 -0
  8. package/dist/runtime/action-handler.d.ts +110 -6
  9. package/dist/runtime/action-handler.js +110 -10
  10. package/dist/runtime/actions.d.ts +65 -5
  11. package/dist/runtime/actions.js +118 -13
  12. package/dist/runtime/audit.d.ts +23 -3
  13. package/dist/runtime/audit.js +20 -3
  14. package/dist/runtime/db.d.ts +23 -4
  15. package/dist/runtime/db.js +27 -6
  16. package/dist/runtime/error-formatter.d.ts +63 -0
  17. package/dist/runtime/error-formatter.js +68 -1
  18. package/dist/runtime/exposed-action-api-handler.d.ts +87 -0
  19. package/dist/runtime/exposed-action-api-handler.js +287 -0
  20. package/dist/runtime/extractors.d.ts +12 -0
  21. package/dist/runtime/extractors.js +21 -13
  22. package/dist/runtime/files.d.ts +8 -0
  23. package/dist/runtime/files.js +76 -8
  24. package/dist/runtime/hydrate.js +17 -2
  25. package/dist/runtime/index.d.ts +10 -2
  26. package/dist/runtime/index.js +18 -1
  27. package/dist/runtime/observability-bindings.d.ts +110 -0
  28. package/dist/runtime/observability-bindings.js +94 -0
  29. package/dist/runtime/store.d.ts +8 -0
  30. package/dist/runtime/store.js +29 -0
  31. package/dist/runtime/tracer.d.ts +1 -1
  32. package/dist/runtime/tracer.js +1 -1
  33. package/dist/schema/index.d.ts +1 -1
  34. package/dist/schema/index.js +1 -1
  35. package/dist/schema/rls.d.ts +31 -0
  36. package/dist/schema/rls.js +44 -0
  37. package/dist/types/tenant.d.ts +12 -0
  38. package/package.json +1 -1
  39. package/dist/primitives/api.d.ts +0 -44
  40. package/dist/primitives/api.js +0 -70
  41. package/dist/runtime/api-handler.d.ts +0 -29
  42. package/dist/runtime/api-handler.js +0 -212
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-runtime",
3
- "version": "0.2.59",
3
+ "version": "0.2.61",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -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;
@@ -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
- }