@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,4 +1,27 @@
1
1
  import { ZodError } from 'zod';
2
+ /**
3
+ * Stable error codes that domain handlers can throw via `TibError`. The
4
+ * exposed-action API adapter (`createExposedActionApiHandler`) maps each
5
+ * of these to a fixed HTTP status — adding a new code without updating
6
+ * the map will fall through to 500.
7
+ *
8
+ * The set is intentionally small: every code here resolves to exactly one
9
+ * HTTP status, so the contract between runtime and frontend is one-to-one.
10
+ */
11
+ export const TIB_ERROR_CODES = [
12
+ 'VALIDATION_ERROR',
13
+ 'AUTH_REQUIRED',
14
+ 'FORBIDDEN',
15
+ 'NOT_FOUND',
16
+ 'CONFLICT',
17
+ 'GONE',
18
+ 'UNPROCESSABLE_ENTITY',
19
+ 'INTERNAL_ERROR',
20
+ 'TENANT_REQUIRED',
21
+ 'TENANT_MISMATCH',
22
+ 'NOT_EXPOSED',
23
+ 'OUTPUT_VALIDATION_ERROR',
24
+ ];
2
25
  /**
3
26
  * Formats any caught error into the standard TIB error envelope.
4
27
  * Contract locked with frontend scaffolder (#1975).
@@ -26,14 +49,42 @@ export function formatError(err, traceId, logger) {
26
49
  logger.error('Unhandled handler error', { err, traceId });
27
50
  return { error: 'Internal error', code: 'INTERNAL_ERROR', traceId };
28
51
  }
52
+ /**
53
+ * Domain error thrown by action handlers and adapter internals. Carries a
54
+ * stable `code` so adapters can render it to a deterministic HTTP status
55
+ * without parsing the message.
56
+ *
57
+ * Handlers should construct one via `domainError(...)` instead of the raw
58
+ * constructor so the compiler enforces the allowed-code union.
59
+ *
60
+ * The previous action-handler pattern — `throw Object.assign(new Error(),
61
+ * { status: 404 })` — survives end-to-end as long as the throwing code
62
+ * passes through `executeAction`, which translates `TibError` into an
63
+ * `ExecuteActionResult` carrying the same code. See
64
+ * `runtime/action-executor.ts` for the conversion point.
65
+ */
29
66
  export class TibError extends Error {
30
67
  code;
68
+ status;
31
69
  constructor(message, code) {
32
70
  super(message);
33
- this.code = code;
34
71
  this.name = 'TibError';
72
+ this.code = code;
73
+ this.status = undefined;
35
74
  }
36
75
  }
76
+ /**
77
+ * Build a {@link TibError} with a stable, typed code. Domain handlers
78
+ * import this instead of `new TibError(...)` so the compiler refuses
79
+ * unknown codes.
80
+ *
81
+ * ```ts
82
+ * throw domainError('User not found', 'NOT_FOUND');
83
+ * ```
84
+ */
85
+ export function domainError(message, code) {
86
+ return new TibError(message, code);
87
+ }
37
88
  /**
38
89
  * Extracts a trace ID from X-Ray header or X-Request-Id, or generates a fallback.
39
90
  */
