@zudojs/api 0.1.0 → 1.0.0

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -9
  3. package/dist/api/constants.d.ts +32 -2
  4. package/dist/api/constants.js +32 -2
  5. package/dist/api/context/context.type.d.ts +38 -0
  6. package/dist/api/context/context.type.js +86 -5
  7. package/dist/api/context/contextKey.type.d.ts +13 -0
  8. package/dist/api/context/contextKey.type.js +6 -0
  9. package/dist/api/errors/index.d.ts +1 -1
  10. package/dist/api/errors/index.js +1 -1
  11. package/dist/api/executor/executor.core.d.ts +81 -11
  12. package/dist/api/executor/executor.core.js +267 -29
  13. package/dist/api/executor/index.d.ts +1 -0
  14. package/dist/api/handler/handler.type.d.ts +5 -2
  15. package/dist/api/interceptors/interceptor.type.d.ts +18 -1
  16. package/dist/api/operation/operation.type.d.ts +90 -0
  17. package/dist/api/operation/operation.type.js +115 -3
  18. package/dist/api/registry/operationRegistry.core.d.ts +15 -2
  19. package/dist/api/registry/operationRegistry.core.js +32 -4
  20. package/dist/index.d.ts +21 -12
  21. package/dist/index.js +19 -12
  22. package/package.json +24 -17
  23. package/dist/.tsbuildinfo +0 -1
  24. package/dist/api/constants.d.ts.map +0 -1
  25. package/dist/api/constants.js.map +0 -1
  26. package/dist/api/context/context.type.d.ts.map +0 -1
  27. package/dist/api/context/context.type.js.map +0 -1
  28. package/dist/api/context/contextKey.type.d.ts.map +0 -1
  29. package/dist/api/context/contextKey.type.js.map +0 -1
  30. package/dist/api/errors/index.d.ts.map +0 -1
  31. package/dist/api/errors/index.js.map +0 -1
  32. package/dist/api/executor/executor.core.d.ts.map +0 -1
  33. package/dist/api/executor/executor.core.js.map +0 -1
  34. package/dist/api/executor/index.d.ts.map +0 -1
  35. package/dist/api/executor/index.js.map +0 -1
  36. package/dist/api/handler/handler.type.d.ts.map +0 -1
  37. package/dist/api/handler/handler.type.js.map +0 -1
  38. package/dist/api/interceptors/interceptor.type.d.ts.map +0 -1
  39. package/dist/api/interceptors/interceptor.type.js.map +0 -1
  40. package/dist/api/operation/operation.type.d.ts.map +0 -1
  41. package/dist/api/operation/operation.type.js.map +0 -1
  42. package/dist/api/registry/index.d.ts.map +0 -1
  43. package/dist/api/registry/index.js.map +0 -1
  44. package/dist/api/registry/operationRegistry.core.d.ts.map +0 -1
  45. package/dist/api/registry/operationRegistry.core.js.map +0 -1
  46. package/dist/api/result/apiResult.type.d.ts.map +0 -1
  47. package/dist/api/result/apiResult.type.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zudojs/api
2
2
 
3
- Higher-level API layer — operation definitions, execution context, interceptors, policies, and a transport-agnostic executor. Sits above `@zudojs/http` and `@zudojs/cqrs`.
3
+ Higher-level API layer — operation definitions, execution context, interceptors, and a transport-agnostic executor. Sits above `@zudojs/http` and `@zudojs/cqrs`.
4
4
 
5
5
  ## When to use
6
6
 
@@ -20,36 +20,160 @@ npm install @zudojs/api
20
20
 
