@mettlecast/domain-runtime 0.2.59 → 0.2.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +57 -40
  2. package/dist/primitives/action.d.ts +80 -8
  3. package/dist/primitives/action.js +71 -4
  4. package/dist/primitives/index.d.ts +0 -1
  5. package/dist/primitives/index.js +0 -1
  6. package/dist/runtime/action-executor.d.ts +164 -0
  7. package/dist/runtime/action-executor.js +408 -0
  8. package/dist/runtime/action-handler.d.ts +110 -6
  9. package/dist/runtime/action-handler.js +110 -10
  10. package/dist/runtime/actions.d.ts +65 -5
  11. package/dist/runtime/actions.js +118 -13
  12. package/dist/runtime/audit.d.ts +23 -3
  13. package/dist/runtime/audit.js +20 -3
  14. package/dist/runtime/db.d.ts +23 -4
  15. package/dist/runtime/db.js +27 -6
  16. package/dist/runtime/error-formatter.d.ts +63 -0
  17. package/dist/runtime/error-formatter.js +68 -1
  18. package/dist/runtime/exposed-action-api-handler.d.ts +87 -0
  19. package/dist/runtime/exposed-action-api-handler.js +287 -0
  20. package/dist/runtime/extractors.d.ts +12 -0
  21. package/dist/runtime/extractors.js +21 -13
  22. package/dist/runtime/files.d.ts +8 -0
  23. package/dist/runtime/files.js +76 -8
  24. package/dist/runtime/hydrate.js +17 -2
  25. package/dist/runtime/index.d.ts +10 -2
  26. package/dist/runtime/index.js +18 -1
  27. package/dist/runtime/observability-bindings.d.ts +110 -0
  28. package/dist/runtime/observability-bindings.js +94 -0
  29. package/dist/runtime/store.d.ts +8 -0
  30. package/dist/runtime/store.js +29 -0
  31. package/dist/runtime/tracer.d.ts +1 -1
  32. package/dist/runtime/tracer.js +1 -1
  33. package/dist/schema/index.d.ts +1 -1
  34. package/dist/schema/index.js +1 -1
  35. package/dist/schema/rls.d.ts +31 -0
  36. package/dist/schema/rls.js +44 -0
  37. package/dist/types/tenant.d.ts +12 -0
  38. package/package.json +1 -1
  39. package/dist/primitives/api.d.ts +0 -44
  40. package/dist/primitives/api.js +0 -70
  41. package/dist/runtime/api-handler.d.ts +0 -29
  42. package/dist/runtime/api-handler.js +0 -212
@@ -1,16 +1,72 @@
1
1
  import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
2
2
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
3
  import { getTenantCredentials } from './tenant-credentials.js';