@@ -49,3 +100,19 @@ export function extractTraceId(headers) {
49
100
  function generateId() {
50
101
  return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
51
102
  }
103
+ /**
104
+ * Best-effort detector for an error object that carries a numeric
105
+ * `status` property (the legacy pattern that pre-dates {@link TibError}).
106
+ *
107
+ * Used by the exposed-action API adapter's outer try/catch to render a
108
+ * domain-meaningful HTTP status for callers that have not migrated to
109
+ * {@link domainError} yet. Once all built-in handlers are migrated this
110
+ * function becomes dead code; until then it acts as the migration safety
111
+ * net so a runtime upgrade does not regress behaviour.
112
+ */
113
+ export function readLegacyHttpStatus(err) {
114
+ if (typeof err !== 'object' || err === null)
115
+ return undefined;
116
+ const candidate = err;
117
+ return typeof candidate.status === 'number' ? candidate.status : undefined;
118
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Runtime API Gateway adapter for API-exposed actions.
3
+ *
4
+ * Wave 3 of #4619 — wraps an `ActionDefinition` whose `exposure.type === 'api'`
5
+ * into an AWS Lambda HTTP API v2 handler. The adapter is intentionally thin:
6
+ * it owns the HTTP-shaped concerns (body parsing, status code mapping,
7
+ * response envelope, DB release) and delegates the action-shaped concerns
8
+ * (validation, auth/tenancy, idempotency, output validation, audit) to
9
+ * `executeAction`.
10
+ *
11
+ * Mirrors the structure of `createApiLambdaHandler` (api-handler.ts) so the
12
+ * two adapters stay visually parallel — but trades versioning/sunset/rate
13
+ * limiting for action-specific concerns (exposure precondition, actor role
14
+ * claims, output-as-action semantics).
15
+ *
16
+ * DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block, just
17
+ * like `createApiLambdaHandler`. `executeAction` does not auto-release by
18
+ * default, so there is no double-release.
19
+ */
20
+ import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
21
+ import type { ActionDefinition } from '../primitives/action.js';
22
+ import type { ActionRegistry } from './actions.js';
23
+ /**
24
+ * Options for `createExposedActionApiHandler`.
25
+ *
26
+ * Wave 7 Task 7.1 (#4619): the adapter now accepts the same
27
+ * plumbing fields that the in-process action proxy uses so that
28
+ * `ctx.actions` is populated inside the public action handler and
29
+ * `executeAction` can enforce `backendAccess` against in-handler
30
+ * action invocations.
31
+ */
32
+ export interface CreateExposedActionHandlerOptions {
33
+ /**
34
+ * Domain ID that owns the action being executed. Forwarded to
35
+ * `executeAction`'s `meta.definingDomain` so backendAccess enforcement
36
+ * can tighten once the registry plumbs it through.
37
+ */
38
+ definingDomain?: string;
39
+ /**
40
+ * The domain ID of THIS running handler. Used as the
41
+ * `callerDomainId`/`definingDomain` for the ActionsProxy built inside
42
+ * the handler so that public handlers can call their own (and only
43
+ * their own) in-domain private actions.
44
+ */
45
+ domainId?: string;
46
+ /**
47
+ * In-process action registry for the running domain. When provided,
48
+ * the adapter builds an `ActionsProxy` whose `ctx.actions[domainId]`
49
+ * namespace contains the public action itself so handlers can do
50
+ * `ctx.actions[domainId].myAction(input)` without leaving the process.
51
+ */
52
+ actionRegistry?: ActionRegistry;
53
+ /**
54
+ * Map of `domainId → action Lambda ARN` for cross-domain calls. Same
55
+ * shape consumed by `createActionsProxy` directly.
56
+ */
57
+ lambdaArns?: Record<string, string>;
58
+ /**
59
+ * Caller-domain override for the ActionsProxy. Defaults to
60
+ * `options.domainId` when omitted so generated wrappers do not have
61
+ * to populate it explicitly.
62
+ */
63
+ callerDomainId?: string;
64
+ }
65
+ /**
66
+ * Wrap an API-exposed `ActionDefinition` into an AWS Lambda HTTP API v2
67
+ * handler.
68
+ *
69
+ * Pipeline:
70
+ * 1. Extract trace ID from W3C / X-Ray / X-Request-Id headers.
71
+ * 2. Extract actor and tenant from JWT claims + path params.
72
+ * 3. Parse request input (body for non-GET/HEAD, query string for GET/HEAD).
73
+ * 4. Hydrate `ctx` with actor, tenant, DB, event bus, path params.
74
+ * 5. Call `executeAction(action, parsedInput, ctx, { type: 'api', pathTenantId })`.
75
+ * 6. Translate the executor result into an HTTP-shaped response.
76
+ * 7. Release `ctx.db` in `finally` (adapter owns the lifecycle).
77
+ *
78
+ * Construction-time validation (`action.exposure.type === 'api'`) runs
79
+ * before the returned handler closes over any request data, so a misrouted
80
+ * internal action throws immediately at startup rather than 500-ing on the
81
+ * first request.
82
+ *
83
+ * @param action - The API-exposed action to wrap. Must declare
84
+ * `exposure.type === 'api'`.
85
+ * @param options - Optional overrides (e.g. definingDomain for audit).
86
+ * @returns An AWS Lambda HTTP API v2 handler function.
87
+ */
88
+ export declare function createExposedActionApiHandler(action: ActionDefinition, options?: CreateExposedActionHandlerOptions): (event: APIGatewayProxyEventV2WithJWTAuthorizer) => Promise<APIGatewayProxyStructuredResultV2>;
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Runtime API Gateway adapter for API-exposed actions.
3
+ *
4
+ * Wave 3 of #4619 — wraps an `ActionDefinition` whose `exposure.type === 'api'`
5
+ * into an AWS Lambda HTTP API v2 handler. The adapter is intentionally thin:
6
+ * it owns the HTTP-shaped concerns (body parsing, status code mapping,
7
+ * response envelope, DB release) and delegates the action-shaped concerns
8
+ * (validation, auth/tenancy, idempotency, output validation, audit) to
9
+ * `executeAction`.
10
+ *
11
+ * Mirrors the structure of `createApiLambdaHandler` (api-handler.ts) so the
12
+ * two adapters stay visually parallel — but trades versioning/sunset/rate
13
+ * limiting for action-specific concerns (exposure precondition, actor role
14
+ * claims, output-as-action semantics).
15
+ *
16
+ * DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block, just
17
+ * like `createApiLambdaHandler`. `executeAction` does not auto-release by
18
+ * default, so there is no double-release.
19
+ */
20
+ import { hydrateCtx } from './hydrate.js';
21
+ import { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent, } from './extractors.js';
22
+ import { formatError, extractTraceId, TibError, readLegacyHttpStatus } from './error-formatter.js';
23
+ import { executeAction } from './action-executor.js';
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Construction-time validation
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ /**
28
+ * Throw a configuration error if `action` cannot be wrapped by the API
29
+ * adapter. Internal-only actions must never be reachable through an HTTP
30
+ * route — that would silently bypass `backendAccess`.
31
+ */
32
+ function assertApiExposed(action) {
33
+ if (action.exposure.type !== 'api') {
34
+ throw new TibError(`createExposedActionApiHandler: action '${action.id}' has exposure.type '${action.exposure.type}'; only 'api' is supported`, 'INVALID_ACTION_EXPOSURE');
35
+ }
36
+ }
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Body / input parsing
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ /**
41
+ * HTTP methods whose input must come from the URL query string rather than
42
+ * the request body. Matches HTTP semantics: GET/HEAD have no semantic body
43
+ * and proxies commonly strip them.
44
+ */
45
+ const QUERY_BASED_METHODS = new Set(['GET', 'HEAD']);
46
+ /**
47
+ * Parse the action input from an API Gateway HTTP API v2 event.
48
+ *
49
+ * - Non-GET/HEAD requests: parse `event.body` as JSON. A missing body
50
+ * becomes `undefined` (the Zod schema then decides what to do with it).
51
+ * - GET/HEAD requests: decode `event.rawQueryString` into a flat
52
+ * `Record<string, string>` object.
53
+ *
54
+ * NOTE (v1 limitation): the GET/HEAD path uses a minimal query decoder.
55
+ * List-style pagination/filter helpers from `query-parser.ts`
56
+ * (cursor/limit/sort) are intentionally NOT applied here because action
57
+ * inputs are typed Zod schemas, not the generic list contract. Callers
58
+ * wanting pagination over a GET action should declare the relevant fields
59
+ * on `action.input`.
60
+ *
61
+ * @returns Parsed input suitable for `action.input.parse(...)`.
62
+ * @throws TibError when the body is present but not valid JSON.
63
+ */
64
+ function parseEventInput(event) {
65
+ const method = event.requestContext?.http?.method?.toUpperCase() ?? '';
66
+ if (QUERY_BASED_METHODS.has(method)) {
67
+ return parseQueryString(event.rawQueryString ?? '');
68
+ }
69
+ if (!event.body)
70
+ return undefined;
71
+ const raw = event.isBase64Encoded
72
+ ? Buffer.from(event.body, 'base64').toString('utf8')
73
+ : event.body;
74
+ try {
75
+ return JSON.parse(raw);
76
+ }
77
+ catch {
78
+ // Surface a structured error so the adapter can render a 400 envelope.
79
+ throw new TibError('Invalid JSON body', 'VALIDATION_ERROR');
80
+ }
81
+ }
82
+ /**
83
+ * Decode an `application/x-www-form-urlencoded` style query string into a
84
+ * flat `Record<string, string>`. Repeated keys collapse to the last value,
85
+ * matching the behaviour of the standard TIB query-parser helpers.
86
+ */
87
+ function parseQueryString(raw) {
88
+ const out = {};
89
+ if (!raw)
90
+ return out;
91
+ for (const part of raw.split('&')) {
92
+ if (!part)
93
+ continue;
94
+ const eq = part.indexOf('=');
95
+ const key = eq === -1 ? part : part.slice(0, eq);
96
+ const value = eq === -1 ? '' : part.slice(eq + 1);
97
+ try {
98
+ out[decodeURIComponent(key)] = decodeURIComponent(value);
99
+ }
100
+ catch {
101
+ // Skip malformed entries rather than failing the whole request.
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // Actor role extraction
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+ /**
110
+ * Extract role claims from the JWT authorizer payload. Returns undefined
111
+ * when no scopes are attached so `executeAction` interprets the absence as
112
+ * "no role narrowing evidence" — i.e. the auth check will reject system-
113
+ * tenancy actions that require specific roles.
114
+ */
115
+ function extractActorRoles(event) {
116
+ const scopes = event.requestContext?.authorizer?.jwt?.scopes;
117
+ if (!Array.isArray(scopes) || scopes.length === 0)
118
+ return undefined;
119
+ return scopes.filter((s) => typeof s === 'string');
120
+ }
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+ // Status code mapping
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ /**
125
+ * Map the executor's domain-meaningful error codes to HTTP status codes.
126
+ *
127
+ * Centralised so the mapping is documented in one place and easy to audit.
128
+ * Unknown codes (e.g. custom domain errors) fall through to 500. Codes
129
+ * listed here MUST exist on `TIB_ERROR_CODES` in `./error-formatter.ts`
130
+ * so adding a new code requires a paired mapping.
131
+ */
132
+ function statusForErrorCode(code) {
133
+ switch (code) {
134
+ case 'VALIDATION_ERROR':
135
+ return 400;
136
+ case 'AUTH_REQUIRED':
137
+ case 'TENANT_REQUIRED':
138
+ return 401;
139
+ case 'FORBIDDEN':
140
+ case 'TENANT_MISMATCH':
141
+ return 403;
142
+ case 'NOT_FOUND':
143
+ case 'NOT_EXPOSED':
144
+ return 404;
145
+ case 'CONFLICT':
146
+ return 409;
147
+ case 'GONE':
148
+ return 410;
149
+ case 'UNPROCESSABLE_ENTITY':
150
+ return 422;
151
+ case 'INTERNAL_ERROR':
152
+ case 'OUTPUT_VALIDATION_ERROR':
153
+ return 500;
154
+ default:
155
+ return 500;
156
+ }
157
+ }
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Factory
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+ /**
162
+ * Wrap an API-exposed `ActionDefinition` into an AWS Lambda HTTP API v2
163
+ * handler.
164
+ *
165
+ * Pipeline:
166
+ * 1. Extract trace ID from W3C / X-Ray / X-Request-Id headers.
167
+ * 2. Extract actor and tenant from JWT claims + path params.
168
+ * 3. Parse request input (body for non-GET/HEAD, query string for GET/HEAD).
169
+ * 4. Hydrate `ctx` with actor, tenant, DB, event bus, path params.
170
+ * 5. Call `executeAction(action, parsedInput, ctx, { type: 'api', pathTenantId })`.
171
+ * 6. Translate the executor result into an HTTP-shaped response.
172
+ * 7. Release `ctx.db` in `finally` (adapter owns the lifecycle).
173
+ *
174
+ * Construction-time validation (`action.exposure.type === 'api'`) runs
175
+ * before the returned handler closes over any request data, so a misrouted
176
+ * internal action throws immediately at startup rather than 500-ing on the
177
+ * first request.
178
+ *
179
+ * @param action - The API-exposed action to wrap. Must declare
180
+ * `exposure.type === 'api'`.
181
+ * @param options - Optional overrides (e.g. definingDomain for audit).
182
+ * @returns An AWS Lambda HTTP API v2 handler function.
183
+ */
184
+ export function createExposedActionApiHandler(action, options = {}) {
185
+ assertApiExposed(action);
186
+ return async (event) => {
187
+ const responseHeaders = { 'Content-Type': 'application/json' };
188
+ // 1. Trace ID — prefer W3C traceparent when present.
189
+ const traceparent = event.headers?.['traceparent'];
190
+ const traceId = traceparent ?? extractTraceId(event.headers ?? {});
191
+ // ctx is declared outside the try block so the `finally` clause can
192
+ // always reach it (it may be unset if hydration itself throws).
193
+ let ctx;
194
+ try {
195
+ // 2. Actor / tenant extraction — uses the same helpers as
196
+ // createApiLambdaHandler so JWT/path behaviour stays uniform.
197
+ const actor = extractActorFromApiGatewayEvent(event);
198
+ const tenant = extractTenantFromApiGatewayEvent(event);
199
+ // 3. Input parsing.
200
+ let parsedInput;
201
+ try {
202
+ parsedInput = parseEventInput(event);
203
+ }
204
+ catch (err) {
205
+ return {
206
+ statusCode: 400,
207
+ headers: responseHeaders,
208
+ body: JSON.stringify(formatError(err, traceId)),
209
+ };
210
+ }
211
+ // 4. Hydrate ctx. tenantId is excluded from pathParams because it is
212
+ // already surfaced on ctx.tenant.id (and on the executor source).
213
+ const pathTenantId = event.pathParameters?.tenantId;
214
+ const { tenantId: _tenantId, ...remainingPathParams } = event.pathParameters ?? {};
215
+ // Wave 7 Task 7.1 (#4619): forward domain/registry plumbing so
216
+ // `ctx.actions[domainId].myAction(...)` works inside the handler.
217
+ // `domainId` is derived from `options.definingDomain` (preferred —
218
+ // matches what `executeAction` sees) and falls back to the explicit
219
+ // `options.domainId` for callers that want to distinguish the
220
+ // "domain that owns the runtime" from "domain that owns the
221
+ // action being executed". For generated wrappers both values are
222
+ // typically the same.
223
+ const proxyDomainId = options.domainId ?? options.definingDomain;
224
+ const proxyCallerDomainId = options.callerDomainId ?? options.domainId ?? options.definingDomain;
225
+ ctx = await hydrateCtx(event, {
226
+ actor,
227
+ tenant,
228
+ databaseUrl: process.env['DATABASE_URL'],
229
+ eventBusName: process.env['EVENT_BUS_NAME'],
230
+ traceId,
231
+ pathParams: remainingPathParams,
232
+ domainId: proxyDomainId,
233
+ ...(options.actionRegistry ? { actionRegistry: options.actionRegistry } : {}),
234
+ ...(options.lambdaArns ? { lambdaArns: options.lambdaArns } : {}),
235
+ ...(proxyCallerDomainId ? { callerDomainId: proxyCallerDomainId } : {}),
236
+ });
237
+ const actorRoles = extractActorRoles(event);
238
+ // 5. Execute the action through the shared pipeline.
239
+ const result = await executeAction(action, parsedInput, ctx, { type: 'api', pathTenantId, actorRoles }, { definingDomain: options.definingDomain, traceId });
240
+ // 6. Translate executor result → HTTP response.
241
+ if (result.ok) {
242
+ return {
243
+ statusCode: 200,
244
+ headers: responseHeaders,
245
+ body: JSON.stringify(result.value ?? null),
246
+ };
247
+ }
248
+ return {
249
+ statusCode: statusForErrorCode(result.code),
250
+ headers: responseHeaders,
251
+ body: JSON.stringify(formatError(result.error, traceId, ctx.logger)),
252
+ };
253
+ }
254
+ catch (err) {
255
+ // Truly unexpected throws (e.g. handler threw a non-TibError, or
256
+ // hydration itself blew up). Render as a 500 using the standard
257
+ // envelope; ctx.logger may be undefined here so fall back gracefully.
258
+ //
259
+ // Legacy status carrier: handlers that still throw the
260
+ // `Object.assign(new Error(), { status: N })` shape get one free
261
+ // pass so the runtime upgrade is non-breaking. Once every built-in
262
+ // handler is migrated to `domainError(...)` this branch becomes
263
+ // dead code and can be removed.
264
+ // eslint-disable-next-line no-console
265
+ console.error('Unhandled exposed-action handler error', err);
266
+ const legacyStatus = readLegacyHttpStatus(err);
267
+ return {
268
+ statusCode: legacyStatus ?? 500,
269
+ headers: responseHeaders,
270
+ body: JSON.stringify(formatError(err, traceId, ctx?.logger)),
271
+ };
272
+ }
273
+ finally {
274
+ // 7. Adapter owns the DB lifecycle. `executeAction` does not release
275
+ // by default, so this single release is the only owner. If a
276
+ // request throws before hydration completes, ctx is undefined and
277
+ // we have nothing to release.
278
+ if (ctx) {
279
+ try {
280
+ await ctx.db.release();
281
+ }
282
+ catch {
283
+ /* swallow — release failures are logged at the boundary */
284
+ }
285
+ }
286
+ }
287
+ };
288
+ }
@@ -4,6 +4,12 @@ import type { Actor, Tenant } from '../types/index.js';
4
4
  * Extract the Actor from API Gateway HTTP API v2 event with JWT authorizer.
5
5
  * Returns a user actor with claims from the JWT token.
6
6
  *
7
+ * Empty-string claims are treated as missing for the same reason documented
8
+ * on {@link extractTenantFromApiGatewayEvent} — the Cognito Pre-Token-Generation
9
+ * Lambda writes empty strings as "no value" placeholders, and the runtime must
10
+ * not surface a stray empty `sub` or `tenantId` to downstream code that uses
11
+ * them as map keys or DB identifiers.
12
+ *
7
13
  * @param event - API Gateway HTTP API v2 event with JWT authorizer.
8
14
  * @returns Actor extracted from JWT claims.
9
15
  */
@@ -14,6 +20,12 @@ export declare function extractActorFromApiGatewayEvent(event: APIGatewayProxyEv
14
20
  * workspaceId comes from custom:workspaceId claim or defaults to 'unknown'.
15
21
  * orgId comes from custom:orgId claim if present.
16
22
  *
23
+ * Empty-string claims are treated as missing — the Cognito Pre-Token-Generation
24
+ * Lambda (see `scaffold-src/modules/auth/infra/modules/auth/PreTokenLambda.ts.tpl`)
25
+ * intentionally writes `custom:orgId: ''` for users with no org membership, and
26
+ * downstream consumers must not see a stray empty-string ID slip into RLS
27
+ * bindings or audit logs. The runtime ignores those the same as a missing key.
28
+ *
17
29
  * @param event - API Gateway HTTP API v2 event with JWT authorizer.
18
30
  * @returns Tenant extracted from claims and path parameters.
19
31
  */
@@ -2,18 +2,23 @@
2
2
  * Extract the Actor from API Gateway HTTP API v2 event with JWT authorizer.
3
3
  * Returns a user actor with claims from the JWT token.
4
4
  *
5
+ * Empty-string claims are treated as missing for the same reason documented
6
+ * on {@link extractTenantFromApiGatewayEvent} — the Cognito Pre-Token-Generation
7
+ * Lambda writes empty strings as "no value" placeholders, and the runtime must
8
+ * not surface a stray empty `sub` or `tenantId` to downstream code that uses
9
+ * them as map keys or DB identifiers.
10
+ *
5
11
  * @param event - API Gateway HTTP API v2 event with JWT authorizer.
6
12
  * @returns Actor extracted from JWT claims.
7
13
  */
8
14
  export function extractActorFromApiGatewayEvent(event) {
9
15
  const claims = event.requestContext?.authorizer?.jwt?.claims ?? {};
16
+ const nonEmpty = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
10
17
  return {
11
18
  type: 'user',
12
- sub: typeof claims.sub === 'string' ? claims.sub : undefined,
13
- email: typeof claims.email === 'string' ? claims.email : undefined,
14
- tenantId: typeof claims['custom:tenantId'] === 'string'
15
- ? claims['custom:tenantId']
16
- : undefined,
19
+ sub: nonEmpty(claims.sub),
20
+ email: nonEmpty(claims.email),
21
+ tenantId: nonEmpty(claims['custom:tenantId']),
17
22
  };
18
23
  }
19
24
  /**
@@ -22,19 +27,22 @@ export function extractActorFromApiGatewayEvent(event) {
22
27
  * workspaceId comes from custom:workspaceId claim or defaults to 'unknown'.
23
28
  * orgId comes from custom:orgId claim if present.
24
29
  *
30
+ * Empty-string claims are treated as missing — the Cognito Pre-Token-Generation
31
+ * Lambda (see `scaffold-src/modules/auth/infra/modules/auth/PreTokenLambda.ts.tpl`)
32
+ * intentionally writes `custom:orgId: ''` for users with no org membership, and
33
+ * downstream consumers must not see a stray empty-string ID slip into RLS
34
+ * bindings or audit logs. The runtime ignores those the same as a missing key.
35
+ *
25
36
  * @param event - API Gateway HTTP API v2 event with JWT authorizer.
26
37
  * @returns Tenant extracted from claims and path parameters.
27
38
  */
28
39
  export function extractTenantFromApiGatewayEvent(event) {
29
40
  const claims = event.requestContext?.authorizer?.jwt?.claims ?? {};
30
- const tenantId = (typeof claims['custom:tenantId'] === 'string' ? claims['custom:tenantId'] : undefined)
31
- ?? event.pathParameters?.tenantId
41
+ const nonEmpty = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
42
+ const tenantId = nonEmpty(claims['custom:tenantId'])
43
+ ?? nonEmpty(event.pathParameters?.tenantId)
32
44
  ?? 'unknown';
33
- const workspaceId = typeof claims['custom:workspaceId'] === 'string'
34
- ? claims['custom:workspaceId']
35
- : 'unknown';
36
- const orgId = typeof claims['custom:orgId'] === 'string'
37
- ? claims['custom:orgId']
38
- : undefined;
45
+ const workspaceId = nonEmpty(claims['custom:workspaceId']) ?? 'unknown';
46
+ const orgId = nonEmpty(claims['custom:orgId']);
39
47
  return { id: tenantId, workspaceId, orgId };
40
48
  }
@@ -1,4 +1,12 @@
1
1
  import type { DomainFilesContext } from '../ctx/files.js';
2
+ /**
3
+ * Tenant-scoped S3 wrapper. All keys are automatically prefixed with
4
+ * `{tenantId}/` — developers supply only the path after the tenant prefix.
5
+ *
6
+ * Path-traversal patterns (`../`, `./`, leading `/`, empty keys) are rejected
7
+ * before the tenant prefix is applied, so every object operation is scoped to
8
+ * the runtime tenant regardless of caller input.
9
+ */
2
10
  export declare function createFiles(opts: {
3
11
  bucketName: string;
4
12
  tenantId: string;