21
21
  ```typescript
22
22
  import {
23
+ // operations
23
24
  defineOperation,
24
- APIExecutor,
25
+ resolveOperationTimeout,
25
26
  APIOperationRegistry,
27
+ // execution
28
+ APIExecutor,
26
29
  createNoopInterceptor,
27
30
  normalizeAPIError,
31
+ // context
32
+ createAPIContext,
33
+ createContextKey,
34
+ normalizeRequestId,
35
+ isValidRequestId,
36
+ RequestIdContextKey,
37
+ CorrelationIdContextKey,
38
+ TenantIdContextKey,
39
+ UserIdContextKey,
40
+ StartTimeContextKey,
41
+ // results
42
+ apiSuccess,
43
+ apiFailure,
44
+ isApiSuccess,
45
+ isApiFailure,
46
+ // constants
47
+ DEFAULT_OPERATION_TIMEOUT,
48
+ MAX_OPERATION_TIMEOUT,
49
+ MAX_INTERCEPTORS,
50
+ MAX_VALIDATION_ISSUES,
28
51
  type APIContext,
29
52
  type APIContextKey,
53
+ type APIExecutionContext,
54
+ type APIExecutorOptions,
30
55
  type APIHandler,
31
56
  type APIInterceptor,
32
- type OperationDefinition,
57
+ type APIOperation,
58
+ type APIOperationMetadata,
59
+ type APIResult,
60
+ type DefineOperationOptions,
33
61
  type APIErrorOptions,
34
62
  } from "@zudojs/api";
35
63
  ```
36
64
 
65
+ Every API error class from `@zudojs/errors` (`APIError`, `APIValidationError`, `APIInternalError`, `APIOperationNotFoundError`, …) plus `createAPIError`, `isAPIError` and `ErrorCode` are re-exported for convenience.
66
+
37
67
  ## Usage
38
68
 
39
69
  ```typescript
40
- import { defineOperation, APIOperationRegistry } from "@zudojs/api";
70
+ import {
71
+ APIExecutor,
72
+ APIOperationRegistry,
73
+ createAPIContext,
74
+ defineOperation,
75
+ normalizeRequestId,
76
+ } from "@zudojs/api";
41
77
 
42
- const getUser = defineOperation({
43
- name: "GetUser",
44
- input: { id: "string" },
45
- handler: async ({ input, context }) => db.findUser(input.id),
78
+ // 1. Define the operation. The handler is positional: (input, context).
79
+ const getUser = defineOperation<{ id: string }, { id: string; name: string }>({
80
+ name: "users.get",
81
+ input: GetUserSchema, // any Standard Schema (Zod, Valibot, ArkType, …)
82
+ output: UserSchema, // validated too — see "Output validation"
83
+ timeout: 5_000,
84
+ metadata: { tags: ["Users"] },
85
+ handler: async (input, context) => db.findUser(input.id, context.signal),
46
86
  });
47
87
 
88
+ // 2. Register it.
48
89
  const registry = new APIOperationRegistry();
49
90
  registry.register(getUser);
50
- const result = await registry.dispatch("GetUser", { id: "u_1" }, ctx);
91
+ registry.freeze();
92
+
93
+ // 3. Execute it. The registry looks operations up; the executor runs them.
94
+ const executor = new APIExecutor();
95
+ const context = createAPIContext(
96
+ normalizeRequestId(request.headers["x-request-id"]),
97
+ { locale: "en" },
98
+ );
99
+
100
+ const result = await executor.execute(
101
+ registry.require("users.get"), // throws APIOperationNotFoundError (404)
102
+ { id: "u_1" },
103
+ context,
104
+ );
105
+
106
+ if (result.ok) {
107
+ respond(200, result.data);
108
+ } else {
109
+ respond(result.error.statusCode, {
110
+ message: result.error.expose ? result.error.message : "Internal error",
111
+ });
112
+ }
113
+ ```
114
+
115
+ Results are frozen `{ ok: true, data }` / `{ ok: false, error }` objects — `execute` never throws for an operation failure.
116
+
117
+ ## Input validation
118
+
119
+ When `operation.input` is a Standard Schema, the executor validates the input before the handler runs and passes the schema's *transformed* value to the handler. Failures return an `APIValidationError` (422).
120
+
121
+ Schema issue messages routinely interpolate the value that failed, so by default the executor does **not** copy them into the client-facing error: each issue becomes `"<path>: invalid"` (e.g. `"user.email: invalid"`), naming where validation failed without echoing what was submitted. The list is capped at `MAX_VALIDATION_ISSUES` entries with a trailing `"… and N more issue(s) omitted."` marker.
122
+
123
+ ```typescript
124
+ // Opt in to raw messages only when every schema in the process is known
125
+ // to produce value-free messages.
126
+ new APIExecutor({ exposeValidationMessages: true, maxValidationIssues: 10 });
127
+ ```
128
+
129
+ ## Output validation
130
+
131
+ When `operation.output` is a Standard Schema, the handler's return value is validated too, and the validated (possibly stripped or transformed) value becomes `result.data`. A mismatch is a server bug, so it fails with an `APIInternalError` (500, `expose: false`) naming only the failing paths.
132
+
133
+ ## Timeouts
134
+
135
+ Every operation has a deadline. `timeout` must be a positive, finite integer of at most `MAX_OPERATION_TIMEOUT` milliseconds; `defineOperation` rejects `0`, negatives, `NaN` and non-integers rather than silently running unbounded. Precedence is `timeout` → `metadata.timeout` → `DEFAULT_OPERATION_TIMEOUT`, resolved by `resolveOperationTimeout`.
136
+
137
+ The handler itself is not cancellable — pass `context.signal` into anything that supports it. An execution cancelled through the signal fails with `ErrorCode.OPERATION_CANCELLED`; branch on that code rather than on the (nginx-convention) 499 status.
138
+
139
+ ## Interceptors
140
+
141
+ ```typescript
142
+ const timing: APIInterceptor = {
143
+ async intercept(context, next) {
144
+ context.input = sanitize(context.input); // reaches the handler
145
+ const started = Date.now();
146
+ const result = await next();
147
+ // context.result === result, including when a downstream interceptor
148
+ // short-circuits without calling next().
149
+ log(context.operation.name, Date.now() - started, result.ok);
150
+ return result;
151
+ },
152
+ };
153
+
154
+ new APIExecutor([timing]); // or new APIExecutor({ interceptors: [timing] })
51
155
  ```