4
+ /**
5
+ * Reject missing, empty, or 'unknown' tenant IDs at construction time.
6
+ *
7
+ * Mirrors the same guard in `createStore`: the runtime must fail loudly when
8
+ * a wrapper is instantiated with an unsafe tenant rather than silently
9
+ * writing/reading under a shared prefix named "unknown".
10
+ */
11
+ function assertRuntimeTenantId(tenantId) {
12
+ if (typeof tenantId !== 'string' || tenantId.length === 0 || tenantId === 'unknown') {
13
+ throw new Error(`createFiles: a valid runtime tenantId is required (got ${JSON.stringify(tenantId)})`);
14
+ }
15
+ }
16
+ /**
17
+ * Normalise an S3 key and reject any input that would let a caller escape the
18
+ * tenant prefix or reach outside the bucket's logical root.
19
+ *
20
+ * S3 treats keys as opaque flat strings, so `..` and `.` segments are NOT
21
+ * resolved by the service — but the runtime contract is that keys are relative
22
+ * to the tenant's namespace and that path-traversal-style inputs are a
23
+ * developer error (and a likely injection vector if any layer ever resolves
24
+ * them). Reject early.
25
+ *
26
+ * Rules:
27
+ * - Must be a non-empty string.
28
+ * - Must not start with `/` (absolute paths are meaningless inside a bucket).
29
+ * - Must not equal `.` or `..`.
30
+ * - Must not contain any segment equal to `.` or `..` after splitting on `/`.
31
+ */
32
+ function normalizeAndValidateKey(rawKey, op) {
33
+ if (typeof rawKey !== 'string' || rawKey.length === 0) {
34
+ throw new Error(`${op}: key must be a non-empty string (got ${typeof rawKey === 'string' ? JSON.stringify(rawKey) : typeof rawKey})`);
35
+ }
36
+ if (rawKey.startsWith('/')) {
37
+ throw new Error(`${op}: key must not start with '/': ${JSON.stringify(rawKey)}`);
38
+ }
39
+ if (rawKey === '.' || rawKey === '..') {
40
+ throw new Error(`${op}: key must not be '.' or '..': ${JSON.stringify(rawKey)}`);
41
+ }
42
+ for (const seg of rawKey.split('/')) {
43
+ if (seg === '.' || seg === '..') {
44
+ throw new Error(`${op}: key contains path-traversal segment '${seg}': ${JSON.stringify(rawKey)}`);
45
+ }
46
+ }
47
+ return rawKey;
48
+ }
49
+ /**
50
+ * Tenant-scoped S3 wrapper. All keys are automatically prefixed with
51
+ * `{tenantId}/` — developers supply only the path after the tenant prefix.
52
+ *
53
+ * Path-traversal patterns (`../`, `./`, leading `/`, empty keys) are rejected
54
+ * before the tenant prefix is applied, so every object operation is scoped to
55
+ * the runtime tenant regardless of caller input.
56
+ */
4
57
  export function createFiles(opts) {
58
+ assertRuntimeTenantId(opts.tenantId);
5
59
  const client = new S3Client({
6
60
  credentials: () => getTenantCredentials(opts.tenantRoleArn, opts.tenantId),
7
61
  });
8
62
  const { bucketName, tenantId } = opts;
9
- const prefix = (key) => `${tenantId}/${key}`;
63
+ const tenantPrefix = `${tenantId}/`;
64
+ const prefix = (key) => `${tenantPrefix}${key}`;
10
65
  return {
11
66
  async get(key) {
67
+ const safeKey = normalizeAndValidateKey(key, 'files.get');
12
68
  try {
13
- const res = await client.send(new GetObjectCommand({ Bucket: bucketName, Key: prefix(key) }));
69
+ const res = await client.send(new GetObjectCommand({ Bucket: bucketName, Key: prefix(safeKey) }));
14
70
  const chunks = [];
15
71
  for await (const chunk of res.Body) {
16
72
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -24,28 +80,40 @@ export function createFiles(opts) {
24
80
  }
25
81
  },
26
82
  async put(key, body, putOpts) {
83
+ const safeKey = normalizeAndValidateKey(key, 'files.put');
27
84
  await client.send(new PutObjectCommand({
28
85
  Bucket: bucketName,
29
- Key: prefix(key),
86
+ Key: prefix(safeKey),
30
87
  Body: body,
31
88
  ContentType: putOpts?.contentType,
32
89
  Metadata: putOpts?.metadata,
33
90
  }));
34
91
  },
35
92
  async delete(key) {
36
- await client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: prefix(key) }));
93
+ const safeKey = normalizeAndValidateKey(key, 'files.delete');
94
+ await client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: prefix(safeKey) }));
37
95
  },
38
96
  async list(listPrefix) {
39
- const fullPrefix = listPrefix ? `${tenantId}/${listPrefix}` : `${tenantId}/`;
97
+ // listPrefix is optional. When provided it must be a tenant-relative
98
+ // path under the same rules; when omitted, we list everything in the
99
+ // tenant's namespace.
100
+ let fullPrefix;
101
+ if (listPrefix === undefined || listPrefix === '') {
102
+ fullPrefix = tenantPrefix;
103
+ }
104
+ else {
105
+ const safePrefix = normalizeAndValidateKey(listPrefix, 'files.list');
106
+ fullPrefix = prefix(safePrefix);
107
+ }
40
108
  const res = await client.send(new ListObjectsV2Command({ Bucket: bucketName, Prefix: fullPrefix }));
41
- const tenantPrefixLen = `${tenantId}/`.length;
42
109
  return {
43
- keys: (res.Contents ?? []).map(o => (o.Key ?? '').slice(tenantPrefixLen)),
110
+ keys: (res.Contents ?? []).map(o => (o.Key ?? '').slice(tenantPrefix.length)),
44
111
  cursor: res.NextContinuationToken,
45
112
  };
46
113
  },
47
114
  async getSignedUrl(key, expiresIn = 3600) {
48
- const cmd = new GetObjectCommand({ Bucket: bucketName, Key: prefix(key) });
115
+ const safeKey = normalizeAndValidateKey(key, 'files.getSignedUrl');
116
+ const cmd = new GetObjectCommand({ Bucket: bucketName, Key: prefix(safeKey) });
49
117
  return getSignedUrl(client, cmd, { expiresIn });
50
118
  },
51
119
  };
@@ -12,6 +12,7 @@ import { createFlowsProxy } from './flows.js';
12
12
  import { createIntegrationsProxy, createEmptyIntegrationsProxy } from './integrations.js';
