@mettlecast/domain-runtime 0.2.59 → 0.2.60

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 (36) hide show
  1. package/README.md +57 -40
  2. package/dist/primitives/action.d.ts +81 -8
  3. package/dist/primitives/action.js +68 -2
  4. package/dist/runtime/action-executor.d.ts +164 -0
  5. package/dist/runtime/action-executor.js +408 -0
  6. package/dist/runtime/action-handler.d.ts +110 -6
  7. package/dist/runtime/action-handler.js +110 -10
  8. package/dist/runtime/actions.d.ts +65 -5
  9. package/dist/runtime/actions.js +118 -13
  10. package/dist/runtime/audit.d.ts +23 -3
  11. package/dist/runtime/audit.js +20 -3
  12. package/dist/runtime/db.d.ts +23 -4
  13. package/dist/runtime/db.js +27 -6
  14. package/dist/runtime/error-formatter.d.ts +63 -0
  15. package/dist/runtime/error-formatter.js +68 -1
  16. package/dist/runtime/exposed-action-api-handler.d.ts +88 -0
  17. package/dist/runtime/exposed-action-api-handler.js +288 -0
  18. package/dist/runtime/extractors.d.ts +12 -0
  19. package/dist/runtime/extractors.js +21 -13
  20. package/dist/runtime/files.d.ts +8 -0
  21. package/dist/runtime/files.js +76 -8
  22. package/dist/runtime/hydrate.js +17 -2
  23. package/dist/runtime/index.d.ts +10 -0
  24. package/dist/runtime/index.js +18 -0
  25. package/dist/runtime/observability-bindings.d.ts +110 -0
  26. package/dist/runtime/observability-bindings.js +94 -0
  27. package/dist/runtime/store.d.ts +8 -0
  28. package/dist/runtime/store.js +29 -0
  29. package/dist/runtime/tracer.d.ts +1 -1
  30. package/dist/runtime/tracer.js +1 -1
  31. package/dist/schema/index.d.ts +1 -1
  32. package/dist/schema/index.js +1 -1
  33. package/dist/schema/rls.d.ts +31 -0
  34. package/dist/schema/rls.js +44 -0
  35. package/dist/types/tenant.d.ts +12 -0
  36. package/package.json +1 -1
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Shared action executor.
3
+ *
4
+ * Wave 2 of #4619 — used by both API-exposed actions and internal action
5
+ * invocations. Centralises the validation, auth/tenant checks, idempotency,
6
+ * audit, handler execution, output validation, and DB cleanup steps so the
7
+ * downstream API and internal adapters can stay thin and uniform.
8
+ *
9
+ * This module does NOT format HTTP responses — transport-specific adapters
10
+ * own response shaping. The executor returns parsed output (or throws a
11
+ * `TibError`) and is responsible only for the in-process pipeline.
12
+ */
13
+ import { ZodError } from 'zod';
14
+ import { TibError } from './error-formatter.js';
15
+ import { extractObservabilityBindings } from './observability-bindings.js';
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // backendAccess helper
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ /**
20
+ * Resolve whether a given caller domain may invoke an action based on its
21
+ * declared `backendAccess` scope and optional `allowedCallers` allowlist.
22
+ *
23
+ * Rules:
24
+ * - `allowedCallers` (if non-empty) takes precedence: callerDomain MUST be
25
+ * in the list regardless of `backendAccess`. Used to narrow broad
26
+ * platform-scoped primitives for sensitive handlers (issue #4662,
27
+ * Task A hardening).
28
+ * - `'private'` — only the defining domain may call it. When
29
+ * `definingDomain` is missing we fail CLOSED (deny) —
30
+ * the in-process proxy must always pass defining-domain
31
+ * metadata now that Wave 7 Task 7.1 has plumbed it
32
+ * through `createActionsProxy` / `invokeInProcess`.
33
+ * - `'domain'` — any caller whose domain is non-platform is allowed (the
34
+ * callerDomain is just recorded for audit; the policy here
35
+ * is "callable by other domains").
36
+ * - `'platform'` — reserved for platform-level services. We currently allow
37
+ * any non-undefined callerDomain through; finer-grained
38
+ * allowlists are out of scope for Wave 2 and will be wired
39
+ * in once the action registry exposes the defining domain.
40
+ */
41
+ export function isCallerAllowed(opts) {
42
+ const { action, callerDomain, definingDomain } = opts;
43
+ if (callerDomain === undefined) {
44
+ // Defensive: refuse calls that arrive without a caller identity. Internal
45
+ // adapters always populate this; this guard catches programmer errors.
46
+ return false;
47
+ }
48
+ // Narrow allowlist (issue #4662). When `allowedCallers` is non-empty,
49
+ // membership in the list is required regardless of `backendAccess`.
50
+ // An empty/undefined list falls through to the backendAccess policy.
51
+ const allowlist = action.allowedCallers;
52
+ if (Array.isArray(allowlist) && allowlist.length > 0) {
53
+ return allowlist.includes(callerDomain);
54
+ }
55
+ switch (action.backendAccess) {
56
+ case 'private':
57
+ // Only the defining domain may call. When the defining domain is
58
+ // unknown we fail closed — previously this was permissive ("treat
59
+ // as allowed") but that let cross-domain in-process calls bypass
60
+ // the privacy gate when adapters hadn't yet plumbed metadata. The
61
+ // in-process path in `actions.ts` now always supplies `definingDomain`
62
+ // so well-behaved adapters are unaffected; only the unguarded
63
+ // fallback returns false.
64
+ if (!definingDomain)
65
+ return false;
66
+ return callerDomain === definingDomain;
67
+ case 'domain':
68
+ // Any domain in the workspace may invoke. callerDomain is present
69
+ // and non-platform — accept.
70
+ return true;
71
+ case 'platform':
72
+ // Platform scope is reserved; for Wave 2 we accept any callerDomain.
73
+ return true;
74
+ default: {
75
+ // Exhaustiveness guard — should never run.
76
+ const _exhaustive = action.backendAccess;
77
+ void _exhaustive;
78
+ return false;
79
+ }
80
+ }
81
+ }
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // Pipeline helpers (exported so individual tests can pin behaviour)
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+ /**
86
+ * Throws `TibError` when `condition` is falsy. Used to enforce preconditions
87
+ * before any handler work begins; the resulting error carries a stable `code`
88
+ * so adapters can map it to the appropriate HTTP status without parsing
89
+ * the message.
90
+ */
91
+ export function assertOrThrow(condition, message, code) {
92
+ if (!condition) {
93
+ throw new TibError(message, code);
94
+ }
95
+ }
96
+ /**
97
+ * Lightweight tenant scoping check. Centralised so the API and internal
98
+ * adapters share the same wording and codes.
99
+ *
100
+ * - `ctx.tenant.id` must be a non-empty string other than `'unknown'`.
101
+ * - When `expectedTenantId` is provided (path or caller claim), it must
102
+ * match `ctx.tenant.id`.
103
+ */
104
+ export function ensureTenantMatches(ctx, expectedTenantId) {
105
+ assertOrThrow(typeof ctx.tenant.id === 'string' && ctx.tenant.id.length > 0 && ctx.tenant.id !== 'unknown', 'Tenant context required', 'TENANT_REQUIRED');
106
+ if (expectedTenantId !== undefined && expectedTenantId !== ctx.tenant.id) {
107
+ throw new TibError('Tenant mismatch', 'TENANT_MISMATCH');
108
+ }
109
+ }
110
+ /**
111
+ * Check role claims against an action's required role list. Only meaningful
112
+ * when `auth === 'required'` AND `tenancy === 'system'`.
113
+ *
114
+ * Returns `true` when `requiredRoles` is empty/undefined (no narrowing).
115
+ */
116
+ export function actorHasRequiredRoles(actorRoles, requiredRoles) {
117
+ if (!requiredRoles || requiredRoles.length === 0)
118
+ return true;
119
+ if (!actorRoles || actorRoles.length === 0)
120
+ return false;
121
+ return requiredRoles.some(role => actorRoles.includes(role));
122
+ }
123
+ /**
124
+ * Run an action through the shared pipeline.
125
+ *
126
+ * Pipeline:
127
+ * 1. Validate input with `action.input.parse(input)`.
128
+ * 2. Enforce auth/tenancy preconditions based on `source`.
129
+ * 3. Enforce `backendAccess` for internal calls.
130
+ * 4. Check `ctx.idempotency` when `action.idempotent` is true.
131
+ * 5. Invoke `action.handler(parsedInput, ctx)`.
132
+ * 6. Validate output with `action.output.parse(result)`.
133
+ * 7. Mark idempotency after successful handler execution.
134
+ * 8. Audit-log the invocation.
135
+ * 9. Release `ctx.db` (only if ownership flag is set).
136
+ *
137
+ * The executor does NOT throw for application-level failures — it returns
138
+ * a `TibError`-bearing `ExecuteActionResult` so the caller can render it
139
+ * consistently. Truly unexpected throws propagate to the caller.
140
+ */
141
+ export async function executeAction(action, input, ctx, source, meta = {}) {
142
+ const traceId = meta.traceId ?? 'unknown';
143
+ // Issue #4662 Task D — bind a child logger to the canonical
144
+ // observability fields (tenantId, orgId, actorSub, actionId,
145
+ // callerDomain, traceId) so every pipeline step in this function
146
+ // emits structured logs that CloudWatch Logs Insights can filter
147
+ // without re-deriving the identity from each log line. Falls back
148
+ // to the raw logger when `child` is missing or returns nothing
149
+ // (test doubles occasionally stub `child` with `vi.fn()` which
150
+ // returns `undefined`).
151
+ const baseLog = ctx.logger;
152
+ const bindings = extractObservabilityBindings(ctx, {
153
+ traceId,
154
+ definingDomain: meta.definingDomain,
155
+ envelope: source.type === 'internal'
156
+ ? { callerDomain: source.callerDomain, actionId: action.id }
157
+ : { actionId: action.id },
158
+ });
159
+ let log = baseLog;
160
+ if (typeof baseLog.child === 'function') {
161
+ const child = baseLog.child(bindings);
162
+ if (child && typeof child.debug === 'function') {
163
+ log = child;
164
+ }
165
+ }
166
+ // ── 1. Validate input ────────────────────────────────────────────────────
167
+ let parsedInput;
168
+ try {
169
+ parsedInput = action.input.parse(input);
170
+ }
171
+ catch (err) {
172
+ log.warn('Action input validation failed', {
173
+ actionId: action.id,
174
+ code: 'VALIDATION_ERROR',
175
+ traceId,
176
+ });
177
+ return { ok: false, error: err instanceof ZodError ? err : new TibError('Invalid input', 'VALIDATION_ERROR'), code: 'VALIDATION_ERROR' };
178
+ }
179
+ // ── 2. Auth/tenancy preconditions (API source) ───────────────────────────
180
+ if (source.type === 'api') {
181
+ const exposure = action.exposure;
182
+ if (exposure.type !== 'api') {
183
+ // An action invoked via API source must be API-exposed. Internal-only
184
+ // actions should never be reachable through API routes.
185
+ log.warn('Internal action reached through API source', {
186
+ actionId: action.id,
187
+ code: 'INTERNAL_ACTION_NOT_EXPOSABLE',
188
+ traceId,
189
+ });
190
+ return { ok: false, error: new TibError('Action not exposed', 'NOT_EXPOSED'), code: 'NOT_EXPOSED' };
191
+ }
192
+ if (exposure.auth === 'required') {
193
+ try {
194
+ assertOrThrow(typeof ctx.actor.sub === 'string' && ctx.actor.sub.length > 0, 'Authenticated actor required', 'AUTH_REQUIRED');
195
+ }
196
+ catch (err) {
197
+ const tibErr = err instanceof TibError ? err : new TibError('Auth check failed', 'AUTH_REQUIRED');
198
+ log.warn(tibErr.message, { actionId: action.id, code: tibErr.code, traceId });
199
+ return { ok: false, error: tibErr, code: tibErr.code };
200
+ }
201
+ }
202
+ if (exposure.tenancy === 'required') {
203
+ try {
204
+ ensureTenantMatches(ctx, source.pathTenantId);
205
+ }
206
+ catch (err) {
207
+ const tibErr = err instanceof TibError ? err : new TibError('Tenant check failed', 'TENANT_REQUIRED');
208
+ log.warn(tibErr.message, { actionId: action.id, code: tibErr.code, traceId });
209
+ return { ok: false, error: tibErr, code: tibErr.code };
210
+ }
211
+ }
212
+ else if (exposure.tenancy === 'system') {
213
+ // System tenancy: require an actor and (when configured) roles.
214
+ try {
215
+ assertOrThrow(typeof ctx.actor.sub === 'string' && ctx.actor.sub.length > 0, 'Authenticated actor required', 'AUTH_REQUIRED');
216
+ if (exposure.roles && exposure.roles.length > 0) {
217
+ assertOrThrow(actorHasRequiredRoles(source.actorRoles, exposure.roles), `Required role missing: ${exposure.roles.join(',')}`, 'FORBIDDEN');
218
+ }
219
+ }
220
+ catch (err) {
221
+ const tibErr = err instanceof TibError ? err : new TibError('Auth check failed', 'AUTH_REQUIRED');
222
+ log.warn(tibErr.message, { actionId: action.id, code: tibErr.code, traceId });
223
+ return { ok: false, error: tibErr, code: tibErr.code };
224
+ }
225
+ }
226
+ }
227
+ else {
228
+ // ── 3. backendAccess enforcement (internal source) ──────────────────────
229
+ if (!isCallerAllowed({ action, callerDomain: source.callerDomain, definingDomain: meta.definingDomain })) {
230
+ log.warn('Internal caller denied by backendAccess', {
231
+ actionId: action.id,
232
+ callerDomain: source.callerDomain,
233
+ backendAccess: action.backendAccess,
234
+ code: 'FORBIDDEN',
235
+ traceId,
236
+ });
237
+ return { ok: false, error: new TibError('Caller not permitted', 'FORBIDDEN'), code: 'FORBIDDEN' };
238
+ }
239
+ // Tenant scoping for internal calls — non-internal exposures still
240
+ // require a tenant. Pure internal actions may legitimately run with
241
+ // 'unknown' tenants (e.g. system jobs), so we only enforce when the
242
+ // action declares any tenancy posture that demands it.
243
+ if (action.exposure.type === 'api' && action.exposure.tenancy === 'required') {
244
+ try {
245
+ ensureTenantMatches(ctx, source.callerTenantId);
246
+ }
247
+ catch (err) {
248
+ const tibErr = err instanceof TibError ? err : new TibError('Tenant check failed', 'TENANT_REQUIRED');
249
+ log.warn(tibErr.message, { actionId: action.id, code: tibErr.code, traceId });
250
+ return { ok: false, error: tibErr, code: tibErr.code };
251
+ }
252
+ }
253
+ }
254
+ // ── 4. Idempotency check (when configured) ───────────────────────────────
255
+ let replayed = false;
256
+ if (action.idempotent) {
257
+ try {
258
+ if (await ctx.idempotency.isProcessed()) {
259
+ replayed = true;
260
+ log.info('Action idempotent replay short-circuit', {
261
+ actionId: action.id,
262
+ idempotencyKey: ctx.idempotency.key,
263
+ traceId,
264
+ });
265
+ // We don't have the cached output value here — adapters can look it
266
+ // up via ctx.cache if they need to. For Wave 2 we just signal
267
+ // `replayed: true` and skip re-execution.
268
+ return { ok: true, value: undefined, replayed: true };
269
+ }
270
+ }
271
+ catch (err) {
272
+ log.warn('Idempotency check failed; proceeding without dedupe', {
273
+ actionId: action.id,
274
+ err: err instanceof Error ? err.message : String(err),
275
+ traceId,
276
+ });
277
+ }
278
+ }
279
+ // ── 5. Run the handler ───────────────────────────────────────────────────
280
+ let result;
281
+ let handlerRejected;
282
+ const handlerStart = Date.now();
283
+ try {
284
+ result = await action.handler(parsedInput, ctx);
285
+ }
286
+ catch (err) {
287
+ // Domain-meaningful errors (TibError) carry a stable code that adapters
288
+ // map to an HTTP status. Returning them as `ok: false` lets the adapter
289
+ // render the correct status without the executor reaching for message
290
+ // parsing. Non-TibError throws are unexpected and propagate so callers
291
+ // (adapters, tests) can distinguish "domain rejected" from "executor
292
+ // rejected" — preserves the Wave 7 Task 7.2 contract that handler
293
+ // regressions still surface loudly.
294
+ if (err instanceof TibError) {
295
+ handlerRejected = err;
296
+ log.warn('Action handler rejected with TibError', {
297
+ actionId: action.id,
298
+ code: err.code,
299
+ traceId,
300
+ });
301
+ }
302
+ else {
303
+ log.error('Action handler threw', {
304
+ actionId: action.id,
305
+ err: err instanceof Error ? err.message : String(err),
306
+ traceId,
307
+ });
308
+ throw err;
309
+ }
310
+ }
311
+ finally {
312
+ log.debug('Action handler completed', {
313
+ actionId: action.id,
314
+ durationMs: Date.now() - handlerStart,
315
+ traceId,
316
+ });
317
+ }
318
+ if (handlerRejected) {
319
+ return { ok: false, error: handlerRejected, code: handlerRejected.code };
320
+ }
321
+ // ── 6. Validate output ───────────────────────────────────────────────────
322
+ let parsedOutput;
323
+ try {
324
+ parsedOutput = action.output.parse(result);
325
+ }
326
+ catch (err) {
327
+ log.error('Action output validation failed', {
328
+ actionId: action.id,
329
+ traceId,
330
+ });
331
+ return {
332
+ ok: false,
333
+ error: err instanceof ZodError ? err : new TibError('Output validation failed', 'OUTPUT_VALIDATION_ERROR'),
334
+ code: 'OUTPUT_VALIDATION_ERROR',
335
+ };
336
+ }
337
+ // ── 7. Mark idempotency after successful execution ───────────────────────
338
+ if (action.idempotent && !replayed) {
339
+ try {
340
+ await ctx.idempotency.markProcessed();
341
+ }
342
+ catch (err) {
343
+ log.warn('Idempotency markProcessed failed', {
344
+ actionId: action.id,
345
+ err: err instanceof Error ? err.message : String(err),
346
+ traceId,
347
+ });
348
+ }
349
+ }
350
+ // ── 8. Audit-log the invocation (best-effort) ────────────────────────────
351
+ try {
352
+ await ctx.audit.log(action.id, `${source.type === 'api' ? source.pathTenantId ?? ctx.tenant.id : ctx.tenant.id}`, {
353
+ backendAccess: action.backendAccess,
354
+ exposureType: action.exposure.type,
355
+ sourceType: source.type,
356
+ replayed,
357
+ });
358
+ }
359
+ catch (err) {
360
+ // Audit failures should never block a successful invocation — log and
361
+ // continue.
362
+ log.warn('Audit log failed', {
363
+ actionId: action.id,
364
+ err: err instanceof Error ? err.message : String(err),
365
+ traceId,
366
+ });
367
+ }
368
+ return { ok: true, value: parsedOutput, replayed };
369
+ }
370
+ // ─────────────────────────────────────────────────────────────────────────────
371
+ // DB lifecycle helper
372
+ // ─────────────────────────────────────────────────────────────────────────────
373
+ /**
374
+ * Release the DB client associated with `ctx`. Safe to call multiple times —
375
+ * each `DbContext.release()` implementation is idempotent (see `createDb`).
376
+ *
377
+ * The executor does NOT auto-release by default because most adapters
378
+ * (api-handler, action-handler) already own the DB lifecycle for the
379
+ * surrounding request scope. Use `withActionExecution` when the executor
380
+ * is the top-level owner of the request (e.g. standalone scripts, tests,
381
+ * or any future one-shot action runner).
382
+ */
383
+ export async function releaseDbSafely(ctx) {
384
+ try {
385
+ await ctx.db.release();
386
+ }
387
+ catch {
388
+ // Swallow — adapters already log release failures at their boundary.
389
+ }
390
+ }
391
+ /**
392
+ * Wrap `executeAction` so the caller can opt the executor into owning the
393
+ * DB lifecycle. When `ownDbLifecycle: true` the executor runs `ctx.db.release()`
394
+ * in a `finally` block. Existing adapters (api-handler, action-handler)
395
+ * continue to own the DB themselves and should call `executeAction` directly
396
+ * — there is no double-release because the existing `createDb`/`createMockDb`
397
+ * release paths are themselves idempotent.
398
+ */
399
+ export async function withActionExecution(action, input, ctx, source, meta = {}, options = {}) {
400
+ try {
401
+ return await executeAction(action, input, ctx, source, meta);
402
+ }
403
+ finally {
404
+ if (options.ownDbLifecycle) {
405
+ await releaseDbSafely(ctx);
406
+ }
407
+ }
408
+ }
@@ -1,23 +1,127 @@
1
1
  import type { Handler } from 'aws-lambda';