52
156
 
157
+ At most `MAX_INTERCEPTORS` interceptors per executor, and each `next()` may be awaited once.
158
+
159
+ ## Context
160
+
161
+ `APIContext` carries the request id, an optional `AbortSignal`, caller state, and typed key/value slots. Keys carry their own identity, so two keys created with the same name never collide:
162
+
163
+ ```typescript
164
+ const FeatureFlagsKey = createContextKey<readonly string[]>("featureFlags");
165
+
166
+ context.set(FeatureFlagsKey, ["beta"]);
167
+ context.get(FeatureFlagsKey); // readonly string[] | undefined
168
+ context.metadata; // read-only snapshot keyed by key name
169
+ ```
170
+
171
+ `createAPIContext` requires a safe request id (non-empty, ≤128 chars, `[A-Za-z0-9._:-]`) and throws otherwise. Run client-supplied header values through `normalizeRequestId` first — it replaces anything unsafe with a generated UUID, so a request id can never inject a log line or split a response header. `requestId` cannot be reassigned through `RequestIdContextKey`.
172
+
173
+ ## Errors
174
+
175
+ `normalizeAPIError(error, operationName?)` converts anything thrown into an `APIError`. Non-API errors become an `APIInternalError` with a generic message and the original on `cause` — the internal message is deliberately not copied onto the wrapper, because `BaseError.toJSON()` serializes `message`, `stack` and `cause` regardless of `expose`.
176
+
53
177
  ## License
54
178
 
55
179
  MIT
@@ -5,14 +5,44 @@
5
5
  */
