@mettlecast/domain-runtime 0.2.59 → 0.2.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -40
- package/dist/primitives/action.d.ts +81 -8
- package/dist/primitives/action.js +68 -2
- package/dist/runtime/action-executor.d.ts +164 -0
- package/dist/runtime/action-executor.js +408 -0
- package/dist/runtime/action-handler.d.ts +110 -6
- package/dist/runtime/action-handler.js +110 -10
- package/dist/runtime/actions.d.ts +65 -5
- package/dist/runtime/actions.js +118 -13
- package/dist/runtime/audit.d.ts +23 -3
- package/dist/runtime/audit.js +20 -3
- package/dist/runtime/db.d.ts +23 -4
- package/dist/runtime/db.js +27 -6
- package/dist/runtime/error-formatter.d.ts +63 -0
- package/dist/runtime/error-formatter.js +68 -1
- package/dist/runtime/exposed-action-api-handler.d.ts +88 -0
- package/dist/runtime/exposed-action-api-handler.js +288 -0
- package/dist/runtime/extractors.d.ts +12 -0
- package/dist/runtime/extractors.js +21 -13
- package/dist/runtime/files.d.ts +8 -0
- package/dist/runtime/files.js +76 -8
- package/dist/runtime/hydrate.js +17 -2
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +18 -0
- package/dist/runtime/observability-bindings.d.ts +110 -0
- package/dist/runtime/observability-bindings.js +94 -0
- package/dist/runtime/store.d.ts +8 -0
- package/dist/runtime/store.js +29 -0
- package/dist/runtime/tracer.d.ts +1 -1
- package/dist/runtime/tracer.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/rls.d.ts +31 -0
- package/dist/schema/rls.js +44 -0
- package/dist/types/tenant.d.ts +12 -0
- package/package.json +1 -1
|
@@ -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:
|
|
13
|
-
email:
|
|
14
|
-
tenantId:
|
|
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
|
|
31
|
-
|
|
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 =
|
|
34
|
-
|
|
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
|
}
|
package/dist/runtime/files.d.ts
CHANGED
|
@@ -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;
|