13
13
  import { createFeatureFlags } from './feature-flags.js';
14
14
  import { createAudit } from './audit.js';
15
+ import { extractObservabilityBindings } from './observability-bindings.js';
15
16
  import { createStore, createNoopStore } from './store.js';
16
17
  import { createFiles, createNoopFiles } from './files.js';
17
18
  /**
@@ -40,9 +41,14 @@ export async function hydrateCtx(_event, options = {}) {
40
41
  const lambdaName = options.lambdaName ?? process.env['AWS_LAMBDA_FUNCTION_NAME'] ?? 'unknown';
41
42
  const actor = options.actor ?? { type: 'system' };
42
43
  const tenant = options.tenant ?? { id: 'unknown', workspaceId: 'unknown' };
44
+ // Issue #4662 Task D — every log line carries the canonical
45
+ // observability bindings (tenantId, orgId, actorSub, actorRoles,
46
+ // traceId, …) as Powertools persistent attributes so CloudWatch
47
+ // Logs Insights filters can reliably match by tenant without
48
+ // checking for both the key and the literal string 'unknown'.
49
+ const observabilityBindings = extractObservabilityBindings({ tenant, actor, traceId: options.traceId }, { definingDomain: options.domainId });
43
50
  const logger = options.logger ?? createLogger({
44
- tenantId: tenant.id,
45
- workspaceId: tenant.workspaceId,
51
+ ...observabilityBindings,
46
52
  lambda: lambdaName,
47
53
  });
48
54
  const tracer = options.tracer ?? createTracer(lambdaName);
@@ -81,6 +87,11 @@ export async function hydrateCtx(_event, options = {}) {
81
87
  connectionString: options.databaseUrl,
82
88
  tenantId: tenant.id,
83
89
  orgId: tenant.orgId,
90
+ // Bind the authenticated actor's subject so DB-side triggers and audit
91
+ // helpers can read `current_setting('app.actor_id', true)`. Falls back
92
+ // to undefined for system/schedule actors that don't carry a sub claim;
93
+ // createDb skips the set_config call when actorId is absent.
94
+ actorId: actor.sub,
84
95
  });
85
96
  // TODO: Wire proper teardown to call await db.release() after handler completes.
86
97
  // For now, callers must manually call await ctx.db.release() in a try/finally block.
@@ -145,6 +156,10 @@ export async function hydrateCtx(_event, options = {}) {
145
156
  actorId: actor?.sub,
146
157
  traceId: options.traceId ?? 'unknown',
147
158
  publish,
159
+ // Issue #4662 Task D — emit the canonical observability bindings
160
+ // alongside the audit envelope so SOC2 / forensics consumers can
161
+ // filter by any of the canonical fields (tenant, actor, action).
162
+ bindings: extractObservabilityBindings({ tenant, actor, traceId: options.traceId }, { definingDomain: options.domainId }),
148
163
  });
149
164
  const store = options.store ?? ((options.domainTableName ?? process.env['DOMAIN_TABLE_NAME'])
150
165
  ? createStore({ tableName: options.domainTableName ?? process.env['DOMAIN_TABLE_NAME'], tenantId: tenant.id, tenantRoleArn: process.env['DOMAIN_TENANT_ROLE_ARN'] })
@@ -1,6 +1,8 @@
1
1
  export { createLogger } from './logger.js';
2
2
  export { createTracer, noopTracer, initOtel } from './tracer.js';
3
3
  export { createCache } from './cache.js';
4
+ export { extractObservabilityBindings, requireObservabilityBindings, } from './observability-bindings.js';
5
+ export type { ObservabilityBindings, ExtractBindingsOptions, } from './observability-bindings.js';
4
6
  /**
5
7
  * @deprecated Use `createKyClient` instead. The `createFetch` name is kept
6
8
  * as a backward-compatible alias for callers that imported it before the
@@ -15,6 +17,8 @@ export { ok, err, notFound } from '../types/result.js';
15
17
  export type { AppError, Result } from '../types/result.js';
16
18
  export { safeFetch, safeDb, wrapHandler, captureStack } from './typed-errors.js';
17
19
  export type { SafeResult } from './typed-errors.js';
20
+ export { TibError, TIB_ERROR_CODES, domainError, readLegacyHttpStatus, } from './error-formatter.js';
21
+ export type { TibErrorCode, StandardError, HttpStatusCarrier } from './error-formatter.js';
18
22
  export { createSecrets, createEmptySecrets } from './secrets.js';
19
23
  export type { SecretsOptions } from './secrets.js';
20
24
  export { createIdempotency, createNoopIdempotency } from './idempotency.js';
@@ -33,6 +37,8 @@ export { createIntegrationsProxy, createEmptyIntegrationsProxy } from './integra
33
37
  export type { IntegrationsProxyOptions } from './integrations.js';
34
38
  export { hydrateCtx } from './hydrate.js';
35
39
  export type { HydrateOptions } from './hydrate.js';
40
+ export { executeAction, withActionExecution, releaseDbSafely } from './action-executor.js';
41
+ export type { ApiActionSource, InternalActionSource, ActionSource, ExecuteActionMeta, ExecuteActionResult, } from './action-executor.js';
36
42
  export { negotiateVersion } from './version-negotiator.js';
37
43
  export type { VersionNegotiation } from './version-negotiator.js';
38
44
  export { checkSunset, applySunsetHeaders } from './sunset-guard.js';
@@ -41,8 +47,10 @@ export { eventVersionMatches } from './event-version-matcher.js';
41
47
  export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } from './extractors.js';
42
48
  export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
43
49
  export type { ParsedQuery } from './query-parser.js';
44
- export { createApiLambdaHandler } from './api-handler.js';
45
- export type { CreateApiHandlerOptions } from './api-handler.js';
50
+ export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
51
+ export type { CreateExposedActionHandlerOptions } from './exposed-action-api-handler.js';
52
+ export { createActionLambdaHandler } from './action-handler.js';
53
+ export type { CreateActionHandlerOptions, ActionInvocationEnvelope, ActionActorEnvelope, ActionRegistryEntry, ActionHandlerFn, } from './action-handler.js';
46
54
  export { createSubscriberLambdaHandler } from './subscriber-handler.js';
47
55
  export type { CreateSubscriberHandlerOptions } from './subscriber-handler.js';
48
56
  export { createJobLambdaHandler } from './job-handler.js';
@@ -2,6 +2,11 @@
2
2
  export { createLogger } from './logger.js';
3
3
  export { createTracer, noopTracer, initOtel } from './tracer.js';
4
4
  export { createCache } from './cache.js';
5
+ // Issue #4662 Task D — observability bindings. Used by hydrateCtx,
6
+ // the action executor, and the cross-domain ActionsProxy so every log
7
+ // line, audit event, and Lambda envelope carries the same canonical
8
+ // tenant / actor / action identity fields.
9
+ export { extractObservabilityBindings, requireObservabilityBindings, } from './observability-bindings.js';
5
10
  /**
6
11
  * @deprecated Use `createKyClient` instead. The `createFetch` name is kept
7
12
  * as a backward-compatible alias for callers that imported it before the
@@ -15,6 +20,8 @@ export { checkRateLimit, clearRateLimitBuckets } from './rate-limiter.js';
15
20
  // single barrel. The full type definitions live in `../types/result.js`.
16
21
  export { ok, err, notFound } from '../types/result.js';
17
22
  export { safeFetch, safeDb, wrapHandler, captureStack } from './typed-errors.js';
23
+ // Domain error helper — preferred throwing primitive for action handlers.
24
+ export { TibError, TIB_ERROR_CODES, domainError, readLegacyHttpStatus, } from './error-formatter.js';
18
25
  // AWS SDK implementations
19
26
  export { createSecrets, createEmptySecrets } from './secrets.js';
20
27
  export { createIdempotency, createNoopIdempotency } from './idempotency.js';
@@ -28,6 +35,15 @@ export { createFlowsProxy, createCapturingFlowsProxy } from './flows.js';
28
35
  export { createIntegrationsProxy, createEmptyIntegrationsProxy } from './integrations.js';
29
36
  // Master factory
30
37
  export { hydrateCtx } from './hydrate.js';
38
+ // Shared action executor (Wave 2 of #4619). Consumed by both the API-exposed
39
+ // action adapter and the internal cross-domain action Lambda handler. The
40
+ // helper functions exported alongside (`withActionExecution`,
41
+ // `releaseDbSafely`) cover the standalone-script / test case where the
42
+ // executor owns the DB lifecycle. Lower-level pipeline helpers
43
+ // (`isCallerAllowed`, `ensureTenantMatches`, `actorHasRequiredRoles`) stay
44
+ // private to the file — they're exported there only so unit tests can pin
45
+ // behaviour against a single import surface.
46
+ export { executeAction, withActionExecution, releaseDbSafely } from './action-executor.js';
31
47
  // Versioning middleware
32
48
  export { negotiateVersion } from './version-negotiator.js';
33
49
  export { checkSunset, applySunsetHeaders } from './sunset-guard.js';
@@ -37,7 +53,8 @@ export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } fro
37
53
  // Query parsing
38
54
  export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
39
55
  // Lambda handler wrappers
40
- export { createApiLambdaHandler } from './api-handler.js';
56
+ export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
57
+ export { createActionLambdaHandler } from './action-handler.js';
41
58
  export { createSubscriberLambdaHandler } from './subscriber-handler.js';
42
59
  export { createJobLambdaHandler } from './job-handler.js';
43
60
  export { createWebhookLambdaHandler } from './webhook-handler.js';
@@ -0,0 +1,110 @@
1
+ import type { DomainContext } from '../ctx/context.js';
2
+ /**
3
+ * Canonical observability bindings attached to every log line and audit
4
+ * event in the runtime (#4662 Task D).
5
+ *
6
+ * These fields mirror the JWT contract:
7
+ * - `tenantId`, `orgId` come from `ctx.tenant`
8
+ * - `actorSub`, `actorEmail`, `actorRoles` come from `ctx.actor`
9
+ * - `actionId`, `callerDomain`, `traceId` come from the executing
10
+ * action's runtime context (`meta.definingDomain`, `meta.traceId`,
11
+ * and the inbound `ActionInvocationEnvelope` for cross-domain calls)
12
+ *
13
+ * Every key is optional — the helper intentionally omits empty values so
14
+ * structured logs stay compact and CloudWatch Insights filters can
15
+ * reliably match on `tenantId` without checking for both the key and
16
+ * the literal string `'unknown'`.
17
+ */
18
+ export interface ObservabilityBindings {
19
+ /** Tenant ID from the request context. Omitted when unknown. */
20
+ tenantId?: string;
21
+ /** Org ID from the request context. Omitted when absent. */
22
+ orgId?: string;
23
+ /** Workspace ID from the request context. Omitted when unknown. */
24
+ workspaceId?: string;
25
+ /** JWT `sub` claim of the acting principal. Omitted when absent. */
26
+ actorSub?: string;
27
+ /** Email address of the acting principal. Omitted when absent. */
28
+ actorEmail?: string;
29
+ /** Role claims (e.g. `['sys_admin']`). Omitted when empty. */
30
+ actorRoles?: string[];
31
+ /** Action ID being executed (e.g. `'charge-card'`). Omitted when absent. */
32
+ actionId?: string;
33
+ /** Domain that owns the action being executed. Omitted when absent. */
34
+ callerDomain?: string;
35
+ /** Domain that invoked this call (cross-domain only). Omitted when same-domain. */
36
+ targetDomain?: string;
37
+ /** Distributed trace ID for the current request. Omitted when absent. */
38
+ traceId?: string;
39
+ }
40
+ /**
41
+ * Options for {@link extractObservabilityBindings}.
42
+ */
43
+ export interface ExtractBindingsOptions {
44
+ /** Domain ID of the executing action (`meta.definingDomain`). */
45
+ definingDomain?: string;
46
+ /** Trace ID for the current request (`meta.traceId`). */
47
+ traceId?: string;
48
+ /** Inbound action invocation envelope (cross-domain only). */
49
+ envelope?: {
50
+ callerDomain?: string;
51
+ targetDomain?: string;
52
+ actionId?: string;
53
+ actorSub?: string;
54
+ };
55
+ /**
56
+ * When false (default), `callerDomain` is omitted when the caller is
57
+ * the same domain as `definingDomain`. Set to true to always include
58
+ * it (useful for cross-domain debugging).
59
+ */
60
+ alwaysIncludeCaller?: boolean;
61
+ }
62
+ /**
63
+ * Minimal input the binding extractor needs from the runtime context.
64
+ * Keeps callers from having to pass the full {@link DomainContext}
65
+ * (which carries DB, secrets, integrations proxies that have nothing
66
+ * to do with observability).
67
+ */
68
+ interface ObservabilityContext {
69
+ tenant?: {
70
+ id?: string;
71
+ workspaceId?: string;
72
+ orgId?: string;
73
+ };
74
+ actor?: {
75
+ sub?: string;
76
+ email?: string;
77
+ roles?: string[];
78
+ };
79
+ /** Optional inline trace id when callers don't pass it via `options.traceId`. */
80
+ traceId?: string;
81
+ }
82
+ /**
83
+ * Project the runtime `DomainContext` (plus a few optional metadata
84
+ * fields) onto the canonical {@link ObservabilityBindings} shape.
85
+ *
86
+ * Used by:
87
+ *
88
+ * - `createLogger({...bindings})` so every log line carries the
89
+ * bindings as Powertools persistent attributes.
90
+ * - `createAudit(...)` so audit events on the EventBridge bus carry the
91
+ * same shape.
92
+ * - The cross-domain `ActionsProxy` envelope so receiving handlers
93
+ * observe the same identity the caller had.
94
+ *
95
+ * The function is pure and side-effect free — it does NOT mutate `ctx`,
96
+ * does NOT log, and does NOT publish. Callers compose the bindings into
97
+ * whatever observability sink they need.
98
+ */
99
+ export declare function extractObservabilityBindings(ctx: ObservabilityContext, options?: ExtractBindingsOptions): ObservabilityBindings;
100
+ /**
101
+ * Convenience helper: extract bindings and assert at least one
102
+ * tenant identity is present. Used by entrypoints that should never
103
+ * run without a tenant (e.g. API-exposed actions with `tenancy:
104
+ * 'required'`) — it produces a structured error before any side
105
+ * effects occur.
106
+ *
107
+ * @throws TibError when neither tenant nor trace id is present.
108
+ */
109
+ export declare function requireObservabilityBindings(ctx: Pick<DomainContext, 'tenant' | 'actor'>, options?: ExtractBindingsOptions): ObservabilityBindings;
110
+ export {};
@@ -0,0 +1,94 @@
1
+ import { TibError } from './error-formatter.js';
2
+ /**
3
+ * Project the runtime `DomainContext` (plus a few optional metadata
4
+ * fields) onto the canonical {@link ObservabilityBindings} shape.
5
+ *
6
+ * Used by:
7
+ *
8
+ * - `createLogger({...bindings})` so every log line carries the
9
+ * bindings as Powertools persistent attributes.
10
+ * - `createAudit(...)` so audit events on the EventBridge bus carry the
11
+ * same shape.
12
+ * - The cross-domain `ActionsProxy` envelope so receiving handlers
13
+ * observe the same identity the caller had.
14
+ *
15
+ * The function is pure and side-effect free — it does NOT mutate `ctx`,
16
+ * does NOT log, and does NOT publish. Callers compose the bindings into
17
+ * whatever observability sink they need.
18
+ */
19
+ export function extractObservabilityBindings(ctx, options = {}) {
20
+ const out = {};
21
+ const { tenant, actor } = ctx;
22
+ if (tenant) {
23
+ if (tenant.id && tenant.id !== 'unknown')
24
+ out.tenantId = tenant.id;
25
+ if (tenant.orgId && tenant.orgId !== 'unknown')
26
+ out.orgId = tenant.orgId;
27
+ if (tenant.workspaceId && tenant.workspaceId !== 'unknown') {
28
+ out.workspaceId = tenant.workspaceId;
29
+ }
30
+ }
31
+ if (actor) {
32
+ if (actor.sub)
33
+ out.actorSub = actor.sub;
34
+ if (actor.email)
35
+ out.actorEmail = actor.email;
36
+ if (Array.isArray(actor.roles) && actor.roles.length > 0)
37
+ out.actorRoles = actor.roles;
38
+ }
39
+ // `ctx.traceId` is set by `createExposedActionApiHandler` /
40
+ // `createActionLambdaHandler` from the inbound envelope or header. We
41
+ // prefer the explicit `options.traceId` so callers can override (e.g.
42
+ // when replaying a cached invocation).
43
+ const traceId = options.traceId ?? ctx.traceId;
44
+ if (traceId && traceId !== 'unknown')
45
+ out.traceId = traceId;
46
+ if (options.envelope) {
47
+ const { callerDomain, targetDomain, actionId } = options.envelope;
48
+ if (actionId)
49
+ out.actionId = actionId;
50
+ if (callerDomain)
51
+ out.callerDomain = callerDomain;
52
+ if (targetDomain && targetDomain !== callerDomain) {
53
+ out.targetDomain = targetDomain;
54
+ }
55
+ }
56
+ else if (options.definingDomain) {
57
+ // Same-domain invocations: surface the executing action's domain so
58
+ // downstream logs always carry a `callerDomain` for filtering —
59
+ // the strip below removes it again unless `alwaysIncludeCaller`
60
+ // is set.
61
+ out.callerDomain = options.definingDomain;
62
+ }
63
+ // Strip the `callerDomain` when it equals `definingDomain` (the
64
+ // common in-process case where the only metadata we have is the
65
+ // executing action's defining domain) so the field only adds noise
66
+ // for genuinely cross-domain calls. `alwaysIncludeCaller` overrides
67
+ // this for debugging. Skipped when an envelope was supplied —
68
+ // explicit cross-domain metadata always wins over the redundancy
69
+ // heuristic.
70
+ if (!options.alwaysIncludeCaller
71
+ && !options.envelope
72
+ && out.callerDomain
73
+ && options.definingDomain
74
+ && out.callerDomain === options.definingDomain) {
75
+ delete out.callerDomain;
76
+ }
77
+ return out;
78
+ }
79
+ /**
80
+ * Convenience helper: extract bindings and assert at least one
81
+ * tenant identity is present. Used by entrypoints that should never
82
+ * run without a tenant (e.g. API-exposed actions with `tenancy:
83
+ * 'required'`) — it produces a structured error before any side
84
+ * effects occur.
85
+ *
86
+ * @throws TibError when neither tenant nor trace id is present.
87
+ */
88
+ export function requireObservabilityBindings(ctx, options = {}) {
89
+ const bindings = extractObservabilityBindings(ctx, options);
90
+ if (!bindings.tenantId) {
91
+ throw new TibError('Observability bindings missing tenantId', 'OBSERVABILITY_BINDINGS_INCOMPLETE');
92
+ }
93
+ return bindings;
94
+ }
@@ -1,4 +1,12 @@
1
1
  import type { DomainStoreContext } from '../ctx/store.js';