6
6
  /**
7
7
  * Default timeout for API operations (30 seconds).
8
+ *
9
+ * Applied by {@link defineOperation} when no explicit timeout is given, and
10
+ * by the executor as a fail-safe for hand-rolled operation objects.
8
11
  */
9
12
  export declare const DEFAULT_OPERATION_TIMEOUT = 30000;
13
+ /**
14
+ * Maximum timeout accepted for a single operation (1 hour).
15
+ *
16
+ * Enforced by `defineOperation`. A larger value is almost always a unit
17
+ * mistake (seconds vs milliseconds) rather than an intentional deadline.
18
+ */
19
+ export declare const MAX_OPERATION_TIMEOUT = 3600000;
10
20
  /**
11
21
  * Maximum number of interceptors allowed in a pipeline.
22
+ * Enforced by the APIExecutor constructor.
12
23
  */
13
24
  export declare const MAX_INTERCEPTORS = 32;
14
25
  /**
15
- * Maximum number of policies allowed on a single operation.
26
+ * Maximum number of validation issues carried on an `APIValidationError`
27
+ * produced by the executor.
28
+ *
29
+ * Schema libraries emit one issue per failing element, so an array input
30
+ * can produce an unbounded number of issues. The executor truncates the
31
+ * list at this many entries and appends a single marker entry recording
32
+ * how many were dropped.
33
+ */
34
+ export declare const MAX_VALIDATION_ISSUES = 20;
35
+ /**
36
+ * Maximum length of a single validation issue string produced by the
37
+ * executor. Longer strings are truncated with an ellipsis.
38
+ */
39
+ export declare const MAX_VALIDATION_ISSUE_LENGTH = 200;
40
+ /**
41
+ * Maximum length of an operation name accepted by `defineOperation`.
42
+ */
43
+ export declare const MAX_OPERATION_NAME_LENGTH = 128;
44
+ /**
45
+ * Maximum length of a request id accepted by `createAPIContext`.
16
46
  */
17
- export declare const MAX_POLICIES = 16;
47
+ export declare const MAX_REQUEST_ID_LENGTH = 128;
18
48
  //# sourceMappingURL=constants.d.ts.map
@@ -5,14 +5,44 @@
5
5
  */
6
6
  /**
7
7
  * Default timeout for API operations (30 seconds).
8
+ *
9
+ * Applied by {@link defineOperation} when no explicit timeout is given, and
10
+ * by the executor as a fail-safe for hand-rolled operation objects.
8
11
  */
9
12
  export const DEFAULT_OPERATION_TIMEOUT = 30_000;
13
+ /**
14
+ * Maximum timeout accepted for a single operation (1 hour).
15
+ *
16
+ * Enforced by `defineOperation`. A larger value is almost always a unit
17
+ * mistake (seconds vs milliseconds) rather than an intentional deadline.
18
+ */
19
+ export const MAX_OPERATION_TIMEOUT = 3_600_000;
10
20
  /**
11
21
  * Maximum number of interceptors allowed in a pipeline.
22
+ * Enforced by the APIExecutor constructor.
12
23
  */
13
24
  export const MAX_INTERCEPTORS = 32;
14
25
  /**
15
- * Maximum number of policies allowed on a single operation.
26
+ * Maximum number of validation issues carried on an `APIValidationError`
27
+ * produced by the executor.
28
+ *
29
+ * Schema libraries emit one issue per failing element, so an array input
30
+ * can produce an unbounded number of issues. The executor truncates the
31
+ * list at this many entries and appends a single marker entry recording
32
+ * how many were dropped.
33
+ */
34
+ export const MAX_VALIDATION_ISSUES = 20;
35
+ /**
36
+ * Maximum length of a single validation issue string produced by the
37
+ * executor. Longer strings are truncated with an ellipsis.
38
+ */
39
+ export const MAX_VALIDATION_ISSUE_LENGTH = 200;
40
+ /**
41
+ * Maximum length of an operation name accepted by `defineOperation`.
42
+ */
43
+ export const MAX_OPERATION_NAME_LENGTH = 128;
44
+ /**
45
+ * Maximum length of a request id accepted by `createAPIContext`.
16
46
  */
