@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
|
@@ -1,29 +1,129 @@
|
|
|
1
1
|
import { hydrateCtx } from './hydrate.js';
|
|
2
|
+
import { executeAction } from './action-executor.js';
|
|
2
3
|
/**
|
|
3
4
|
* Creates the Lambda handler for an action-class Lambda.
|
|
4
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Receives cross-domain invocations via Lambda invoke and routes the
|
|
7
|
+
* action through the shared `executeAction` pipeline so the full set of
|
|
8
|
+
* checks (input validation, auth/tenancy, backendAccess, idempotency,
|
|
9
|
+
* audit, output validation) applies uniformly with API-exposed actions.
|
|
10
|
+
*
|
|
11
|
+
* Behaviour:
|
|
12
|
+
* - Hydrates `ctx` from the structured envelope (`actor` block takes
|
|
13
|
+
* precedence over the legacy `actorId` fallback).
|
|
14
|
+
* - Looks up the registered action by `domainId + actionId`.
|
|
15
|
+
* - If the registered entry is an `ActionDefinition`, runs it through
|
|
16
|
+
* `executeAction` with an `InternalActionSource` carrying the caller
|
|
17
|
+
* domain. This enables backendAccess enforcement on every cross-domain
|
|
18
|
+
* invocation.
|
|
19
|
+
* - If only the legacy handler-function form is registered, calls it
|
|
20
|
+
* directly — preserves migration compatibility for callers that have
|
|
21
|
+
* not yet supplied full definitions. The narrow migration-compatible
|
|
22
|
+
* path skips the executor pipeline (no Zod validation, no idempotency,
|
|
23
|
+
* no audit log) so legacy callers keep working while Wave 3 rolls out.
|
|
24
|
+
* New callers should register `ActionDefinition` instances.
|
|
25
|
+
*
|
|
26
|
+
* @param registry - Map of `domainId → actionId → ActionDefinition | handler`.
|
|
27
|
+
* @param options - Domain identification and runtime options.
|
|
28
|
+
* @returns AWS Lambda handler for the action-class Lambda.
|
|
5
29
|
*/
|
|
6
30
|
export function createActionLambdaHandler(registry, options) {
|
|
7
31
|
return async (event) => {
|
|
8
32
|
const { input, envelope } = event;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
33
|
+
// ── Hydrate actor from structured envelope (with legacy fallback) ──
|
|
34
|
+
const actor = envelope.actor.sub
|
|
35
|
+
? envelope.actor
|
|
36
|
+
: (envelope.actorId
|
|
37
|
+
? { ...envelope.actor, sub: envelope.actorId }
|
|
38
|
+
: envelope.actor);
|
|
39
|
+
// `hydrateCtx(event, options)` — pass `undefined` for the unused event
|
|
40
|
+
// slot and the structured envelope-derived payload as the second arg.
|
|
41
|
+
// Passing the options as the first arg (the previous implementation)
|
|
42
|
+
// made `hydrateCtx` fall back to its empty-options defaults, so cross-
|
|
43
|
+
// domain Lambda handlers ended up with `tenant.id = 'unknown'` and a
|
|
44
|
+
// system actor — silently bypassing the tenant/auth checks in
|
|
45
|
+
// `executeAction`. The smoke test in
|
|
46
|
+
// `__tests__/auth-tenant-smoke.test.ts` pins this contract.
|
|
47
|
+
const ctx = await hydrateCtx(undefined, {
|
|
48
|
+
tenant: {
|
|
49
|
+
id: envelope.tenantId,
|
|
50
|
+
workspaceId: envelope.tenantId,
|
|
51
|
+
...(envelope.orgId !== undefined ? { orgId: envelope.orgId } : {}),
|
|
52
|
+
},
|
|
53
|
+
actor: {
|
|
54
|
+
type: actor.type,
|
|
55
|
+
...(actor.sub !== undefined ? { sub: actor.sub } : {}),
|
|
56
|
+
...(actor.email !== undefined ? { email: actor.email } : {}),
|
|
57
|
+
...(actor.roles !== undefined ? { roles: actor.roles } : {}),
|
|
58
|
+
...(actor.scopes !== undefined ? { scopes: actor.scopes } : {}),
|
|
59
|
+
tenantId: envelope.tenantId,
|
|
60
|
+
},
|
|
12
61
|
domainId: options.domainId,
|
|
13
62
|
traceId: envelope.traceId,
|
|
14
|
-
eventBusName: options.eventBusName,
|
|
15
|
-
databaseUrl: options.databaseUrl,
|
|
16
|
-
idempotencyTable: options.idempotencyTableName,
|
|
63
|
+
...(options.eventBusName !== undefined ? { eventBusName: options.eventBusName } : {}),
|
|
64
|
+
...(options.databaseUrl !== undefined ? { databaseUrl: options.databaseUrl } : {}),
|
|
65
|
+
...(options.idempotencyTableName !== undefined ? { idempotencyTable: options.idempotencyTableName } : {}),
|
|
17
66
|
actionRegistry: registry,
|
|
18
67
|
});
|
|
19
68
|
const domainActions = registry[options.domainId];
|
|
20
69
|
if (!domainActions) {
|
|
21
70
|
throw new Error(`No actions registered for domain: ${options.domainId}`);
|
|
22
71
|
}
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
72
|
+
const entry = domainActions[envelope.actionId];
|
|
73
|
+
if (!entry) {
|
|
25
74
|
throw new Error(`Action not found: ${options.domainId}.${envelope.actionId}`);
|
|
26
75
|
}
|
|
27
|
-
|
|
76
|
+
// ── Route through executeAction when a full definition is available ──
|
|
77
|
+
// `isActionDefinition` discriminates the new shape (definition object)
|
|
78
|
+
// from the legacy handler-function form. See `ActionRegistry` for the
|
|
79
|
+
// rationale and migration guidance.
|
|
80
|
+
if (isActionDefinition(entry)) {
|
|
81
|
+
const result = await executeAction(entry, input, ctx, { type: 'internal', callerDomain: envelope.callerDomain }, {
|
|
82
|
+
definingDomain: options.domainId,
|
|
83
|
+
traceId: envelope.traceId,
|
|
84
|
+
});
|
|
85
|
+
return encodeExecuteResult(result);
|
|
86
|
+
}
|
|
87
|
+
// ── Migration-compatible legacy path: call the handler directly ──
|
|
88
|
+
// When callers have not yet supplied a full ActionDefinition we still
|
|
89
|
+
// honour the invocation so the upgrade is non-breaking. This path skips
|
|
90
|
+
// the executor pipeline intentionally — older handler signatures cannot
|
|
91
|
+
// be expected to pass Zod schemas that may not exist. A WARN is logged
|
|
92
|
+
// so operators can spot callers that need to migrate.
|
|
93
|
+
ctx.logger.warn('Action invoked without ActionDefinition; legacy handler path used', {
|
|
94
|
+
domainId: options.domainId,
|
|
95
|
+
actionId: envelope.actionId,
|
|
96
|
+
callerDomain: envelope.callerDomain,
|
|
97
|
+
traceId: envelope.traceId,
|
|
98
|
+
});
|
|
99
|
+
return entry(input, ctx);
|
|
28
100
|
};
|
|
29
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Encodes an {@link ExecuteActionResult} as a Lambda-friendly return value.
|
|
104
|
+
* Successful results pass through the validated output; failed results
|
|
105
|
+
* surface the structured `TibError` code so callers can branch on it
|
|
106
|
+
* without parsing messages.
|
|
107
|
+
*/
|
|
108
|
+
function encodeExecuteResult(result) {
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
return result.value;
|
|
111
|
+
}
|
|
112
|
+
throw result.error;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Type-guard: is the registry entry a full `ActionDefinition`?
|
|
116
|
+
*
|
|
117
|
+
* Definition entries carry the `_kind: 'action'` discriminant produced by
|
|
118
|
+
* `defineAction`. Bare handler functions lack it. When `_kind` is missing
|
|
119
|
+
* we fall back to checking for `backendAccess` — `defineAction` requires
|
|
120
|
+
* this field on every definition, so its presence is a reliable marker
|
|
121
|
+
* even if `_kind` were stripped (e.g. by a non-runtime serializer).
|
|
122
|
+
*/
|
|
123
|
+
function isActionDefinition(entry) {
|
|
124
|
+
if (typeof entry === 'function')
|
|
125
|
+
return false;
|
|
126
|
+
if (entry._kind === 'action')
|
|
127
|
+
return true;
|
|
128
|
+
return typeof entry.backendAccess === 'string' && typeof entry.input !== 'undefined';
|
|
129
|
+
}
|
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
import type { ActionsProxy } from '../ctx/proxies.js';
|
|
2
|
+
import type { ActionInvocationEnvelope } from './action-handler.js';
|
|
3
|
+
import type { ActionDefinition } from '../primitives/action.js';
|
|
2
4
|
/**
|
|
3
5
|
* A registry of action handlers keyed by domain ID then action ID.
|
|
4
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Each value may be either:
|
|
8
|
+
* - a full {@link ActionDefinition} (preferred — produced by `defineAction`).
|
|
9
|
+
* Routes through `executeAction` so backendAccess, idempotency, audit,
|
|
10
|
+
* and output validation all apply.
|
|
11
|
+
* - a bare handler function `(input, ctx) => Promise<unknown>`. Kept for
|
|
12
|
+
* migration compatibility with callers that registered before Wave 3 of
|
|
13
|
+
* #4619. NOT routed through `executeAction` — receives `ctx` directly.
|
|
14
|
+
*
|
|
15
|
+
* Re-exports the discriminating types from `action-handler.ts` so consumers
|
|
16
|
+
* only need to import from this module to type their registries.
|
|
17
|
+
*/
|
|
18
|
+
export type ActionRegistry = Record<string, Record<string, ActionDefinition | ActionHandlerFn>>;
|
|
19
|
+
/**
|
|
20
|
+
* Legacy handler-function registration form. Re-exported so callers can
|
|
21
|
+
* type legacy entries explicitly.
|
|
22
|
+
*/
|
|
23
|
+
export type ActionHandlerFn = (input: unknown, ctx: any) => Promise<unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* Options for the ActionsProxy factory.
|
|
26
|
+
*
|
|
27
|
+
* Wave 7 Task 7.1 (#4619): `definingDomains` carries the canonical
|
|
28
|
+
* domain-id for every action registered in-process so `executeAction`
|
|
29
|
+
* can enforce `backendAccess: 'private'` against cross-domain callers.
|
|
30
|
+
* When omitted, each registered entry is treated as defined by the
|
|
31
|
+
* caller domain (the conservative default for tests/legacy callers —
|
|
32
|
+
* see `invokeInProcess` for the exact contract).
|
|
5
33
|
*/
|
|
6
|
-
export type ActionRegistry = Record<string, Record<string, (input: unknown, ctx: any) => Promise<unknown>>>;
|
|
7
|
-
/** Options for the ActionsProxy factory. */
|
|
8
34
|
export interface ActionsProxyOptions {
|
|
9
35
|
/**
|
|
10
36
|
* Registry of domain action handlers. Keys are domain IDs; values are
|
|
11
|
-
* maps of action ID to handler
|
|
37
|
+
* maps of action ID to `ActionDefinition` (preferred) or legacy handler
|
|
38
|
+
* function.
|
|
12
39
|
*/
|
|
13
40
|
registry: ActionRegistry;
|
|
14
41
|
/**
|
|
@@ -24,16 +51,49 @@ export interface ActionsProxyOptions {
|
|
|
24
51
|
* This domain's ID (used in the invocation envelope).
|
|
25
52
|
*/
|
|
26
53
|
callerDomainId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Optional override mapping `${domainId}.${actionId}` → defining domain
|
|
56
|
+
* id. When provided, the runtime can enforce `backendAccess: 'private'`
|
|
57
|
+
* against cross-domain in-process calls. When omitted, the default is
|
|
58
|
+
* "every registered action is owned by its own domain" — same-domain
|
|
59
|
+
* calls still succeed and cross-domain calls route through the
|
|
60
|
+
* executor's `isCallerAllowed` (which fails closed for `private`
|
|
61
|
+
* actions when defining domain metadata is missing).
|
|
62
|
+
*/
|
|
63
|
+
definingDomains?: Record<string, string>;
|
|
27
64
|
}
|
|
28
65
|
/**
|
|
29
66
|
* Creates an ActionsProxy from a pre-built action registry.
|
|
67
|
+
*
|
|
30
68
|
* Access pattern: `ctx.actions[domainId][actionId](input)`.
|
|
31
|
-
*
|
|
69
|
+
*
|
|
70
|
+
* - In-process: registered entries run inside this Lambda. Definitions
|
|
71
|
+
* are routed through `executeAction(..., { type: 'internal', callerDomain })`
|
|
72
|
+
* so the full auth/tenancy/backendAccess pipeline applies. Legacy handler
|
|
73
|
+
* functions are invoked directly.
|
|
74
|
+
* - Cross-domain: when a `lambdaArn` is provided for a domain not in the
|
|
75
|
+
* in-process registry, a Lambda `InvokeCommand` is dispatched with the
|
|
76
|
+
* structured {@link ActionInvocationEnvelope}. The receiving handler
|
|
77
|
+
* (`createActionLambdaHandler`) hydrates `ctx` from that envelope.
|
|
78
|
+
*
|
|
32
79
|
* @param options - ActionsProxyOptions with registry and ctx reference.
|
|
33
80
|
* @returns An ActionsProxy object conforming to the domain-runtime type.
|
|
34
81
|
* @throws At call time if the domain or action is not found in the registry.
|
|
35
82
|
*/
|
|
36
83
|
export declare function createActionsProxy(options: ActionsProxyOptions): ActionsProxy;
|
|
84
|
+
/**
|
|
85
|
+
* Builds a structured {@link ActionInvocationEnvelope} payload from the
|
|
86
|
+
* caller-side `ctx`. Always emits the new structured `actor` block;
|
|
87
|
+
* never the legacy flat `actorId` field (that field is consumed only on
|
|
88
|
+
* the receive side as a fallback).
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildEnvelope(opts: {
|
|
91
|
+
input: unknown;
|
|
92
|
+
ctx: any;
|
|
93
|
+
callerDomainId?: string;
|
|
94
|
+
targetDomain?: string;
|
|
95
|
+
actionId: string;
|
|
96
|
+
}): ActionInvocationEnvelope['envelope'];
|
|
37
97
|
/**
|
|
38
98
|
* Creates an empty ActionsProxy for unit tests or environments with no registered actions.
|
|
39
99
|
* @returns An empty ActionsProxy (no domains, no actions).
|
package/dist/runtime/actions.js
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
2
|
+
import { executeAction } from './action-executor.js';
|
|
2
3
|
/**
|
|
3
4
|
* Creates an ActionsProxy from a pre-built action registry.
|
|
5
|
+
*
|
|
4
6
|
* Access pattern: `ctx.actions[domainId][actionId](input)`.
|
|
5
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* - In-process: registered entries run inside this Lambda. Definitions
|
|
9
|
+
* are routed through `executeAction(..., { type: 'internal', callerDomain })`
|
|
10
|
+
* so the full auth/tenancy/backendAccess pipeline applies. Legacy handler
|
|
11
|
+
* functions are invoked directly.
|
|
12
|
+
* - Cross-domain: when a `lambdaArn` is provided for a domain not in the
|
|
13
|
+
* in-process registry, a Lambda `InvokeCommand` is dispatched with the
|
|
14
|
+
* structured {@link ActionInvocationEnvelope}. The receiving handler
|
|
15
|
+
* (`createActionLambdaHandler`) hydrates `ctx` from that envelope.
|
|
16
|
+
*
|
|
6
17
|
* @param options - ActionsProxyOptions with registry and ctx reference.
|
|
7
18
|
* @returns An ActionsProxy object conforming to the domain-runtime type.
|
|
8
19
|
* @throws At call time if the domain or action is not found in the registry.
|
|
9
20
|
*/
|
|
10
21
|
export function createActionsProxy(options) {
|
|
11
|
-
const { registry, ctx, lambdaArns = {}, callerDomainId } = options;
|
|
22
|
+
const { registry, ctx, lambdaArns = {}, callerDomainId, definingDomains = {} } = options;
|
|
12
23
|
const proxy = {};
|
|
13
|
-
// Build in-process proxy for domains in the registry
|
|
24
|
+
// Build in-process proxy for domains in the registry. We resolve the
|
|
25
|
+
// defining domain for each (domainId, actionId) entry up front so the
|
|
26
|
+
// in-process path can plumb it into `executeAction`'s meta. The default
|
|
27
|
+
// when no entry exists in `definingDomains` is the `domainId` of the
|
|
28
|
+
// registry key — i.e. "this entry is defined by the domain it lives
|
|
29
|
+
// under", which is the conservative same-domain default and the
|
|
30
|
+
// convention used by generated dedicated Lambda wrappers.
|
|
14
31
|
for (const [domainId, actions] of Object.entries(registry)) {
|
|
15
32
|
proxy[domainId] = {};
|
|
16
|
-
for (const [actionId,
|
|
17
|
-
|
|
33
|
+
for (const [actionId, entry] of Object.entries(actions)) {
|
|
34
|
+
const definingDomain = definingDomains[`${domainId}.${actionId}`] ?? domainId;
|
|
35
|
+
proxy[domainId][actionId] = (...args) => invokeInProcess(entry, args[0], ctx, callerDomainId, definingDomain);
|
|
18
36
|
}
|
|
19
37
|
}
|
|
20
38
|
// For cross-domain calls, create Lambda invoke proxies
|
|
@@ -30,14 +48,13 @@ export function createActionsProxy(options) {
|
|
|
30
48
|
return async (input) => {
|
|
31
49
|
const payload = {
|
|
32
50
|
input,
|
|
33
|
-
envelope: {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
51
|
+
envelope: buildEnvelope({
|
|
52
|
+
input,
|
|
53
|
+
ctx,
|
|
54
|
+
callerDomainId,
|
|
55
|
+
targetDomain: domainId,
|
|
56
|
+
actionId: String(actionId),
|
|
57
|
+
}),
|
|
41
58
|
};
|
|
42
59
|
const response = await lambdaClient.send(new InvokeCommand({
|
|
43
60
|
FunctionName: arn,
|
|
@@ -55,6 +72,94 @@ export function createActionsProxy(options) {
|
|
|
55
72
|
}
|
|
56
73
|
return proxy;
|
|
57
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Builds a structured {@link ActionInvocationEnvelope} payload from the
|
|
77
|
+
* caller-side `ctx`. Always emits the new structured `actor` block;
|
|
78
|
+
* never the legacy flat `actorId` field (that field is consumed only on
|
|
79
|
+
* the receive side as a fallback).
|
|
80
|
+
*/
|
|
81
|
+
export function buildEnvelope(opts) {
|
|
82
|
+
const actorEnvelope = toActorEnvelope(opts.ctx?.actor);
|
|
83
|
+
const envelope = {
|
|
84
|
+
tenantId: opts.ctx?.tenant?.id ?? 'unknown',
|
|
85
|
+
actor: actorEnvelope,
|
|
86
|
+
callerDomain: opts.callerDomainId ?? 'unknown',
|
|
87
|
+
actionId: opts.actionId,
|
|
88
|
+
traceId: opts.ctx?.traceId ?? 'unknown',
|
|
89
|
+
};
|
|
90
|
+
if (opts.ctx?.tenant?.orgId !== undefined) {
|
|
91
|
+
envelope.orgId = opts.ctx.tenant.orgId;
|
|
92
|
+
}
|
|
93
|
+
if (opts.targetDomain !== undefined) {
|
|
94
|
+
envelope.targetDomain = opts.targetDomain;
|
|
95
|
+
}
|
|
96
|
+
return envelope;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Project the runtime `Actor` shape onto the wire-format
|
|
100
|
+
* {@link ActionActorEnvelope}. Fields that are not present on the source
|
|
101
|
+
* `Actor` are omitted entirely (not coerced to `undefined`) so the JSON
|
|
102
|
+
* payload stays compact.
|
|
103
|
+
*/
|
|
104
|
+
function toActorEnvelope(actor) {
|
|
105
|
+
if (!actor) {
|
|
106
|
+
return { type: 'system' };
|
|
107
|
+
}
|
|
108
|
+
const env = { type: actor.type };
|
|
109
|
+
if (actor.sub !== undefined)
|
|
110
|
+
env.sub = actor.sub;
|
|
111
|
+
if (actor.email !== undefined)
|
|
112
|
+
env.email = actor.email;
|
|
113
|
+
if (actor.roles !== undefined)
|
|
114
|
+
env.roles = actor.roles;
|
|
115
|
+
if (actor.scopes !== undefined)
|
|
116
|
+
env.scopes = actor.scopes;
|
|
117
|
+
return env;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Invoke a registry entry in-process.
|
|
121
|
+
*
|
|
122
|
+
* - If the entry is an `ActionDefinition`, route through `executeAction`
|
|
123
|
+
* with an `InternalActionSource` so backendAccess enforcement, input
|
|
124
|
+
* validation, idempotency, audit, and output validation all apply.
|
|
125
|
+
* - Otherwise treat it as a legacy handler function and invoke directly.
|
|
126
|
+
* A WARN is logged so operators can spot legacy registrations.
|
|
127
|
+
*
|
|
128
|
+
* `definingDomain` is the domain that owns the registered action. It is
|
|
129
|
+
* forwarded to `executeAction`'s `meta` so `isCallerAllowed` can enforce
|
|
130
|
+
* `backendAccess: 'private'` against cross-domain in-process callers.
|
|
131
|
+
*/
|
|
132
|
+
async function invokeInProcess(entry, input,
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
134
|
+
ctx, callerDomainId, definingDomain) {
|
|
135
|
+
if (isActionDefinition(entry)) {
|
|
136
|
+
const result = await executeAction(entry, input, ctx, { type: 'internal', callerDomain: callerDomainId }, { definingDomain, traceId: ctx?.traceId });
|
|
137
|
+
if (!result.ok)
|
|
138
|
+
throw result.error;
|
|
139
|
+
return result.value;
|
|
140
|
+
}
|
|
141
|
+
// Legacy handler-function path
|
|
142
|
+
if (ctx?.logger?.warn) {
|
|
143
|
+
ctx.logger.warn('Action invoked without ActionDefinition; legacy handler path used', {
|
|
144
|
+
callerDomain: callerDomainId,
|
|
145
|
+
definingDomain,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return entry(input, ctx);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Type-guard mirroring the one in `action-handler.ts`. Defined locally so
|
|
152
|
+
* this module does not need to depend on the handler implementation file.
|
|
153
|
+
*/
|
|
154
|
+
function isActionDefinition(entry) {
|
|
155
|
+
if (typeof entry !== 'function') {
|
|
156
|
+
const candidate = entry;
|
|
157
|
+
if (candidate._kind === 'action')
|
|
158
|
+
return true;
|
|
159
|
+
return typeof candidate.backendAccess === 'string' && typeof candidate.input !== 'undefined';
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
58
163
|
/**
|
|
59
164
|
* Creates an empty ActionsProxy for unit tests or environments with no registered actions.
|
|
60
165
|
* @returns An empty ActionsProxy (no domains, no actions).
|
package/dist/runtime/audit.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PublishFn } from '../ctx/comms.js';
|
|
2
|
+
import type { ObservabilityBindings } from './observability-bindings.js';
|
|
2
3
|
export interface AuditEntry {
|
|
3
4
|
action: string;
|
|
4
5
|
target: string;
|
|
@@ -8,9 +9,20 @@ export interface AuditContext {
|
|
|
8
9
|
log(action: string, target: string, metadata?: Record<string, unknown>): Promise<void>;
|
|
9
10
|
}
|
|
10
11
|
/**
|
|
11
|
-
* Creates an audit context that auto-injects
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Creates an audit context that auto-injects the canonical
|
|
13
|
+
* observability bindings (#4662 Task D — tenant/actor/action identity)
|
|
14
|
+
* alongside the legacy flat fields, then publishes a
|
|
15
|
+
* `{domain}.audit.event` to EventBridge.
|
|
16
|
+
*
|
|
17
|
+
* SOC2 / forensics baseline — per-domain hand-rolling is forbidden by
|
|
18
|
+
* the ESLint rule `no-raw-audit-publish`. New callers should consume
|
|
19
|
+
* `ctx.audit.log(...)` so the bindings stay consistent across
|
|
20
|
+
* domains.
|
|
21
|
+
*
|
|
22
|
+
* The bindings object is shallow-merged into the audit envelope so
|
|
23
|
+
* downstream consumers (CloudWatch Logs Insights, the audit replay
|
|
24
|
+
* pipeline, the SOC2 report generator) can rely on a stable field
|
|
25
|
+
* shape regardless of which domain emitted the event.
|
|
14
26
|
*/
|
|
15
27
|
export declare function createAudit(opts: {
|
|
16
28
|
domainId: string;
|
|
@@ -18,4 +30,12 @@ export declare function createAudit(opts: {
|
|
|
18
30
|
actorId: string | undefined;
|
|
19
31
|
traceId: string;
|
|
20
32
|
publish: PublishFn;
|
|
33
|
+
/**
|
|
34
|
+
* Optional canonical observability bindings. When supplied the audit
|
|
35
|
+
* envelope carries the full structured shape (`actorSub`, `orgId`,
|
|
36
|
+
* `callerDomain`, etc.) in addition to the legacy flat fields so
|
|
37
|
+
* older consumers keep working while new consumers can filter by
|
|
38
|
+
* any of the canonical fields.
|
|
39
|
+
*/
|
|
40
|
+
bindings?: ObservabilityBindings;
|
|
21
41
|
}): AuditContext;
|
package/dist/runtime/audit.js
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Creates an audit context that auto-injects
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Creates an audit context that auto-injects the canonical
|
|
3
|
+
* observability bindings (#4662 Task D — tenant/actor/action identity)
|
|
4
|
+
* alongside the legacy flat fields, then publishes a
|
|
5
|
+
* `{domain}.audit.event` to EventBridge.
|
|
6
|
+
*
|
|
7
|
+
* SOC2 / forensics baseline — per-domain hand-rolling is forbidden by
|
|
8
|
+
* the ESLint rule `no-raw-audit-publish`. New callers should consume
|
|
9
|
+
* `ctx.audit.log(...)` so the bindings stay consistent across
|
|
10
|
+
* domains.
|
|
11
|
+
*
|
|
12
|
+
* The bindings object is shallow-merged into the audit envelope so
|
|
13
|
+
* downstream consumers (CloudWatch Logs Insights, the audit replay
|
|
14
|
+
* pipeline, the SOC2 report generator) can rely on a stable field
|
|
15
|
+
* shape regardless of which domain emitted the event.
|
|
5
16
|
*/
|
|
6
17
|
export function createAudit(opts) {
|
|
18
|
+
const bindings = opts.bindings ?? {};
|
|
7
19
|
return {
|
|
8
20
|
async log(action, target, metadata) {
|
|
9
21
|
await opts.publish(`${opts.domainId}.audit.event`, {
|
|
10
22
|
action,
|
|
11
23
|
target,
|
|
12
24
|
metadata: metadata ?? {},
|
|
25
|
+
// Legacy flat fields (kept stable for downstream consumers).
|
|
13
26
|
tenantId: opts.tenantId,
|
|
14
27
|
actorId: opts.actorId ?? 'system',
|
|
15
28
|
traceId: opts.traceId,
|
|
29
|
+
// New structured bindings (#4662 Task D). Spread AFTER the flat
|
|
30
|
+
// fields so an empty bindings object doesn't overwrite the
|
|
31
|
+
// legacy keys with `undefined`.
|
|
32
|
+
...bindings,
|
|
16
33
|
timestamp: new Date().toISOString(),
|
|
17
34
|
});
|
|
18
35
|
},
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface DbOptions {
|
|
|
16
16
|
* Optional; only set if present.
|
|
17
17
|
*/
|
|
18
18
|
orgId?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Actor subject (`Actor.sub`) to bind as the PostgreSQL session variable
|
|
21
|
+
* `app.actor_id`. Optional; only set if present. Useful for DB-side audit
|
|
22
|
+
* triggers and `current_setting('app.actor_id', true)` reads.
|
|
23
|
+
*/
|
|
24
|
+
actorId?: string;
|
|
19
25
|
/**
|
|
20
26
|
* Optional schema name passed to drizzle. Defaults to undefined (uses search_path).
|
|
21
27
|
*/
|
|
@@ -24,10 +30,23 @@ export interface DbOptions {
|
|
|
24
30
|
/**
|
|
25
31
|
* Create a DrizzleORM postgres DbContext bound to a tenant-scoped client.
|
|
26
32
|
*
|
|
27
|
-
* The implementation checks out a single client from the pool and
|
|
28
|
-
*
|
|
29
|
-
* query made through this DbContext.
|
|
30
|
-
* after the handler completes to return
|
|
33
|
+
* The implementation checks out a single client from the pool and binds the
|
|
34
|
+
* per-request identity onto the PostgreSQL session via `SELECT set_config(...)`.
|
|
35
|
+
* The same client is reused for every query made through this DbContext.
|
|
36
|
+
* The caller must invoke `release()` after the handler completes to return
|
|
37
|
+
* the client to the pool.
|
|
38
|
+
*
|
|
39
|
+
* Why `SELECT set_config(..., is_local := false)` rather than `SET`?
|
|
40
|
+
*
|
|
41
|
+
* - `SET app.current_tenant = $1` only accepts a literal value and cannot be
|
|
42
|
+
* parameterised via the extended query protocol, which means it cannot be
|
|
43
|
+
* used in a prepared-statement-aware path or with binary parameters.
|
|
44
|
+
* - `set_config('app.current_tenant', $1, false)` runs as a regular parameterised
|
|
45
|
+
* statement, works in the prepared-statement pipeline, and — with `is_local := false` —
|
|
46
|
+
* binds the value for the rest of the **session** (i.e. the checked-out client).
|
|
47
|
+
* - `is_local := true` would scope the setting to the current transaction only
|
|
48
|
+
* and the value would not survive subsequent queries on the same client,
|
|
49
|
+
* which is exactly the failure mode we want to avoid in long-running handlers.
|
|
31
50
|
*
|
|
32
51
|
* Connection resolution order:
|
|
33
52
|
* 1. `options.connectionString`
|
package/dist/runtime/db.js
CHANGED
|
@@ -28,10 +28,23 @@ async function connectionStringFromSecret(secretArn) {
|
|
|
28
28
|
/**
|
|
29
29
|
* Create a DrizzleORM postgres DbContext bound to a tenant-scoped client.
|
|
30
30
|
*
|
|
31
|
-
* The implementation checks out a single client from the pool and
|
|
32
|
-
*
|
|
33
|
-
* query made through this DbContext.
|
|
34
|
-
* after the handler completes to return
|
|
31
|
+
* The implementation checks out a single client from the pool and binds the
|
|
32
|
+
* per-request identity onto the PostgreSQL session via `SELECT set_config(...)`.
|
|
33
|
+
* The same client is reused for every query made through this DbContext.
|
|
34
|
+
* The caller must invoke `release()` after the handler completes to return
|
|
35
|
+
* the client to the pool.
|
|
36
|
+
*
|
|
37
|
+
* Why `SELECT set_config(..., is_local := false)` rather than `SET`?
|
|
38
|
+
*
|
|
39
|
+
* - `SET app.current_tenant = $1` only accepts a literal value and cannot be
|
|
40
|
+
* parameterised via the extended query protocol, which means it cannot be
|
|
41
|
+
* used in a prepared-statement-aware path or with binary parameters.
|
|
42
|
+
* - `set_config('app.current_tenant', $1, false)` runs as a regular parameterised
|
|
43
|
+
* statement, works in the prepared-statement pipeline, and — with `is_local := false` —
|
|
44
|
+
* binds the value for the rest of the **session** (i.e. the checked-out client).
|
|
45
|
+
* - `is_local := true` would scope the setting to the current transaction only
|
|
46
|
+
* and the value would not survive subsequent queries on the same client,
|
|
47
|
+
* which is exactly the failure mode we want to avoid in long-running handlers.
|
|
35
48
|
*
|
|
36
49
|
* Connection resolution order:
|
|
37
50
|
* 1. `options.connectionString`
|
|
@@ -63,9 +76,17 @@ export async function createDb(options) {
|
|
|
63
76
|
const pool = new Pool({ connectionString });
|
|
64
77
|
const client = await pool.connect();
|
|
65
78
|
try {
|
|
66
|
-
|
|
79
|
+
// Bind session-scoped identity onto the checked-out client.
|
|
80
|
+
// `false` keeps the setting for the lifetime of the session (the client).
|
|
81
|
+
// Using `SELECT set_config(...)` instead of `SET` lets us pass the value
|
|
82
|
+
// as a parameterised query argument, which keeps the prepared-statement
|
|
83
|
+
// path hot and protects against accidental string interpolation.
|
|
84
|
+
await client.query("SELECT set_config('app.current_tenant', $1, false)", [options.tenantId]);
|
|
67
85
|
if (options.orgId) {
|
|
68
|
-
await client.query('
|
|
86
|
+
await client.query("SELECT set_config('app.org_id', $1, false)", [options.orgId]);
|
|
87
|
+
}
|
|
88
|
+
if (options.actorId) {
|
|
89
|
+
await client.query("SELECT set_config('app.actor_id', $1, false)", [options.actorId]);
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
catch (err) {
|
|
@@ -9,16 +9,79 @@ export interface StandardError {
|
|
|
9
9
|
code: string;
|
|
10
10
|
}>;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Stable error codes that domain handlers can throw via `TibError`. The
|
|
14
|
+
* exposed-action API adapter (`createExposedActionApiHandler`) maps each
|
|
15
|
+
* of these to a fixed HTTP status — adding a new code without updating
|
|
16
|
+
* the map will fall through to 500.
|
|
17
|
+
*
|
|
18
|
+
* The set is intentionally small: every code here resolves to exactly one
|
|
19
|
+
* HTTP status, so the contract between runtime and frontend is one-to-one.
|
|
20
|
+
*/
|
|
21
|
+
export declare const TIB_ERROR_CODES: readonly ["VALIDATION_ERROR", "AUTH_REQUIRED", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "GONE", "UNPROCESSABLE_ENTITY", "INTERNAL_ERROR", "TENANT_REQUIRED", "TENANT_MISMATCH", "NOT_EXPOSED", "OUTPUT_VALIDATION_ERROR"];
|
|
22
|
+
/**
|
|
23
|
+
* Canonical TypeScript union of {@link TIB_ERROR_CODES}. Use as the
|
|
24
|
+
* `code` argument type when building a `TibError` so misspelled codes
|
|
25
|
+
* fail at compile time instead of silently falling through to 500.
|
|
26
|
+
*/
|
|
27
|
+
export type TibErrorCode = (typeof TIB_ERROR_CODES)[number];
|
|
12
28
|
/**
|
|
13
29
|
* Formats any caught error into the standard TIB error envelope.
|
|
14
30
|
* Contract locked with frontend scaffolder (#1975).
|
|
15
31
|
*/
|
|
16
32
|
export declare function formatError(err: unknown, traceId: string, logger?: Logger): StandardError;
|
|
33
|
+
/**
|
|
34
|
+
* Domain error thrown by action handlers and adapter internals. Carries a
|
|
35
|
+
* stable `code` so adapters can render it to a deterministic HTTP status
|
|
36
|
+
* without parsing the message.
|
|
37
|
+
*
|
|
38
|
+
* Handlers should construct one via `domainError(...)` instead of the raw
|
|
39
|
+
* constructor so the compiler enforces the allowed-code union.
|
|
40
|
+
*
|
|
41
|
+
* The previous action-handler pattern — `throw Object.assign(new Error(),
|
|
42
|
+
* { status: 404 })` — survives end-to-end as long as the throwing code
|
|
43
|
+
* passes through `executeAction`, which translates `TibError` into an
|
|
44
|
+
* `ExecuteActionResult` carrying the same code. See
|
|
45
|
+
* `runtime/action-executor.ts` for the conversion point.
|
|
46
|
+
*/
|
|
17
47
|
export declare class TibError extends Error {
|
|
18
48
|
readonly code: string;
|
|
49
|
+
readonly status: number | undefined;
|
|
19
50
|
constructor(message: string, code: string);
|
|
20
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Optional `status` carried by handlers that still author their own
|
|
54
|
+
* HTTP-style numeric code. Read by the adapter's outer try/catch as a
|
|
55
|
+
* fallback when the thrown error is not a `TibError` — kept for the
|
|
56
|
+
* migration window only.
|
|
57
|
+
*
|
|
58
|
+
* @deprecated Use {@link domainError} with a `TibErrorCode` instead.
|
|
59
|
+
*/
|
|
60
|
+
export interface HttpStatusCarrier {
|
|
61
|
+
status?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a {@link TibError} with a stable, typed code. Domain handlers
|
|
65
|
+
* import this instead of `new TibError(...)` so the compiler refuses
|
|
66
|
+
* unknown codes.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* throw domainError('User not found', 'NOT_FOUND');
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function domainError(message: string, code: TibErrorCode): TibError;
|
|
21
73
|
/**
|
|
22
74
|
* Extracts a trace ID from X-Ray header or X-Request-Id, or generates a fallback.
|
|
23
75
|
*/
|
|
24
76
|
export declare function extractTraceId(headers: Record<string, string | undefined>): string;
|
|
77
|
+
/**
|
|
78
|
+
* Best-effort detector for an error object that carries a numeric
|
|
79
|
+
* `status` property (the legacy pattern that pre-dates {@link TibError}).
|
|
80
|
+
*
|
|
81
|
+
* Used by the exposed-action API adapter's outer try/catch to render a
|
|
82
|
+
* domain-meaningful HTTP status for callers that have not migrated to
|
|
83
|
+
* {@link domainError} yet. Once all built-in handlers are migrated this
|
|
84
|
+
* function becomes dead code; until then it acts as the migration safety
|
|
85
|
+
* net so a runtime upgrade does not regress behaviour.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readLegacyHttpStatus(err: unknown): number | undefined;
|