@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- readonly driver: JobsDriver;
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,
@@ -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 { isThrownError, renderCauseValue, renderMetaRecord, renderThrowable } from './error-render';
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(`${init.code}: ${described.title} — ${init.cause}`, { cause: init.cause });
73
- this.code = init.code;
74
- this.title = described.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');
@@ -27,6 +27,7 @@ export {
27
27
  renderCauseValue,
28
28
  renderFixLiteral,
29
29
  renderThrowable,
30
+ singleLine,
30
31
  stringField,
31
32
  } from '../error-render';
32
33
  export type { ErrorRetry } from '../error-retry';
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,