17
- export const MAX_POLICIES = 16;
47
+ export const MAX_REQUEST_ID_LENGTH = 128;
18
48
  //# sourceMappingURL=constants.js.map
@@ -10,11 +10,49 @@ export interface APIContext<TState = unknown> {
10
10
  readonly signal?: AbortSignal;
11
11
  readonly state: TState;
12
12
  get<T>(key: APIContextKey<T>): T | undefined;
13
+ /**
14
+ * Stores a value under a typed key.
15
+ *
16
+ * @throws {TypeError} for {@link RequestIdContextKey}, which is fixed at
17
+ * construction so `get(RequestIdContextKey)` can never disagree with
18
+ * `context.requestId`.
19
+ */
13
20
  set<T>(key: APIContextKey<T>, value: T): void;
21
+ /**
22
+ * Read-only snapshot of the context values, keyed by context key name.
23
+ *
24
+ * This is a genuine read-only façade, not the context's backing store:
25
+ * it has no mutating methods at all, so casting it to `Map` and calling
26
+ * `set`/`delete`/`clear` throws rather than mutating the context.
27
+ */
14
28
  readonly metadata: ReadonlyMap<string, unknown>;
15
29
  }
30
+ /**
31
+ * Determines whether a value is usable as a request id.
32
+ *
33
+ * A valid request id is a non-empty string of at most
34
+ * {@link MAX_REQUEST_ID_LENGTH} characters drawn from `[A-Za-z0-9._:-]`.
35
+ * The charset excludes CR/LF and other control characters, so a request id
36
+ * is always safe to place in a log line or a response header.
37
+ */
38
+ export declare function isValidRequestId(value: unknown): value is string;
39
+ /**
40
+ * Normalizes an untrusted request id.
41
+ *
42
+ * Transports typically populate the request id from an inbound
43
+ * `X-Request-Id` / `X-Correlation-Id` header, i.e. straight from the
44
+ * network. Pass that value through this function before handing it to
45
+ * {@link createAPIContext}: valid ids are returned unchanged, anything
46
+ * else (missing, empty, over-long, or containing characters that would
47
+ * allow log injection or response splitting) is replaced by a freshly
48
+ * generated UUID.
49
+ */
50
+ export declare function normalizeRequestId(value: unknown): string;
16
51
  /**
17
52
  * Creates a new API context.
53
+ *
54
+ * @throws {TypeError} if `requestId` is not a valid request id. Use
55
+ * {@link normalizeRequestId} for client-supplied values.
18
56
  */
19
57
  export declare function createAPIContext<TState = unknown>(requestId: string, state: TState, signal?: AbortSignal): APIContext<TState>;
20
58
  export type { APIContextKey };
@@ -1,25 +1,106 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { MAX_REQUEST_ID_LENGTH } from "../constants.js";
1
3
  import { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, } from "./contextKey.type.js";
4
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
5
+ /**
6
+ * Determines whether a value is usable as a request id.
7
+ *
8
+ * A valid request id is a non-empty string of at most
9
+ * {@link MAX_REQUEST_ID_LENGTH} characters drawn from `[A-Za-z0-9._:-]`.
10
+ * The charset excludes CR/LF and other control characters, so a request id
11
+ * is always safe to place in a log line or a response header.
12
+ */
13
+ export function isValidRequestId(value) {
14
+ return (typeof value === "string" &&
15
+ value.length > 0 &&
16
+ value.length <= MAX_REQUEST_ID_LENGTH &&
17
+ REQUEST_ID_PATTERN.test(value));
18
+ }
19
+ /**
20
+ * Normalizes an untrusted request id.
21
+ *
22
+ * Transports typically populate the request id from an inbound
23
+ * `X-Request-Id` / `X-Correlation-Id` header, i.e. straight from the
24
+ * network. Pass that value through this function before handing it to
25
+ * {@link createAPIContext}: valid ids are returned unchanged, anything
26
+ * else (missing, empty, over-long, or containing characters that would
27
+ * allow log injection or response splitting) is replaced by a freshly
28
+ * generated UUID.
29
+ */
30
+ export function normalizeRequestId(value) {
31
+ return isValidRequestId(value) ? value : randomUUID();
32
+ }
2
33
  /**
3
34
  * Creates a new API context.
35
+ *
36
+ * @throws {TypeError} if `requestId` is not a valid request id. Use
37
+ * {@link normalizeRequestId} for client-supplied values.
4
38
  */
