@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.
- package/README.md +57 -40
- package/dist/primitives/action.d.ts +81 -8
- package/dist/primitives/action.js +68 -2
- 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 +88 -0
- package/dist/runtime/exposed-action-api-handler.js +288 -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 -0
- package/dist/runtime/index.js +18 -0
- 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
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
16
|
-
* Receives cross-domain invocations via Lambda invoke and runs the action handler.
|
|
69
|
+
* Options for {@link createActionLambdaHandler}.
|
|
17
70
|
*/
|
|
18
|
-
export
|
|
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
|
-
}
|
|
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>;
|