2
2
  import type { ActionRegistry } from './actions.js';
3
+ import type { ActionDefinition } from '../primitives/action.js';
4
+ /**
5
+ * Actor payload carried inside an {@link ActionInvocationEnvelope}.
6
+ *
7
+ * Mirrors {@link import('../types/tenant.js').Actor} but is a wire-format
8
+ * type — callers serialise it as JSON when crossing the Lambda invoke
9
+ * boundary, so the envelope must remain stable across runtime versions.
10
+ *
11
+ * Added in Wave 3 of #4619 to replace the legacy flat `actorId: string`
12
+ * field. New callers populate the structured form; old callers still
13
+ * work because the legacy field is honoured as a fallback.
14
+ */
15
+ export interface ActionActorEnvelope {
16
+ /** Actor classification (mirrors `Actor.type`). */
17
+ type: 'user' | 'system' | 'action' | 'schedule' | 'webhook';
18
+ /** Subject identifier (JWT `sub` claim or system ID). */
19
+ sub?: string;
20
+ /** Email address for user actors. */
21
+ email?: string;
22
+ /** Role claims attached to the actor. */
23
+ roles?: string[];
24
+ /** OAuth/Cognito scope claims. */
25
+ scopes?: string[];
26
+ }
27
+ /**
28
+ * Structured envelope propagated across the cross-domain action boundary.
29
+ *
30
+ * Added in Wave 3 of #4619. Producers (`createActionsProxy`) emit this
31
+ * shape for every Lambda-backed cross-domain call. Consumers
32
+ * (`createActionLambdaHandler`) hydrate `ctx` from it and route through
33
+ * `executeAction` when a full `ActionDefinition` is available.
34
+ *
35
+ * Migration note — the previous envelope carried a flat `actorId: string`
36
+ * field. That field is still parsed as a fallback when `actor.sub` is not
37
+ * present so in-flight invocations from older callers don't drop context
38
+ * during the upgrade window. New code MUST emit the structured `actor`
39
+ * block; `actorId` is deprecated and will be removed in a later wave.
40
+ */
3
41
  export interface ActionInvocationEnvelope {
4
42
  input: unknown;
5
43
  envelope: {
44
+ /** Tenant ID under which the action is being invoked. */
6
45
  tenantId: string;
46
+ /** Organisation ID from the caller's JWT (optional). */
7
47
  orgId?: string;
8
- actorId: string;
9
- traceId: string;
48
+ /** Caller identity (subject, email, roles, scopes). */
49
+ actor: ActionActorEnvelope;
50
+ /** Domain ID of the caller (used by `executeAction` for backendAccess). */
10
51
  callerDomain: string;
52
+ /** Target domain being invoked. Optional — derived from the ARN when omitted. */
53
+ targetDomain?: string;
54
+ /** Action ID being invoked within the target domain. */
11
55
  actionId: string;
56
+ /** Distributed trace ID propagated for observability. */
57
+ traceId: string;
58
+ /** Per-invocation request ID (correlates with the caller's Lambda log). */
59
+ requestId?: string;
60
+ /**
61
+ * @deprecated Legacy flat identifier — use `actor.sub` instead. Kept so
62
+ * older emitters don't drop caller identity mid-migration. When both
63
+ * are present the structured `actor` block wins.
64
+ */
65
+ actorId?: string;
12
66
  };
13
67
  }