5
39
  export function createAPIContext(requestId, state, signal) {
6
- const metadata = new Map();
7
- metadata.set(RequestIdContextKey.name, requestId);
40
+ if (!isValidRequestId(requestId)) {
41
+ throw new TypeError(`Invalid requestId: expected a non-empty string of at most ${MAX_REQUEST_ID_LENGTH} characters matching ${REQUEST_ID_PATTERN.source}. Use normalizeRequestId() for client-supplied values.`);
42
+ }
43
+ // Values are stored under the key's unique `id` symbol, so keys sharing
44
+ // a `name` never share a slot.
45
+ const store = new Map();
46
+ const names = new Map();
47
+ const remember = (key) => {
48
+ names.set(key.id, key.name);
49
+ };
50
+ remember(RequestIdContextKey);
51
+ store.set(RequestIdContextKey.id, requestId);
52
+ let view;
8
53
  const context = {
9
54
  requestId,
10
55
  signal,
11
56
  state,
12
57
  get(key) {
13
- return metadata.get(key.name);
58
+ return store.get(key.id);
14
59
  },
15
60
  set(key, value) {
16
- metadata.set(key.name, value);
61
+ if (key.id === RequestIdContextKey.id) {
62
+ throw new TypeError("requestId is fixed at context creation and cannot be reassigned.");
63
+ }
64
+ remember(key);
65
+ store.set(key.id, value);
66
+ view = undefined;
17
67
  },
18
68
  get metadata() {
19
- return new Map(metadata);
69
+ if (view === undefined) {
70
+ const snapshot = new Map();
71
+ for (const [id, value] of store) {
72
+ snapshot.set(names.get(id) ?? id.toString(), value);
73
+ }
74
+ view = createReadonlyMapView(snapshot);
75
+ }
76
+ return view;
20
77
  },
21
78
  };
22
79
  return Object.freeze(context);
23
80
  }
81
+ /**
82
+ * Wraps a private map in a frozen object exposing only the read half of
83
+ * the `ReadonlyMap` interface. The wrapped map is unreachable from the
84
+ * returned value, so there is no cast that recovers mutation rights.
85
+ */
86
+ function createReadonlyMapView(source) {
87
+ const view = {
88
+ get size() {
89
+ return source.size;
90
+ },
91
+ get: (key) => source.get(key),
92
+ has: (key) => source.has(key),
93
+ keys: () => source.keys(),
94
+ values: () => source.values(),
95
+ entries: () => source.entries(),
96
+ forEach: (callback, thisArg) => {
97
+ for (const [key, value] of source) {
98
+ callback.call(thisArg, value, key, view);
99
+ }
100
+ },
101
+ [Symbol.iterator]: () => source[Symbol.iterator](),
102
+ };
103
+ return Object.freeze(view);
104
+ }
24
105
  export { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, };
25
106
  //# sourceMappingURL=context.type.js.map
@@ -8,13 +8,26 @@
8
8
  */
