@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
@@ -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
- * Receives cross-domain invocations via Lambda invoke and runs the action handler.
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
- const ctx = await hydrateCtx({
10
- tenant: { id: envelope.tenantId, workspaceId: envelope.tenantId, orgId: envelope.orgId },
11
- actor: { type: 'action', sub: envelope.actorId, tenantId: envelope.tenantId },
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 handler = domainActions[envelope.actionId];
24
- if (!handler) {
72
+ const entry = domainActions[envelope.actionId];
73
+ if (!entry) {
25
74
  throw new Error(`Action not found: ${options.domainId}.${envelope.actionId}`);
26
75
  }
27
- return handler(input, ctx);
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
- * Each handler receives `(input, ctx)` — ctx is injected by the caller.
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 functions.
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
- * V1 is in-process only. V2 adds Lambda invoke for cross-domain calls.
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).
@@ -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
- * V1 is in-process only. V2 adds Lambda invoke for cross-domain calls.
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, handler] of Object.entries(actions)) {
17
- proxy[domainId][actionId] = (...args) => handler(args[0], ctx);
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
- tenantId: ctx.tenant?.id ?? 'unknown',
35
- orgId: ctx.tenant?.orgId,
36
- actorId: ctx.actor?.sub ?? 'system',
37
- traceId: ctx.traceId ?? 'unknown',
38
- callerDomain: callerDomainId ?? 'unknown',
39
- actionId,
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).
@@ -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 tenantId, actorId, traceId, timestamp
12
- * and publishes a {domain}.audit.event to EventBridge.
13
- * SOC2 / forensics baseline per-domain hand-rolling forbidden by ESLint rule.
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;
@@ -1,18 +1,35 @@
1
1
  /**
2
- * Creates an audit context that auto-injects tenantId, actorId, traceId, timestamp
3
- * and publishes a {domain}.audit.event to EventBridge.
4
- * SOC2 / forensics baseline per-domain hand-rolling forbidden by ESLint rule.
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
  },
@@ -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 runs
28
- * `SET app.current_tenant = $1` on it. The same client is reused for every
29
- * query made through this DbContext. The caller must invoke `release()`
30
- * after the handler completes to return the client to the pool.
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`
@@ -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 runs
32
- * `SET app.current_tenant = $1` on it. The same client is reused for every
33
- * query made through this DbContext. The caller must invoke `release()`
34
- * after the handler completes to return the client to the pool.
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
- await client.query('SET app.current_tenant = $1', [options.tenantId]);
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('SET app.org_id = $1', [options.orgId]);
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;