@ultimat3/core 4.0.0 → 5.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 +28 -0
- package/package.json +1 -1
- package/src/config.ts +11 -3
- package/src/error-render.ts +47 -0
- package/src/errors.ts +27 -6
- package/src/exports/error-contract.ts +1 -0
- package/src/index.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -8,6 +8,7 @@ is a change to every package.
|
|
|
8
8
|
| Deps | none (`bun-types` only) |
|
|
9
9
|
| Errors | subclass `UltimateError`; never `throw new Error` |
|
|
10
10
|
| Values in a message | `renderCauseValue()` / `renderFixLiteral()`; never raw `JSON.stringify`, `String()` or `${…}` on an `unknown` |
|
|
11
|
+
| Rendering the 3-line format | nothing to remember — `UltimateError`'s CONSTRUCTOR escapes `code`, `title`, `cause`, `fix` and `docs` with `singleLine()`. Call it yourself only when you render a shape this class never built, e.g. a `Finding` |
|
|
11
12
|
| A value a CALLER supplied | `describeValue()` — shape, never content. `renderCauseValue` is safe against throwing, not against leaking |
|
|
12
13
|
| Reading a caught value | `renderThrowable()` / `isThrownError()` / `stringField()`; never `error.message`, `error instanceof Error` or `typeof error.code === 'string'` directly — the probe throws before the renderer runs |
|
|
13
14
|
| New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
|
|
@@ -32,6 +33,33 @@ parameters typed `unknown` that reach a `cause:` / `fix:`, and it cannot see a v
|
|
|
32
33
|
through a local helper first (`packages/ui/src/components/ErrorState.tsx` builds a `message`
|
|
33
34
|
const, then assigns it).
|
|
34
35
|
|
|
36
|
+
`singleLine` is the escape that keeps the 3-line contract to three lines, and it exists because
|
|
37
|
+
`scripts/error-render.ts` **cannot see this class**. That gate refuses a parameter typed
|
|
38
|
+
`unknown`/`any` reaching a `cause:`; a value that is already a `string` renders without throwing, so
|
|
39
|
+
there is nothing for it to object to — while a newline in one adds a line to a format that is
|
|
40
|
+
line-oriented in the terminal, in CI logs and inside the dev overlay's `<pre>`. Three holes shipped
|
|
41
|
+
in `@ultimat3/auth` under a green check, the worst reachable by an unauthenticated stranger with one
|
|
42
|
+
crafted OIDC token (issue #97).
|
|
43
|
+
|
|
44
|
+
**It is applied in the CONSTRUCTOR, `As of 2026-08-20` — not at the renderers, which is where it
|
|
45
|
+
went first and could not stay.** Escaping at each renderer was six call sites, and six is a number
|
|
46
|
+
that only goes up: a seventh in this repo, and every renderer an APP writes, would each have had to
|
|
47
|
+
remember. `format()` is also not the only reader — an uncaught throw prints `.message`, a log line
|
|
48
|
+
takes `.cause`, `--json` takes `toJSON()` — so a per-renderer escape left three of four doors open.
|
|
49
|
+
One constructor covers all of them, and `singleLine` is idempotent, so a call site that already
|
|
50
|
+
escaped (`@ultimat3/auth` renders `claims.iss` at its source, quotes and all) is unharmed. `format()`
|
|
51
|
+
therefore interpolates the fields bare: a second pass would be a second place that has to be right.
|
|
52
|
+
The four renderers that still call it — `renderErrorLines` in `@ultimat3/http`,
|
|
53
|
+
`renderFrameworkError` in `@ultimat3/mcp`, `renderFinding` / `detailLines` in `@ultimat3/cli` — take
|
|
54
|
+
shapes this class never built (a `Finding`, a catalog entry), which is the one case left.
|
|
55
|
+
|
|
56
|
+
It is not a general sanitiser: a cause is prose and keeps its quotes,
|
|
57
|
+
its backslashes and its percent signs — only the control range is touched. Line breaks are the
|
|
58
|
+
structural half; the rest of C0 and DEL ride along because a terminal reads a raw `\u001b` as an ANSI
|
|
59
|
+
escape, so a cause could repaint the screen or hide the line above it. `@ultimat3/schema` carries a
|
|
60
|
+
deliberate duplicate for the tier-0 reason below, pinned behaviourally by
|
|
61
|
+
`single-line-pin.test.ts` in `@ultimat3/cli`.
|
|
62
|
+
|
|
35
63
|
`describeValue` in `error-render.ts` is a character-for-character duplicate of `describeValue` in
|
|
36
64
|
`packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
|
|
37
65
|
one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -8,7 +8,6 @@ import { ROLES, type Role } from './roles';
|
|
|
8
8
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
9
9
|
export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
|
|
10
10
|
export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
|
|
11
|
-
export type JobsDriver = 'postgres' | 'redis' | 'nats';
|
|
12
11
|
export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
|
|
13
12
|
export type RealtimeTransport = 'memory' | 'nats' | 'redis';
|
|
14
13
|
|
|
@@ -83,7 +82,17 @@ export interface CacheConfig {
|
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
export interface JobsConfig {
|
|
86
|
-
|
|
85
|
+
/**
|
|
86
|
+
* No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
|
|
87
|
+
* built `createPgDriver` — so `jobs: { driver: 'redis' }` did not throw, did not warn, and
|
|
88
|
+
* silently gave you Postgres. Deleted 2026-08-20, and it is the worse shape of the same defect
|
|
89
|
+
* `realtime.heartbeatMs` was: a knob that fails SILENTLY in the dangerous direction.
|
|
90
|
+
*
|
|
91
|
+
* The seam that works is `setJobDriver(driver)` — `setJobDriver(createPgDriver({ executor }))`,
|
|
92
|
+
* or `setJobDriver(createMemoryDriver())` in a test. Swap the driver, zero job-code change, which
|
|
93
|
+
* is the whole of what the `JobDriver` interface buys. There is no config line, and one that
|
|
94
|
+
* cannot be honoured is worse than none.
|
|
95
|
+
*/
|
|
87
96
|
readonly queues: readonly string[];
|
|
88
97
|
readonly concurrency: number;
|
|
89
98
|
readonly maxAttempts: number;
|
|
@@ -232,7 +241,6 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
232
241
|
database: { driver: 'postgres', ssl: false },
|
|
233
242
|
cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
|
|
234
243
|
jobs: {
|
|
235
|
-
driver: 'postgres',
|
|
236
244
|
queues: [`${name}-default`],
|
|
237
245
|
concurrency: 8,
|
|
238
246
|
maxAttempts: 5,
|
package/src/error-render.ts
CHANGED
|
@@ -64,6 +64,53 @@ export function renderCauseValue(value: unknown): string {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* One line, always. Escapes every character that a line-oriented reader would treat as a line
|
|
69
|
+
* break, leaving everything else byte-identical.
|
|
70
|
+
*
|
|
71
|
+
* WHY this exists at all: `renderCauseValue` above is safe against a value that THROWS while
|
|
72
|
+
* rendering, and safe against a value that LEAKS. It is not safe against a value that is already
|
|
73
|
+
* a `string` — a `string` renders fine, so nothing objected to it — and a caller-controlled
|
|
74
|
+
* string can carry a newline. The 3-line contract format (`<code>` / ` cause: …` / ` fix: …`)
|
|
75
|
+
* is line-oriented in the terminal, in CI logs and inside the dev overlay's `<pre>`, so one
|
|
76
|
+
* newline in a `cause` writes a second line an operator reads as a genuine framework message.
|
|
77
|
+
* Reproduced with a forged OIDC `iss` claim: an unauthenticated stranger with one crafted token.
|
|
78
|
+
*
|
|
79
|
+
* At the RENDERER, not at the call site, deliberately. `bun run error-render` cannot see this
|
|
80
|
+
* class — its rule is about `unknown` reaching a `cause:` — and three holes shipped in
|
|
81
|
+
* `@ultimat3/auth` alone under a green check. A rule every call site must remember is a rule the
|
|
82
|
+
* 296th call site forgets; there are six renderers of this format and no more, so escaping there
|
|
83
|
+
* is one place instead of every place (issue #97, option 3).
|
|
84
|
+
*
|
|
85
|
+
* `\u2028` and `\u2029` are included because they terminate a line for a JavaScript parser and
|
|
86
|
+
* for several log viewers, while `String.prototype.split('\n')` never sees them.
|
|
87
|
+
*
|
|
88
|
+
* The set is every C0 control, DEL, and `\u2028`/`\u2029`. Line breaks are the structural half —
|
|
89
|
+
* they add a line to a line-oriented format — and the rest ride along because a terminal reads them
|
|
90
|
+
* as commands of its own: a raw `\u001b` in a cause is an ANSI escape, so a value could repaint the
|
|
91
|
+
* screen, hide the line above it or move the cursor over what a reader had already been shown.
|
|
92
|
+
*
|
|
93
|
+
* NOT a general sanitiser. A `cause` is prose and keeps its quotes, its backslashes, its percent
|
|
94
|
+
* signs and every printable character it arrived with; only the control range is touched.
|
|
95
|
+
*/
|
|
96
|
+
export function singleLine(text: string): string {
|
|
97
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping them is the point.
|
|
98
|
+
return text.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, (char) => {
|
|
99
|
+
const known = CONTROL_ESCAPES[char];
|
|
100
|
+
if (known !== undefined) return known;
|
|
101
|
+
return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The spellings a reader already knows from a JSON string, so the escape reads as an escape. */
|
|
106
|
+
const CONTROL_ESCAPES: Readonly<Record<string, string>> = {
|
|
107
|
+
'\n': String.raw`\n`,
|
|
108
|
+
'\r': String.raw`\r`,
|
|
109
|
+
'\t': String.raw`\t`,
|
|
110
|
+
'\b': String.raw`\b`,
|
|
111
|
+
'\f': String.raw`\f`,
|
|
112
|
+
};
|
|
113
|
+
|
|
67
114
|
/**
|
|
68
115
|
* `value instanceof Error`, made total. The test itself can throw: a `Proxy`'s `getPrototypeOf`
|
|
69
116
|
* trap runs during `instanceof`, and the one place this question is asked is a `catch` block that
|
package/src/errors.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
// overlay and `--json`. Never throw a bare Error anywhere in the framework.
|
|
4
4
|
|
|
5
5
|
import { describeErrorCode } from './error-codes';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isThrownError,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
renderMetaRecord,
|
|
10
|
+
renderThrowable,
|
|
11
|
+
singleLine,
|
|
12
|
+
} from './error-render';
|
|
7
13
|
import { DEFAULT_ERROR_RETRY, type ErrorRetry, isErrorRetry, retryFor } from './error-retry';
|
|
8
14
|
|
|
9
15
|
/**
|
|
@@ -64,16 +70,27 @@ export class UltimateError extends Error {
|
|
|
64
70
|
|
|
65
71
|
constructor(init: UltimateErrorInit) {
|
|
66
72
|
const described = describeErrorCode(init.code);
|
|
73
|
+
// Every line-bearing field is escaped HERE, once, and never again downstream. A `cause` is a
|
|
74
|
+
// single line by contract, and a caller controls one often enough to matter: `claims.iss` off
|
|
75
|
+
// an unverified JWT, an IdP's `error_description`, a forwarded IP. One newline in any of them
|
|
76
|
+
// writes a second line an operator, a CI log or a `<pre>` reads as a genuine framework
|
|
77
|
+
// message. Escaping at each RENDERER was the first fix and it cannot hold — it is six call
|
|
78
|
+
// sites today, a seventh whenever someone writes one, and zero of the renderers an APP writes.
|
|
79
|
+
// Escaping at construction is the one place that covers all of them, and it is what #97 called
|
|
80
|
+
// the real answer. `singleLine` is idempotent, so a call site that already escaped is unharmed.
|
|
81
|
+
const code = singleLine(init.code);
|
|
82
|
+
const title = singleLine(described.title);
|
|
83
|
+
const cause = singleLine(init.cause);
|
|
67
84
|
// `message` carries the cause because it is the ONLY field a runtime prints when an
|
|
68
85
|
// error escapes uncaught — a worker log, a CI transcript, a stack trace. A message of
|
|
69
86
|
// just `code: title` tells an operator which rule fired but not which row, column or
|
|
70
87
|
// value, which is the opposite of "errors are instructions". `format()` still renders
|
|
71
88
|
// the canonical 3 lines from the fields, so the two never disagree.
|
|
72
|
-
super(`${
|
|
73
|
-
this.code =
|
|
74
|
-
this.title =
|
|
75
|
-
this.fix = init.fix;
|
|
76
|
-
this.docs = init.docs ?? described.docs;
|
|
89
|
+
super(`${code}: ${title} — ${cause}`, { cause });
|
|
90
|
+
this.code = code;
|
|
91
|
+
this.title = title;
|
|
92
|
+
this.fix = singleLine(init.fix);
|
|
93
|
+
this.docs = singleLine(init.docs ?? described.docs);
|
|
77
94
|
this.retry = init.retry ?? retryFor(init.code);
|
|
78
95
|
this.meta = init.meta;
|
|
79
96
|
this.sourceError = init.sourceError;
|
|
@@ -89,6 +106,10 @@ export class UltimateError extends Error {
|
|
|
89
106
|
* ```
|
|
90
107
|
*/
|
|
91
108
|
format(options?: FormatErrorOptions): string {
|
|
109
|
+
// No `singleLine` here. The constructor already escaped all five fields, so a second pass
|
|
110
|
+
// would be a second place that has to be right — and the one that gets forgotten. This method
|
|
111
|
+
// is line-oriented and stays exactly 3 lines (4 with `docs`) because the fields cannot carry
|
|
112
|
+
// a line break, not because this joiner removes them.
|
|
92
113
|
const lines = [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`];
|
|
93
114
|
if (options?.docs === true) lines.push(` docs: ${this.docs}`);
|
|
94
115
|
return lines.join('\n');
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,6 @@ export type {
|
|
|
44
44
|
CacheTier,
|
|
45
45
|
DatabaseConfig,
|
|
46
46
|
JobsConfig,
|
|
47
|
-
JobsDriver,
|
|
48
47
|
McpConfig,
|
|
49
48
|
OfflineStrategy,
|
|
50
49
|
PwaConfig,
|
|
@@ -159,6 +158,7 @@ export {
|
|
|
159
158
|
resetErrorRetry,
|
|
160
159
|
retryFor,
|
|
161
160
|
SCHEMA_ERROR_CODE_TITLES,
|
|
161
|
+
singleLine,
|
|
162
162
|
stringField,
|
|
163
163
|
toUltimateError,
|
|
164
164
|
ULTIMATE_ERROR_BRAND,
|