9
9
  /**
10
10
  * A typed key for storing and retrieving values from the API context.
11
+ *
12
+ * Keys carry a unique `id` symbol, so two keys created with the same
13
+ * `name` are distinct slots: a third-party key can never collide with —
14
+ * or be read as the wrong type through — a key owned by someone else.
15
+ * `name` is retained for debugging and for the context's `metadata` view.
16
+ *
17
+ * Keys must be created with {@link createContextKey}; the `id` symbol is
18
+ * the identity the context stores values under.
11
19
  */
12
20
  export interface APIContextKey<T> {
13
21
  readonly name: string;
22
+ /** Unique identity for this key. Two keys never share an id. */
23
+ readonly id: symbol;
24
+ /** Phantom type carrier. Never populated at runtime. */
14
25
  readonly type: T;
15
26
  }
16
27
  /**
17
28
  * Creates a typed context key.
29
+ *
30
+ * @throws {TypeError} if `name` is not a non-empty string.
18
31
  */
19
32
  export declare function createContextKey<T>(name: string): APIContextKey<T>;
20
33
  /**
@@ -8,10 +8,16 @@
8
8
  */
9
9
  /**
10
10
  * Creates a typed context key.
11
+ *
12
+ * @throws {TypeError} if `name` is not a non-empty string.
11
13
  */
12
14
  export function createContextKey(name) {
15
+ if (typeof name !== "string" || name.length === 0) {
16
+ throw new TypeError("Context key name must be a non-empty string.");
17
+ }
13
18
  return Object.freeze({
14
19
  name,
20
+ id: Symbol(name),
15
21
  type: undefined,
16
22
  });
17
23
  }
@@ -5,6 +5,6 @@
5
5
  *
6
6
  * Re-exported from @zudojs/errors for convenience.
7
7
  */
8
- export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, } from "@zudojs/errors";
8
+ export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, ErrorCode, } from "@zudojs/errors";
9
9
  export type { APIErrorOptions } from "@zudojs/errors";
10
10
  //# sourceMappingURL=index.d.ts.map
@@ -5,5 +5,5 @@
5
5
  *
6
6
  * Re-exported from @zudojs/errors for convenience.
7
7
  */
8
- export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, } from "@zudojs/errors";
8
+ export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, ErrorCode, } from "@zudojs/errors";
9
9
  //# sourceMappingURL=index.js.map
@@ -2,31 +2,101 @@ import type { APIContext } from "../context/context.type.js";
2
2
  import type { APIResult } from "../result/apiResult.type.js";
3
3
  import type { APIOperation } from "../operation/operation.type.js";
4
4
  import type { APIInterceptor } from "../interceptors/interceptor.type.js";
5
+ import { APIError } from "../errors/index.js";
6
+ export type { APIExecutionContext } from "../interceptors/interceptor.type.js";
5
7
  /**
6
- * Execution context passed through the interceptor pipeline.
8
+ * Error normalizer for converting unknown errors into APIError instances.
9
+ *
10
+ * APIErrors pass through untouched. Everything else is wrapped in an
11
+ * `APIInternalError` (`expose: false`) carrying a generic message; the
12
+ * original error is preserved on `cause` for logging.
13
+ *
14
+ * The wrapper's own message is deliberately *not* a copy of the original.
15
+ * `BaseError.toJSON()` in `@zudojs/errors` 0.1.0 emits `message`, `stack`
16
+ * and the serialized `cause` regardless of `expose`, so a transport doing
17
+ * `res.json(result.error)` would otherwise ship the raw driver message
18
+ * (connection strings, constraint names, file paths, tokens) to a client.
7
19
  */
8
- export interface APIExecutionContext<TInput = unknown, TOutput = unknown> {
9
- readonly operation: APIOperation<TInput, TOutput>;
10
- readonly input: TInput;
11
- readonly context: APIContext;
12
- readonly result?: APIResult<TOutput>;
13
- }
20
+ export declare function normalizeAPIError(error: unknown, operationName?: string): APIError;
14
21
  /**
15
- * Error normalizer for converting unknown errors into Error instances.
22
+ * Options for {@link APIExecutor}.
16
23
  */