14
68
  /**
15
- * Creates the Lambda handler for an action-class Lambda.
16
- * Receives cross-domain invocations via Lambda invoke and runs the action handler.
69
+ * Options for {@link createActionLambdaHandler}.
17
70
  */
18
- export declare function createActionLambdaHandler(registry: ActionRegistry, options: {
71
+ export interface CreateActionHandlerOptions {
72
+ /** Domain ID that owns this handler. Used as the `definingDomain` for backendAccess checks. */
19
73
  domainId: string;
74
+ /** EventBridge event bus name (optional, used by the publisher). */
20
75
  eventBusName?: string;
76
+ /** Database connection string (optional, used by `createDb`). */
21
77
  databaseUrl?: string;
78
+ /** DynamoDB table name for idempotency (optional). */
22
79
  idempotencyTableName?: string;
23
- }): Handler<ActionInvocationEnvelope, unknown>;
80
+ }
81
+ /**
82
+ * Creates the Lambda handler for an action-class Lambda.
83
+ *
84
+ * Receives cross-domain invocations via Lambda invoke and routes the
85
+ * action through the shared `executeAction` pipeline so the full set of
86
+ * checks (input validation, auth/tenancy, backendAccess, idempotency,
87
+ * audit, output validation) applies uniformly with API-exposed actions.
88
+ *
89
+ * Behaviour:
90
+ * - Hydrates `ctx` from the structured envelope (`actor` block takes
91
+ * precedence over the legacy `actorId` fallback).
92
+ * - Looks up the registered action by `domainId + actionId`.
93
+ * - If the registered entry is an `ActionDefinition`, runs it through
94
+ * `executeAction` with an `InternalActionSource` carrying the caller
95
+ * domain. This enables backendAccess enforcement on every cross-domain
96
+ * invocation.
97
+ * - If only the legacy handler-function form is registered, calls it
98
+ * directly — preserves migration compatibility for callers that have
99
+ * not yet supplied full definitions. The narrow migration-compatible
100
+ * path skips the executor pipeline (no Zod validation, no idempotency,
101
+ * no audit log) so legacy callers keep working while Wave 3 rolls out.
102
+ * New callers should register `ActionDefinition` instances.
103
+ *
104
+ * @param registry - Map of `domainId → actionId → ActionDefinition | handler`.
105
+ * @param options - Domain identification and runtime options.
106
+ * @returns AWS Lambda handler for the action-class Lambda.
107
+ */
108
+ export declare function createActionLambdaHandler(registry: ActionRegistry, options: CreateActionHandlerOptions): Handler<ActionInvocationEnvelope, unknown>;
109
+ /**
110
+ * Shape of an entry inside {@link ActionRegistry}. The runtime supports two
111
+ * registrations side-by-side during the Wave 3 migration:
112
+ *
113
+ * 1. A full `ActionDefinition` (returned by `defineAction(...)`). Preferred —
114
+ * routes through `executeAction` so backendAccess, idempotency, audit,
115
+ * and output validation all apply.
116
+ * 2. A bare handler function. Legacy form — kept so callers that have not
117
+ * yet migrated continue to work. NOT routed through `executeAction`;
118
+ * receives `ctx` directly.
119
+ *
120
+ * New code MUST register the `ActionDefinition` form.
121
+ */
122
+ export type ActionRegistryEntry = ActionDefinition | ActionHandlerFn;
123
+ /**
124
+ * Function signature for the legacy handler-only registration form.
125
+ * Mirrors the previous `ActionRegistry` value type for back-compat.
126
+ */
127
+ export type ActionHandlerFn = (input: unknown, ctx: any) => Promise<unknown>;