@ultimat3/action 30.8.0 → 31.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.
- package/CLAUDE.md +17 -32
- package/README.md +57 -49
- package/package.json +11 -9
- package/src/action-stream.ts +17 -1
- package/src/adapter.ts +22 -0
- package/src/audit-gate.ts +10 -8
- package/src/audit-memory.ts +2 -2
- package/src/audit-postgres.ts +7 -10
- package/src/audit.ts +2 -2
- package/src/client.ts +7 -6
- package/src/error-titles.ts +1 -1
- package/src/errors-http.ts +1 -1
- package/src/errors-idempotency.ts +2 -2
- package/src/errors-meter.ts +1 -1
- package/src/errors-stream.ts +1 -1
- package/src/errors.ts +29 -43
- package/src/exercise.ts +2 -1
- package/src/handler-override.ts +18 -0
- package/src/idempotency-memory.ts +14 -2
- package/src/idempotency-postgres.ts +7 -7
- package/src/idempotency.ts +21 -11
- package/src/index.ts +19 -57
- package/src/invoke.ts +29 -1
- package/src/loader.ts +5 -0
- package/src/meter-store.ts +13 -5
- package/src/openapi.ts +2 -0
- package/src/policy-gate.ts +21 -56
- package/src/rate-limit-gate.ts +18 -29
- package/src/validate.ts +9 -1
- package/src/wire-issues.ts +7 -9
package/src/invoke.ts
CHANGED
|
@@ -16,10 +16,12 @@ import {
|
|
|
16
16
|
anonymousActor,
|
|
17
17
|
ctxOf,
|
|
18
18
|
failureCaptureActive,
|
|
19
|
+
instrument,
|
|
19
20
|
isUltimateError,
|
|
20
21
|
REDACTED,
|
|
21
22
|
recordFailureInput,
|
|
22
23
|
runWithContext,
|
|
24
|
+
subscribed,
|
|
23
25
|
tryUseContext,
|
|
24
26
|
useContext,
|
|
25
27
|
withChildContext,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
import { statusFor } from '@ultimat3/http';
|
|
30
32
|
import { maskSensitive } from '@ultimat3/schema';
|
|
31
33
|
import type { AnyAction, AnyActionDef, InvokeOptions } from './action';
|
|
34
|
+
import { declarationOf, drainsItsHandler } from './action-stream';
|
|
32
35
|
import type { AuditRecord } from './audit';
|
|
33
36
|
import {
|
|
34
37
|
auditFailureFor,
|
|
@@ -40,6 +43,8 @@ import {
|
|
|
40
43
|
import { bustAfterCommit } from './cache-gate';
|
|
41
44
|
import { judgedHandler } from './declared-errors';
|
|
42
45
|
import { ActionForeignError, ActionUnregisteredError } from './errors';
|
|
46
|
+
import type { HandlerOverride } from './handler-override';
|
|
47
|
+
import { handlerOverrideOf, setHandlerOverride } from './handler-override';
|
|
43
48
|
import { getIdempotencyStore, withIdempotency } from './idempotency';
|
|
44
49
|
import { idempotencyKeyFor } from './idempotency-key';
|
|
45
50
|
import { drawMeter } from './meter-gate';
|
|
@@ -83,6 +88,15 @@ export function handlerReached(target: AnyAction): number {
|
|
|
83
88
|
return HANDLER_REACHED.get(defOf(target)) ?? 0;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* A test double where `target`'s `handle` is, on every surface — policy, rate limit, input parse,
|
|
93
|
+
* meter, idempotency and output parse still run. `undefined` restores the declared handler. On
|
|
94
|
+
* `@ultimat3/action/adapter` only: `@ultimat3/testing`'s `stub()` is its caller.
|
|
95
|
+
*/
|
|
96
|
+
export function overrideHandler(target: AnyAction, handle: HandlerOverride | undefined): void {
|
|
97
|
+
setHandlerOverride(declarationOf(defOf(target)), handle);
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
/** Projections need a stable name; an unregistered action has none yet. */
|
|
87
101
|
export function actionName(target: AnyAction): string {
|
|
88
102
|
if (target.name.length === 0) throw new ActionUnregisteredError();
|
|
@@ -233,8 +247,13 @@ async function traced(
|
|
|
233
247
|
'ultimate.actor.kind': ctx.actor.kind,
|
|
234
248
|
'ultimate.idempotent': def.idempotent === true,
|
|
235
249
|
});
|
|
250
|
+
const startedAt = performance.now();
|
|
236
251
|
try {
|
|
237
252
|
const value = await perform(def, name, raw, ctx, options, trace);
|
|
253
|
+
// Every invocation, sampled or not — the span reaches an exporter only when sampled.
|
|
254
|
+
if (subscribed('action.invoke')) {
|
|
255
|
+
instrument('action.invoke', { action: name, durationMs: performance.now() - startedAt });
|
|
256
|
+
}
|
|
238
257
|
span.setAttributes({
|
|
239
258
|
'ultimate.outcome': 'allowed',
|
|
240
259
|
'ultimate.idempotency.replayed': trace.replayed,
|
|
@@ -248,6 +267,13 @@ async function traced(
|
|
|
248
267
|
// are different questions, and a trace that called both "error" cannot separate them.
|
|
249
268
|
span.setAttribute('ultimate.outcome', auditOutcomeFor(error));
|
|
250
269
|
if (isUltimateError(error)) span.setAttribute('ultimate.error.code', error.code);
|
|
270
|
+
if (subscribed('action.invoke')) {
|
|
271
|
+
instrument('action.invoke', {
|
|
272
|
+
action: name,
|
|
273
|
+
durationMs: performance.now() - startedAt,
|
|
274
|
+
error,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
251
277
|
throw error;
|
|
252
278
|
}
|
|
253
279
|
});
|
|
@@ -345,7 +371,9 @@ async function perform(
|
|
|
345
371
|
HANDLER_REACHED.set(def, (HANDLER_REACHED.get(def) ?? 0) + 1);
|
|
346
372
|
// Read here, not closed over: under a key this runs in `runToCompletion`'s scope, and a handler
|
|
347
373
|
// passing the outer `ctx.signal` onward would fail its vendor call and settle a failure.
|
|
348
|
-
const
|
|
374
|
+
const override = drainsItsHandler(def) ? undefined : handlerOverrideOf(def);
|
|
375
|
+
const handle = override ?? def.handle.bind(def);
|
|
376
|
+
const produced = await judgedHandler(def, name, () => handle({ input, ctx: useContext() }));
|
|
349
377
|
return validateOutput(def.output, produced, name);
|
|
350
378
|
};
|
|
351
379
|
// The meter is drawn LAST before the handler, on every surface `surface: 'server'` included: a
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@ultimat3/action/loader` — where an export is NAMED as an action: `x`'s module scan and the
|
|
2
|
+
// framework's own fixtures. Never the `.` barrel: an app hands its modules to `defineApi`, the one
|
|
3
|
+
// registration path (axiom 1), and that reaches these through the same registry.
|
|
4
|
+
|
|
5
|
+
export { registerAction, registerActions } from './registry';
|
package/src/meter-store.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* the pool it opened, so a tenant's day is one counter across every replica and every restart.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
7
8
|
import { memoryMeterStore } from './meter-memory';
|
|
8
9
|
|
|
9
10
|
/** One counter: whose, which meter, which period declaration. */
|
|
@@ -45,17 +46,24 @@ export interface MeterStore {
|
|
|
45
46
|
releaseWarning(key: MeterKey, periodStartMs: number): Promise<void>;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
let
|
|
49
|
+
let base: MeterStore = memoryMeterStore();
|
|
50
|
+
/** The installed store: a stack over the process default, released in any order. */
|
|
51
|
+
const slot = adapterSlot<MeterStore>(() => base);
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The boot's installer (its release removes THIS install only, in any order — `adapterSlot`), on `@ultimat3/action/adapter` and never the `.` barrel: an app swaps the
|
|
55
|
+
* store with `meterStore:` in `apps/<app>/runtime.ts`.
|
|
56
|
+
*/
|
|
57
|
+
export function installMeterStore(store: MeterStore): () => void {
|
|
58
|
+
return slot.install(store);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
export function getMeterStore(): MeterStore {
|
|
55
|
-
return
|
|
62
|
+
return slot.current();
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/** Back to a fresh process default. The boot's teardown, and a test's. */
|
|
59
66
|
export function resetMeterStore(): void {
|
|
60
|
-
|
|
67
|
+
slot.reset();
|
|
68
|
+
base = memoryMeterStore();
|
|
61
69
|
}
|
package/src/openapi.ts
CHANGED
|
@@ -145,6 +145,8 @@ const PROBLEM_SCHEMA: JsonSchemaObject = {
|
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
|
+
// Present, and only ever `true`, when validation stopped at `MAX_ISSUES`: `issues` is a prefix.
|
|
149
|
+
issuesTruncated: { type: 'boolean', enum: [true] },
|
|
148
150
|
// Only the keys the code declared through `registerProblemMeta`; its shape is per code.
|
|
149
151
|
meta: { type: 'object' },
|
|
150
152
|
},
|
package/src/policy-gate.ts
CHANGED
|
@@ -1,87 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP, job, direct server
|
|
3
|
+
* call) reaches authz here, through policy's `guardPrimitive` — the one gate `@ultimat3/query`
|
|
4
|
+
* runs too, so the two primitives cannot drift on a surface rule.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import { enforce, enforceBeforeInput } from '@ultimat3/policy';
|
|
8
|
+
import type { GuardSubject, Policy, PrimitiveSurface } from '@ultimat3/policy';
|
|
9
|
+
import { guardPrimitive } from '@ultimat3/policy';
|
|
11
10
|
import { ActionDeniedError } from './errors';
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
* Policies are opaque here: we evaluate them, we never introspect their rules.
|
|
15
|
-
* `TRow` is what a row-level rule decides about, defaulted so the bare
|
|
16
|
-
* `ActionPolicy` keeps meaning "decides on input, any row or none".
|
|
17
|
-
*/
|
|
12
|
+
/** Opaque here: evaluated, never introspected. `TRow` is what a row-level rule decides about. */
|
|
18
13
|
export type ActionPolicy<TRow = unknown> = Policy<unknown, TRow>;
|
|
19
14
|
|
|
20
15
|
/** Which projection is running. Selects the deny renderer, never the decision. */
|
|
21
|
-
export type Surface = '
|
|
16
|
+
export type Surface = PrimitiveSurface<'action'>;
|
|
22
17
|
|
|
23
18
|
export interface PolicySubject {
|
|
24
19
|
readonly actor: Actor | null;
|
|
25
20
|
readonly input: unknown;
|
|
26
|
-
/**
|
|
27
|
-
* The already-loaded row a row-level rule decides about; `null` when the action
|
|
28
|
-
* declared no loader. Optional here and required in `PolicyArgs` for the same
|
|
29
|
-
* reason `EvaluateArgs.row` is: a surface deciding on input alone should not have
|
|
30
|
-
* to write `row: null`, but the predicate it reaches must still see the field.
|
|
31
|
-
* `invoke` always passes it, so the gap closes before any rule runs.
|
|
32
|
-
*/
|
|
21
|
+
/** The loaded row a row-level rule decides about; `invoke` always passes it, `null` for none. */
|
|
33
22
|
readonly row?: unknown;
|
|
34
23
|
readonly ctx: Ctx;
|
|
35
24
|
readonly action: string;
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
/**
|
|
39
|
-
* Evaluate once, render the denial per surface. `enforce` runs the same policy
|
|
40
|
-
* object for every surface, so an actor denied over HTTP is denied over MCP with
|
|
41
|
-
* the same reason and the same code.
|
|
42
|
-
*/
|
|
27
|
+
/** Evaluate once, render the denial per surface: one reason and one code on every surface. */
|
|
43
28
|
export function guardAction(policy: ActionPolicy, subject: PolicySubject, surface: Surface): void {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
actor: subject.actor,
|
|
47
|
-
// `evaluate()` normalises a missing row to `null`, so an input-only rule and a
|
|
48
|
-
// row rule reach the predicate through one shape rather than two.
|
|
49
|
-
row: subject.row,
|
|
50
|
-
ctx: subject.ctx,
|
|
51
|
-
});
|
|
52
|
-
if (denial !== undefined) throw new ActionDeniedError(subject.action, denial);
|
|
29
|
+
const { action: name, ...rest } = subject;
|
|
30
|
+
guard(policy, { stage: 'input', name, ...rest }, surface);
|
|
53
31
|
}
|
|
54
32
|
|
|
55
33
|
/**
|
|
56
|
-
* The actor-only half
|
|
57
|
-
*
|
|
58
|
-
* the input schema of an operation they may not call. Undecided — a predicate that reads the input
|
|
59
|
-
* or the row — passes, and `guardAction` decides after the parse exactly as before. Same denial
|
|
60
|
-
* class, same code, same surface rendering as `guardAction`'s.
|
|
34
|
+
* The actor-only half, BEFORE the input parse: a caller refused whatever they send gets the 403
|
|
35
|
+
* (or 401), never a 400 describing an input schema they may not call. Undecided passes.
|
|
61
36
|
*/
|
|
62
37
|
export function guardActionBeforeInput(
|
|
63
38
|
policy: ActionPolicy,
|
|
64
39
|
subject: Omit<PolicySubject, 'input' | 'row'>,
|
|
65
40
|
surface: Surface,
|
|
66
41
|
): void {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ctx: subject.ctx,
|
|
70
|
-
|
|
71
|
-
|
|
42
|
+
guard(
|
|
43
|
+
policy,
|
|
44
|
+
{ stage: 'actor', name: subject.action, actor: subject.actor, ctx: subject.ctx },
|
|
45
|
+
surface,
|
|
46
|
+
);
|
|
72
47
|
}
|
|
73
48
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
case 'http':
|
|
78
|
-
return 'http';
|
|
79
|
-
case 'mcp':
|
|
80
|
-
return 'mcp';
|
|
81
|
-
case 'job':
|
|
82
|
-
case 'server':
|
|
83
|
-
return 'job';
|
|
84
|
-
default:
|
|
85
|
-
return assertNever(surface);
|
|
86
|
-
}
|
|
49
|
+
function guard(policy: ActionPolicy, subject: GuardSubject, surface: Surface): void {
|
|
50
|
+
const refused = guardPrimitive('action', policy, subject, surface);
|
|
51
|
+
if (refused !== undefined) throw new ActionDeniedError(refused);
|
|
87
52
|
}
|
package/src/rate-limit-gate.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
asyncContext,
|
|
10
|
+
fixedRateLimitOf,
|
|
11
|
+
rateLimitScopeOf,
|
|
12
|
+
rateLimitSpendsOn,
|
|
13
|
+
rateLimitSubjectOf,
|
|
14
|
+
resolvedRateLimitOf,
|
|
15
|
+
} from '@ultimat3/core';
|
|
9
16
|
import type { RateLimitDecision } from '@ultimat3/http';
|
|
10
17
|
// The bucket maths, the subject precedence and the refusal are all `@ultimat3/http`'s (tier 2):
|
|
11
18
|
// a copy here would be a second answer to "what does this limit mean" and "whose bucket is it".
|
|
@@ -56,13 +63,6 @@ export function inJobFrame(): boolean {
|
|
|
56
63
|
return callerFrame.get()?.job === true;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
/**
|
|
60
|
-
* Namespaced, never the bare action name: the HTTP pipeline still spends its `default` bucket
|
|
61
|
-
* under `<route name>|<subject>`, and one key taken against two bucket shapes in one store would
|
|
62
|
-
* corrupt both counters.
|
|
63
|
-
*/
|
|
64
|
-
const ACTION_SCOPE = 'action';
|
|
65
|
-
|
|
66
66
|
export interface RateLimitSpendOptions {
|
|
67
67
|
readonly surface: Surface;
|
|
68
68
|
/** The connection address — the only subject an anonymous caller has. */
|
|
@@ -86,20 +86,20 @@ export async function spendActionLimit(
|
|
|
86
86
|
ctx: Ctx,
|
|
87
87
|
options: RateLimitSpendOptions,
|
|
88
88
|
): Promise<void> {
|
|
89
|
-
if (limit === undefined || options.surface
|
|
89
|
+
if (limit === undefined || !rateLimitSpendsOn(options.surface)) return;
|
|
90
90
|
const frame = callerFrame.get();
|
|
91
91
|
// A job run, or any call nested inside one (an `agent()` run as a job calling its tools as
|
|
92
92
|
// `'mcp'`): never a visitor's address, and the unattributed bucket rather than `ip:unknown`.
|
|
93
93
|
const inJob = options.surface === 'job' || inJobFrame();
|
|
94
94
|
const spends = rateLimitSpends(
|
|
95
95
|
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
routeName:
|
|
96
|
+
// Core's one subject rule — the org is read off an anonymous actor too (a job run).
|
|
97
|
+
...rateLimitSubjectOf(
|
|
98
|
+
ctx.actor,
|
|
99
|
+
options.clientAddress ?? (inJob ? null : (frame?.address ?? null)),
|
|
100
|
+
),
|
|
101
|
+
// Namespaced, never the bare name: the HTTP pipeline spends `<route name>|<subject>`.
|
|
102
|
+
routeName: rateLimitScopeOf('action', name),
|
|
103
103
|
...(inJob ? { unattributed: UNATTRIBUTED_JOB } : {}),
|
|
104
104
|
},
|
|
105
105
|
// The tenant allowance is the HTTP pipeline's (it spends it for every route, a handler-limited
|
|
@@ -109,7 +109,7 @@ export async function spendActionLimit(
|
|
|
109
109
|
// Resolved once per call, BEFORE any bucket is touched, and through `toBucket`'s own screen: a
|
|
110
110
|
// resolver answering `NaN`, `0` or `Infinity` is `X_RATE_LIMIT_INVALID`, never allow-all.
|
|
111
111
|
const bucket = toBucket(name, {
|
|
112
|
-
limit: await
|
|
112
|
+
limit: await resolvedRateLimitOf(limit, { ctx }),
|
|
113
113
|
windowMs: limit.windowMs,
|
|
114
114
|
});
|
|
115
115
|
for (const spend of spends) {
|
|
@@ -124,16 +124,6 @@ export async function spendActionLimit(
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/**
|
|
128
|
-
* The number a call is held to: the declared one, or what the resolver answers from `ctx` — the
|
|
129
|
-
* tenant's plan, read by the app. A non-number becomes `NaN`, so `toBucket` refuses it by name.
|
|
130
|
-
*/
|
|
131
|
-
async function resolvedRateLimit(limit: ActionRateLimit, ctx: Ctx): Promise<number> {
|
|
132
|
-
const resolved: unknown =
|
|
133
|
-
typeof limit.limit === 'function' ? await limit.limit({ ctx }) : limit.limit;
|
|
134
|
-
return typeof resolved === 'number' ? resolved : Number.NaN;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
127
|
/**
|
|
138
128
|
* At mount, never on the first request: a fixed pair is checked whole; a per-call limit has only
|
|
139
129
|
* its window to check until a caller resolves it, so the window is checked against one request.
|
|
@@ -149,6 +139,5 @@ export function assertActionRateLimit(name: string, limit: ActionRateLimit): voi
|
|
|
149
139
|
export function fixedRateLimit(
|
|
150
140
|
limit: ActionRateLimit | undefined,
|
|
151
141
|
): { readonly limit: number; readonly windowMs: number } | null {
|
|
152
|
-
|
|
153
|
-
return { limit: limit.limit, windowMs: limit.windowMs };
|
|
142
|
+
return fixedRateLimitOf(limit);
|
|
154
143
|
}
|
package/src/validate.ts
CHANGED
|
@@ -26,7 +26,15 @@ export async function parseActionInput<S extends StandardSchemaV1>(
|
|
|
26
26
|
const result = await validateAsync(schema, raw);
|
|
27
27
|
if (result.issues !== undefined) {
|
|
28
28
|
const issues = toValidationIssues(result.issues);
|
|
29
|
-
|
|
29
|
+
// `issuesTruncated`: validation stopped at `MAX_ISSUES`, and the refusal must say the list is
|
|
30
|
+
// a prefix rather than let a client render 100 rejections as the whole answer.
|
|
31
|
+
throw new InputInvalidError(
|
|
32
|
+
actionName,
|
|
33
|
+
formatIssues(issues).join('; '),
|
|
34
|
+
issues,
|
|
35
|
+
'action',
|
|
36
|
+
result.issuesTruncated === true,
|
|
37
|
+
);
|
|
30
38
|
}
|
|
31
39
|
return result.value;
|
|
32
40
|
}
|
package/src/wire-issues.ts
CHANGED
|
@@ -5,24 +5,22 @@
|
|
|
5
5
|
* structure another layer will render.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { stringField } from '@ultimat3/core';
|
|
8
|
+
import { stringField } from '@ultimat3/core/page';
|
|
9
9
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
10
|
-
import { issueRuleFacts } from '@ultimat3/schema';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
|
|
14
|
-
* entries are rendered into a DOM by whoever displays them, so the bound is here rather than there.
|
|
15
|
-
*/
|
|
16
|
-
export const MAX_WIRE_ISSUES = 100;
|
|
10
|
+
import { issueRuleFacts, MAX_ISSUES } from '@ultimat3/schema';
|
|
17
11
|
|
|
18
12
|
/**
|
|
13
|
+
* Bounded by `@ultimat3/schema`'s `MAX_ISSUES`, imported and never restated or re-exported: a list
|
|
14
|
+
* longer is not a form's worth of rejections but a body meant to be expensive, and the server stops
|
|
15
|
+
* validating at exactly that length, so a capped list arrives whole.
|
|
16
|
+
*
|
|
19
17
|
* All-or-nothing on purpose. A partly-parsed list would DROP the entries it could not read, and
|
|
20
18
|
* nothing downstream would know: a caller that finds `meta.issues` uses it INSTEAD of the
|
|
21
19
|
* flattened `cause`, so a dropped entry is a rejection the user never hears about. Refusing the
|
|
22
20
|
* whole list leaves the `cause` — which still holds every issue — as the answer.
|
|
23
21
|
*/
|
|
24
22
|
export function issuesFromWire(value: unknown): readonly ValidationIssue[] | undefined {
|
|
25
|
-
if (!Array.isArray(value) || value.length === 0 || value.length >
|
|
23
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ISSUES) {
|
|
26
24
|
return undefined;
|
|
27
25
|
}
|
|
28
26
|
const issues: ValidationIssue[] = [];
|