17
- export declare function normalizeAPIError(error: unknown, operationName?: string): Error;
24
+ export interface APIExecutorOptions {
25
+ /** Interceptor pipeline, outermost first. */
26
+ readonly interceptors?: readonly APIInterceptor[];
27
+ /**
28
+ * Whether raw schema issue messages are copied into the client-facing
29
+ * `APIValidationError` (which is `expose: true`).
30
+ *
31
+ * Default `false`. Schema messages routinely interpolate the received
32
+ * value — Zod's built-in messages do for several checks, and most
33
+ * hand-written `message:` strings do — which would put submitted
34
+ * secrets straight into a 422 body. With the default, clients receive
35
+ * one entry per failing path (`"user.email: invalid"`) naming *where*
36
+ * validation failed but never echoing *what* was submitted.
37
+ *
38
+ * Set to `true` only when every schema in the process is known to
39
+ * produce value-free messages.
40
+ */
41
+ readonly exposeValidationMessages?: boolean;
42
+ /**
43
+ * Maximum number of validation issues carried on a single
44
+ * `APIValidationError`. Defaults to {@link MAX_VALIDATION_ISSUES}.
45
+ *
46
+ * A schema over a large array emits one issue per failing element, so
47
+ * an uncapped list is an amplification vector: the executor is the
48
+ * layer on the untrusted-input boundary and caps it here.
49
+ */
50
+ readonly maxValidationIssues?: number;
51
+ }
18
52
  /**
19
53
  * Executes an API operation through its interceptor pipeline.
54
+ *
55
+ * Enforces the operation timeout, honors the context AbortSignal, and
56
+ * validates input and output when the operation's `input` / `output` is a
57
+ * Standard Schema.
20
58
  */
21
59
  export declare class APIExecutor {
22
60
  private readonly interceptors;
23
- constructor(interceptors?: readonly APIInterceptor[]);
61
+ private readonly exposeValidationMessages;
62
+ private readonly maxValidationIssues;
63
+ constructor(optionsOrInterceptors?: readonly APIInterceptor[] | APIExecutorOptions);
24
64
  /**
25
65
  * Executes an operation with the given input and context.
26
66
  */
27
67
  execute<TInput = unknown, TOutput = unknown>(operation: APIOperation<TInput, TOutput>, input: TInput, context: APIContext): Promise<APIResult<TOutput>>;
28
68
  /**
29
- * Runs the interceptor pipeline.
69
+ * Invokes the operation handler under its timeout and abort signal, and
70
+ * validates the handler's output when `operation.output` is a Standard
71
+ * Schema.
72
+ */
73
+ private invokeHandler;
74
+ /**
75
+ * Validates handler output against `operation.output`.
76
+ *
77
+ * A response that does not match its declared schema is a server bug,
78
+ * not a client mistake, so failures surface as an `APIInternalError`
79
+ * (500, `expose: false`) naming only the failing paths — never the
80
+ * offending values, which are exactly the fields (password hashes,
81
+ * internal audit columns) that should not reach a client.
82
+ */
83
+ private validateOutput;
84
+ /**
85
+ * Converts schema issues into the capped, redacted list carried on the
86
+ * client-facing `APIValidationError`.
87
+ */
88
+ private clientIssues;
89
+ /**
90
+ * Runs the interceptor pipeline (Koa-style dispatch).
91
+ *
92
+ * Each interceptor's `next()` may be awaited at most once; a second
93
+ * call rejects instead of silently re-executing the handler while
94
+ * bypassing downstream interceptors.
95
+ *
96
+ * `context.result` is assigned as each level resolves, so an
97
+ * interceptor reading it after `await next()` sees exactly what the
98
+ * level below returned — including when a downstream interceptor
99
+ * short-circuits without calling `next()`.
30
100
  */
31
101
  private runPipeline;
32
102
  }