2
+ /**
3
+ * Tenant-scoped DynamoDB wrapper. PK is always the current tenant — developers
4
+ * supply only the SK. All operations are automatically scoped to ctx.tenant.id.
5
+ *
6
+ * The runtime tenant ID supplied at construction time is the SOLE source of
7
+ * the partition key. Caller-supplied `tenantId` fields in `put` items or
8
+ * `update` payloads are dropped/overridden before reaching DynamoDB.
9
+ */
2
10
  export declare function createStore(opts: {
3
11
  tableName: string;
4
12
  tenantId: string;
@@ -1,7 +1,30 @@
1
1
  import { DynamoDBClient, GetItemCommand, PutItemCommand, DeleteItemCommand, QueryCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
2
2
  import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
3
3
  import { getTenantCredentials } from './tenant-credentials.js';
4
+ /**
5
+ * Reject missing, empty, or 'unknown' tenant IDs at construction time.
6
+ *
7
+ * The runtime layer (hydrateCtx, api-handler, action-executor) already
8
+ * surfaces tenant failures upstream, but the wrapper itself must fail loudly
9
+ * if it is ever instantiated with an unsafe tenant — otherwise it would
10
+ * silently write to a single shared partition named "unknown". This is the
11
+ * last line of defence for DynamoDB tenant isolation (#4619 Wave 5 Task 5.2).
12
+ */
13
+ function assertRuntimeTenantId(tenantId) {
14
+ if (typeof tenantId !== 'string' || tenantId.length === 0 || tenantId === 'unknown') {
15
+ throw new Error(`createStore: a valid runtime tenantId is required (got ${JSON.stringify(tenantId)})`);
16
+ }
17
+ }
18
+ /**
19
+ * Tenant-scoped DynamoDB wrapper. PK is always the current tenant — developers
20
+ * supply only the SK. All operations are automatically scoped to ctx.tenant.id.
21
+ *
22
+ * The runtime tenant ID supplied at construction time is the SOLE source of
23
+ * the partition key. Caller-supplied `tenantId` fields in `put` items or
24
+ * `update` payloads are dropped/overridden before reaching DynamoDB.
25
+ */
4
26
  export function createStore(opts) {
27
+ assertRuntimeTenantId(opts.tenantId);
5
28
  const client = new DynamoDBClient({
6
29
  credentials: () => getTenantCredentials(opts.tenantRoleArn, opts.tenantId),
7
30
  });
@@ -15,6 +38,9 @@ export function createStore(opts) {
15
38
  return res.Item ? unmarshall(res.Item) : null;
16
39
  },
17
40
  async put(sk, item, putOpts) {
41
+ // Caller-supplied tenantId/sk in `item` is always overridden by the
42
+ // runtime values. This is structural, not just a behavioural note —
43
+ // the spread order makes it impossible for caller fields to win.
18
44
  const record = { ...item, tenantId, sk };
19
45
  if (putOpts?.ttl) {
20
46
  record['expiresAt'] = Math.floor(Date.now() / 1000) + putOpts.ttl;
@@ -52,6 +78,9 @@ export function createStore(opts) {
52
78
  };
53
79
  },
54
80
  async update(sk, updates) {
81
+ // Defensive: even if a caller tries to write `tenantId` or `sk` via the
82
+ // updates map, those keys are filtered before reaching DynamoDB. The
83
+ // partition key and sort key are owned by the runtime.
55
84
  const entries = Object.entries(updates).filter(([k]) => k !== 'tenantId' && k !== 'sk');
56
85
  if (entries.length === 0)
57
86
  return;
@@ -20,7 +20,7 @@ export declare const noopTracer: Tracer;
20
20
  *
21
21
  * **Opt-in by design.** This function is NOT called automatically by the
22
22
  * runtime; domain handler templates must call `initOtel()` at module
23
- * scope if they want OTel-exported spans. The `tib doctor` gate
23
+ * scope if they want OTel-exported spans. The `mc-domain-module doctor` gate
24
24
  * (`checkOtelInitInLambdas`) enforces this convention by scanning
25
25
  * `domains/.../api/-.ts` for an `initOtel(...)` call.
26
26
  *
@@ -68,7 +68,7 @@ let initialised = false;
68
68
  *
69
69
  * **Opt-in by design.** This function is NOT called automatically by the
70
70
  * runtime; domain handler templates must call `initOtel()` at module
71
- * scope if they want OTel-exported spans. The `tib doctor` gate
71
+ * scope if they want OTel-exported spans. The `mc-domain-module doctor` gate
72
72
  * (`checkOtelInitInLambdas`) enforces this convention by scanning
73
73
  * `domains/.../api/-.ts` for an `initOtel(...)` call.
74
74
  *
@@ -1,6 +1,6 @@
1
1
  export { z } from 'zod';
2
2
  export * from './helpers.js';
3
- export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql } from './rls.js';
3
+ export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql, tenantAdminBypassSql, } from './rls.js';
4
4
  export type { TenantIsolationOptions } from './rls.js';
5
5
  export { generateRlsMigration } from './migration.js';
6
6
  export type { RlsMigrationOptions, RlsMigrationTable } from './migration.js';
@@ -1,4 +1,4 @@
1
1
  export { z } from 'zod';
2
2
  export * from './helpers.js';
3
- export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql } from './rls.js';
3
+ export { tenantIsolationSql, dropTenantIsolationSql, tenantRoleSql, tenantAdminBypassSql, } from './rls.js';
4
4
  export { generateRlsMigration } from './migration.js';
@@ -22,10 +22,41 @@ export interface TenantIsolationOptions {
22
22
  * if a query is made outside a request context — those queries return zero rows
23
23
  * instead of throwing.
24
24
  *
25
+ * **Session variable contract.** This policy reads `app.current_tenant`, which
26
+ * is bound by `createDb` (Wave 5 Task 5.1) via
27
+ * `SELECT set_config('app.current_tenant', $1, false)` on every per-request
28
+ * client checkout. The bound value comes from `ctx.tenant.id` (see
29
+ * `hydrate.ts`). Any domain code that issues raw SQL against a RLS-protected
30
+ * table therefore relies on the runtime having already bound that variable;
31
+ * if a query is made before `createDb` finishes binding (or after the client
32
+ * is released back to the pool), the policy falls back to a zero-row result
33
+ * because the setting is unset and `current_setting(..., true)` returns NULL.
34
+ *
35
+ * For sys-admin / cross-tenant queries, callers must additionally run
36
+ * `SELECT set_config('app.bypass_rls', 'true', false)` on the same client.
37
+ * This contract is enforced by `tenantAdminBypassSql` below.
38
+ *
25
39
  * @param opts - Table and tenant column configuration.
26
40
  * @returns A multi-statement SQL string suitable for inclusion in a migration.
27
41
  */
28
42
  export declare function tenantIsolationSql(opts: TenantIsolationOptions): string;
43
+ /**
44
+ * Generate the SQL to add a permissive RLS bypass policy that fires when
45
+ * `current_setting('app.bypass_rls', true) = 'true'`. Intended to be added
46
+ * alongside `tenantIsolationSql` for sys-admin / cross-tenant queries that
47
+ * must read rows belonging to any tenant (for example, the data-management
48
+ * domain's schema explorer). The runtime sets `app.bypass_rls` via
49
+ * `SELECT set_config('app.bypass_rls', 'true', false)` on the same per-request
50
+ * client that hydrates with the sys-admin role.
51
+ *
52
+ * The generated policy is idempotent: it only creates the policy if no policy
53
+ * with the same name already exists.
54
+ *
55
+ * @param opts - Table and tenant column configuration (same shape as
56
+ * `tenantIsolationSql` so the two helpers compose cleanly).
57
+ * @returns A multi-statement SQL string suitable for inclusion in a migration.
58
+ */
59
+ export declare function tenantAdminBypassSql(opts: TenantIsolationOptions): string;
29
60
  /**
30
61
  * Generate the SQL to drop a tenant isolation policy and disable RLS.
31
62
  * Useful for down-migrations.