@ultimat3/core 23.0.0 → 25.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 (98) hide show
  1. package/CLAUDE.md +29 -26
  2. package/README.md +98 -30
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +53 -0
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +9 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +79 -0
  29. package/src/config-site.ts +14 -3
  30. package/src/config.ts +141 -173
  31. package/src/context.ts +29 -13
  32. package/src/cookie.ts +299 -0
  33. package/src/core-error-codes.ts +2 -0
  34. package/src/cursor-page.ts +41 -0
  35. package/src/cursor.ts +26 -5
  36. package/src/decimal-order.ts +5 -4
  37. package/src/deprecation.ts +77 -0
  38. package/src/dev-secrets.ts +18 -7
  39. package/src/drain-deadline.ts +43 -0
  40. package/src/env-example.ts +9 -29
  41. package/src/error-reporter-sentry.ts +7 -3
  42. package/src/errors.ts +18 -9
  43. package/src/exports/error-contract.ts +0 -1
  44. package/src/exports/observability.ts +1 -1
  45. package/src/exports/secrets.ts +3 -0
  46. package/src/finite-option.ts +1 -1
  47. package/src/flight-gate.ts +43 -16
  48. package/src/fnv1a.ts +19 -0
  49. package/src/generation-fence.ts +1 -1
  50. package/src/health-disclosure.ts +43 -0
  51. package/src/host-rules.ts +28 -1
  52. package/src/html-escape.ts +24 -0
  53. package/src/ids.ts +7 -7
  54. package/src/image/canvas.ts +76 -5
  55. package/src/image/errors.ts +3 -1
  56. package/src/image/pipeline.ts +17 -5
  57. package/src/image/png-pixels.ts +29 -6
  58. package/src/image/probe.ts +7 -2
  59. package/src/image/raster.ts +27 -2
  60. package/src/index.ts +99 -33
  61. package/src/iso-date.ts +1 -1
  62. package/src/lifecycle-errors.ts +1 -1
  63. package/src/lifecycle-readiness.ts +60 -2
  64. package/src/lifecycle-signals.ts +27 -2
  65. package/src/lifecycle-types.ts +96 -0
  66. package/src/lifecycle.ts +44 -133
  67. package/src/locale-direction.ts +1 -1
  68. package/src/logger.ts +92 -16
  69. package/src/mcp-exposure.ts +70 -8
  70. package/src/measurement-actor.ts +16 -1
  71. package/src/metric-errors.ts +32 -0
  72. package/src/metric-registry.ts +151 -0
  73. package/src/metric-series.ts +94 -0
  74. package/src/metrics.ts +9 -255
  75. package/src/nearest-name.ts +11 -2
  76. package/src/otlp-metric-exporter.ts +1 -1
  77. package/src/otlp-span-exporter.ts +1 -1
  78. package/src/otlp.ts +44 -13
  79. package/src/page.ts +4 -2
  80. package/src/pg-executor.ts +15 -0
  81. package/src/public-cause.ts +37 -0
  82. package/src/registrar.ts +22 -4
  83. package/src/retry.ts +40 -7
  84. package/src/route-rank.ts +36 -0
  85. package/src/same-origin.ts +1 -1
  86. package/src/sampler.ts +6 -2
  87. package/src/secrets-errors.ts +14 -3
  88. package/src/secrets-key-file.ts +139 -0
  89. package/src/secrets-store.ts +32 -16
  90. package/src/service.ts +5 -5
  91. package/src/single-flight.ts +1 -1
  92. package/src/source-mask.ts +14 -8
  93. package/src/store-mode.ts +23 -0
  94. package/src/telemetry.ts +1 -1
  95. package/src/theme-storage.ts +12 -0
  96. package/src/type-pins.ts +51 -1
  97. package/src/image/fixtures.ts +0 -263
  98. package/src/time-zone-name.ts +0 -14
@@ -0,0 +1,96 @@
1
+ // Single responsibility: the lifecycle's data model — its states, phases, hook and option shapes,
2
+ // and the health report it answers with. The mechanism that moves between them is `lifecycle.ts`,
3
+ // which re-exports every name here so a caller keeps one import path.
4
+
5
+ import type { Clock } from './clock';
6
+ import type { ReadinessMode } from './config-health';
7
+ import type { ReadinessStatus } from './lifecycle-readiness';
8
+ import type { Logger } from './logger';
9
+
10
+ export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
11
+
12
+ /** Ordered. `accept` runs first, `close` last. */
13
+ export type ShutdownPhase = 'accept' | 'inflight' | 'close';
14
+
15
+ export const SHUTDOWN_PHASES: readonly ShutdownPhase[] = ['accept', 'inflight', 'close'];
16
+
17
+ /**
18
+ * Signals Ultimate reacts to. Narrower than `NodeJS.Signals` on purpose. `SIGBREAK` is Windows'
19
+ * Ctrl-Break — the one console signal there that is neither Ctrl-C nor a window closing.
20
+ */
21
+ export type ProcessSignal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGQUIT' | 'SIGBREAK';
22
+
23
+ export interface ShutdownReason {
24
+ readonly signal: string;
25
+ /**
26
+ * Real monotonic ms (`systemClock`) after which hooks are abandoned — deliberately NOT the
27
+ * injected clock. The budget this bounds is `terminationGracePeriodSeconds`, counted by the
28
+ * kubelet in real seconds, so a frozen clock must be unable to extend it: read off `clock` a
29
+ * test that advanced an hour of fake time handed the drain a 16-minute grace period, while
30
+ * `waitForIdle` went on sleeping on a real `setTimeout`. `clock` still owns `uptimeMs`.
31
+ */
32
+ readonly deadlineAt: number;
33
+ }
34
+
35
+ export type ShutdownHook = (reason: ShutdownReason) => void | Promise<void>;
36
+
37
+ export interface OnShutdownOptions {
38
+ readonly phase?: ShutdownPhase | undefined;
39
+ }
40
+
41
+ export interface LifecycleOptions {
42
+ /**
43
+ * The whole drain's budget — the in-flight wait AND every hook, in every phase. 25s by default,
44
+ * and **enforced whether or not an app sets it**: `ShutdownReason.deadlineAt` was always computed
45
+ * and handed to every hook, so the deadline was declared by the design and only the enforcement
46
+ * was missing. No hook reads `deadlineAt`, which is why it has to be imposed here.
47
+ *
48
+ * The lever is a LARGER value, not the absence of one: a `worker` holding a 10-minute job wants
49
+ * `configureLifecycle({ deadlineMs: 600_000 })` and a `terminationGracePeriodSeconds` at least as
50
+ * large. Left at 25s it is abandoned and the process exits clean — the row's visibility lease
51
+ * lapses and another worker re-claims it, which is what at-least-once already promises. The
52
+ * alternative is not "the job finishes": it is the same duplicate, delivered by SIGKILL at the
53
+ * kubelet's grace period, with no log line naming what overran.
54
+ *
55
+ * Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
56
+ */
57
+ readonly deadlineMs?: number | undefined;
58
+ /**
59
+ * How long `/readyz` answers 503 BEFORE the `accept` phase closes the listener — the time the
60
+ * endpoints controller and the ingress need to stop routing here. Closing on the flip itself left
61
+ * endpoints pointing at a closed socket, and a POST in that window got a 502. Added to
62
+ * `deadlineMs`, never taken from it. Unset: `defaultReadinessGraceMs()` of the process env at
63
+ * drain time — 0 in development/test, 5000 everywhere else, including a process naming no env.
64
+ * A whole number from 0 to 60000; 0 is no grace.
65
+ */
66
+ readonly readinessGraceMs?: number | undefined;
67
+ /** What a failing check does to `/readyz` — see `ReadinessMode`. Default `'dependencies'`. */
68
+ readonly readiness?: ReadinessMode | undefined;
69
+ readonly clock?: Clock | undefined;
70
+ readonly logger?: Logger | undefined;
71
+ }
72
+
73
+ export interface HealthReport {
74
+ readonly state: HealthState;
75
+ readonly ready: boolean;
76
+ readonly uptimeMs: number;
77
+ readonly inflight: number;
78
+ readonly buildId: string;
79
+ /** Named, because "alert on check failures BY CHECK NAME" is not writable against a boolean. */
80
+ readonly checks: Readonly<Record<string, ReadinessStatus>>;
81
+ /**
82
+ * How many checks are registered. `checks: {}` reads identically for "every check passed" and
83
+ * "nobody registered one", and only the second is a `/readyz` that means no more than "the
84
+ * socket is bound" — which is what the chart's and compose's healthchecks route traffic on.
85
+ * Reported rather than enforced: an empty registry is still ready, so a role that genuinely has
86
+ * no dependency does not have to invent a check to boot.
87
+ */
88
+ readonly registered: number;
89
+ }
90
+
91
+ export interface HealthPayload {
92
+ readonly ok: boolean;
93
+ /** The status code the HTTP layer should return. Core stays HTTP-free; this is just data. */
94
+ readonly status: number;
95
+ readonly body: HealthReport;
96
+ }
package/src/lifecycle.ts CHANGED
@@ -5,98 +5,45 @@
5
5
  import { type Clock, systemClock } from './clock';
6
6
  import type { ReadinessMode } from './config-health';
7
7
  import { assertReadinessMode } from './config-health';
8
+ import { DRAIN_DEADLINE_DEFAULT_MS } from './drain-deadline';
8
9
  import { UltimateError } from './errors';
9
10
  import { finiteCount } from './finite-option';
10
11
  import { settleWithin } from './lifecycle-deadline';
11
12
  import { lifecycleDrained } from './lifecycle-errors';
12
13
  import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
13
- import type { ReadinessCheck, ReadinessStatus } from './lifecycle-readiness';
14
+ import type { ReadinessStatus } from './lifecycle-readiness';
15
+ import {
16
+ clearReadinessChecks,
17
+ readinessCheckCount,
18
+ runReadinessChecks,
19
+ } from './lifecycle-readiness';
20
+ import type {
21
+ HealthPayload,
22
+ HealthReport,
23
+ HealthState,
24
+ LifecycleOptions,
25
+ OnShutdownOptions,
26
+ ShutdownHook,
27
+ ShutdownPhase,
28
+ ShutdownReason,
29
+ } from './lifecycle-types';
14
30
  import { type LogFields, type Logger, logger as rootLogger } from './logger';
15
31
 
16
- export type HealthState = 'starting' | 'ready' | 'draining' | 'stopped';
17
-
18
- /** Ordered. `accept` runs first, `close` last. */
19
- export type ShutdownPhase = 'accept' | 'inflight' | 'close';
20
-
21
- export const SHUTDOWN_PHASES: readonly ShutdownPhase[] = ['accept', 'inflight', 'close'];
22
-
23
- /** Signals Ultimate reacts to. Narrower than `NodeJS.Signals` on purpose. */
24
- export type ProcessSignal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGQUIT';
25
-
26
- export interface ShutdownReason {
27
- readonly signal: string;
28
- /**
29
- * Real monotonic ms (`systemClock`) after which hooks are abandoned — deliberately NOT the
30
- * injected clock. The budget this bounds is `terminationGracePeriodSeconds`, counted by the
31
- * kubelet in real seconds, so a frozen clock must be unable to extend it: read off `clock` a
32
- * test that advanced an hour of fake time handed the drain a 16-minute grace period, while
33
- * `waitForIdle` went on sleeping on a real `setTimeout`. `clock` still owns `uptimeMs`.
34
- */
35
- readonly deadlineAt: number;
36
- }
37
-
38
- export type ShutdownHook = (reason: ShutdownReason) => void | Promise<void>;
39
-
40
- export interface OnShutdownOptions {
41
- readonly phase?: ShutdownPhase | undefined;
42
- }
43
-
44
- export interface LifecycleOptions {
45
- /**
46
- * The whole drain's budget — the in-flight wait AND every hook, in every phase. 25s by default,
47
- * and **enforced whether or not an app sets it**: `ShutdownReason.deadlineAt` was always computed
48
- * and handed to every hook, so the deadline was declared by the design and only the enforcement
49
- * was missing. No hook reads `deadlineAt`, which is why it has to be imposed here.
50
- *
51
- * The lever is a LARGER value, not the absence of one: a `worker` holding a 10-minute job wants
52
- * `configureLifecycle({ deadlineMs: 600_000 })` and a `terminationGracePeriodSeconds` at least as
53
- * large. Left at 25s it is abandoned and the process exits clean — the row's visibility lease
54
- * lapses and another worker re-claims it, which is what at-least-once already promises. The
55
- * alternative is not "the job finishes": it is the same duplicate, delivered by SIGKILL at the
56
- * kubelet's grace period, with no log line naming what overran.
57
- *
58
- * Screened where it is assigned: a whole number of milliseconds, 0 or more. `0` is "drain now".
59
- */
60
- readonly deadlineMs?: number | undefined;
61
- /**
62
- * How long `/readyz` answers 503 BEFORE the `accept` phase closes the listener — the time the
63
- * endpoints controller and the ingress need to stop routing here. Closing on the flip itself left
64
- * endpoints pointing at a closed socket, and a POST in that window got a 502. Added to
65
- * `deadlineMs`, never taken from it. Unset: `defaultReadinessGraceMs()` of the process env at
66
- * drain time — 0 in development/test, 5000 everywhere else, including a process naming no env.
67
- * A whole number from 0 to 60000; 0 is no grace.
68
- */
69
- readonly readinessGraceMs?: number | undefined;
70
- /** What a failing check does to `/readyz` — see `ReadinessMode`. Default `'dependencies'`. */
71
- readonly readiness?: ReadinessMode | undefined;
72
- readonly clock?: Clock | undefined;
73
- readonly logger?: Logger | undefined;
74
- }
75
-
76
- export interface HealthReport {
77
- readonly state: HealthState;
78
- readonly ready: boolean;
79
- readonly uptimeMs: number;
80
- readonly inflight: number;
81
- readonly buildId: string;
82
- /** Named, because "alert on check failures BY CHECK NAME" is not writable against a boolean. */
83
- readonly checks: Readonly<Record<string, ReadinessStatus>>;
84
- /**
85
- * How many checks are registered. `checks: {}` reads identically for "every check passed" and
86
- * "nobody registered one", and only the second is a `/readyz` that means no more than "the
87
- * socket is bound" — which is what the chart's and compose's healthchecks route traffic on.
88
- * Reported rather than enforced: an empty registry is still ready, so a role that genuinely has
89
- * no dependency does not have to invent a check to boot.
90
- */
91
- readonly registered: number;
92
- }
93
-
94
- export interface HealthPayload {
95
- readonly ok: boolean;
96
- /** The status code the HTTP layer should return. Core stays HTTP-free; this is just data. */
97
- readonly status: number;
98
- readonly body: HealthReport;
99
- }
32
+ // The data model and the readiness registry are modules of their own; every name stays exported
33
+ // from here, so nothing that imports a lifecycle type or the check registry learns a second path.
34
+ export { readinessCheckCount, registerReadinessCheck } from './lifecycle-readiness';
35
+ export type {
36
+ HealthPayload,
37
+ HealthReport,
38
+ HealthState,
39
+ LifecycleOptions,
40
+ OnShutdownOptions,
41
+ ProcessSignal,
42
+ ShutdownHook,
43
+ ShutdownPhase,
44
+ ShutdownReason,
45
+ } from './lifecycle-types';
46
+ export { SHUTDOWN_PHASES } from './lifecycle-types';
100
47
 
101
48
  interface Registration {
102
49
  readonly name: string;
@@ -104,7 +51,8 @@ interface Registration {
104
51
  readonly hook: ShutdownHook;
105
52
  }
106
53
 
107
- const DEFAULT_DEADLINE_MS = 25_000;
54
+ /** `drain.deadlineMs`'s default, owned by `drain-deadline.ts` so the config and this agree. */
55
+ const DEFAULT_DEADLINE_MS = DRAIN_DEADLINE_DEFAULT_MS;
108
56
 
109
57
  let deadlineMs = DEFAULT_DEADLINE_MS;
110
58
  /** `undefined` means "the environment's default", read when a drain starts, not at import. */
@@ -120,15 +68,13 @@ let lifetime = 0;
120
68
  let registrations: Registration[] = [];
121
69
  let drainPromise: Promise<void> | undefined;
122
70
  let idleWaiters: (() => void)[] = [];
123
- const readiness = new Map<string, ReadinessCheck>();
124
71
 
125
72
  export function configureLifecycle(options: LifecycleOptions): void {
126
73
  // Screened above the write, never beside the arithmetic: `Math.max(0, deadlineAt - monotonic())`
127
74
  // PROPAGATES a NaN into `setTimeout(fn, NaN)`, i.e. 0 — measured, one in-flight operation dropped
128
75
  // and a 300ms close hook ABANDONED 111ms into a 25s budget, while `X_SHUTDOWN_TIMEOUT` rendered
129
76
  // `NaNms` and told the operator to RAISE a budget that was never a number. `min: 0` because 0 is
130
- // a real budget — drain now, no grace — and `@ultimat3/http`'s `drainTimeoutMs` accepts 0 and
131
- // hands it straight here, so a floor of 1 would refuse at boot what that package declares.
77
+ // a real budget — drain now, no grace — and `drain.deadlineMs` reaches here unchanged.
132
78
  if (options.deadlineMs !== undefined) {
133
79
  deadlineMs = finiteCount('configureLifecycle', 'deadlineMs', options.deadlineMs, 0);
134
80
  }
@@ -174,30 +120,6 @@ export function markReady(): void {
174
120
  if (state === 'starting') state = 'ready';
175
121
  }
176
122
 
177
- /**
178
- * Register a named readiness check. Returns its unregister — the same shape as `onShutdown`, and
179
- * owned by whoever can be started twice, for the same reason.
180
- */
181
- export function registerReadinessCheck(name: string, check: ReadinessCheck): () => void {
182
- if (readiness.has(name)) {
183
- throw new UltimateError({
184
- code: 'X_READINESS_CHECK_DUPLICATE',
185
- cause: `a readiness check named "${name}" is already registered (have: ${[...readiness.keys()].join(', ')})`,
186
- fix: `name the second check for what it actually probes, e.g. registerReadinessCheck('${name}-replica', check) — or hold the unregister the first registration returned and call it first`,
187
- meta: { name },
188
- });
189
- }
190
- readiness.set(name, check);
191
- return () => {
192
- if (readiness.get(name) === check) readiness.delete(name);
193
- };
194
- }
195
-
196
- /** Test-only: registered checks. A count that climbs across a start/stop cycle is a leak. */
197
- export function readinessCheckCount(): number {
198
- return readiness.size;
199
- }
200
-
201
123
  /**
202
124
  * Every line this file emits, and the only way it emits one. `log` is an injection seam
203
125
  * (`configureLifecycle({ logger })`), so an app's `Logger` decides whether a log call can throw —
@@ -226,24 +148,13 @@ function report(level: 'info' | 'warn' | 'error', message: string, fields: LogFi
226
148
  }
227
149
 
228
150
  /**
229
- * Every check, run now, by name. A check that throws is `failing` — never an unhandled error.
230
- *
231
- * Built through `Object.fromEntries`, never by assigning `results[name]`: assignment to the one
232
- * name `__proto__` sets the PROTOTYPE instead of adding a key, so that check vanished from the
233
- * report, `ready` was computed over an empty object — vacuously true — and a failing check
234
- * answered 200. `fromEntries` defines own properties and has no such name.
151
+ * Every check, run now, by name. A check that throws is `failing` — never an unhandled error —
152
+ * and is reported through `report`, so an injected logger that throws cannot replace the answer.
235
153
  */
236
154
  export function readinessChecks(): Readonly<Record<string, ReadinessStatus>> {
237
- const results: [string, ReadinessStatus][] = [];
238
- for (const [name, check] of readiness) {
239
- try {
240
- results.push([name, check() ? 'ok' : 'failing']);
241
- } catch (thrown) {
242
- results.push([name, 'failing']);
243
- report('warn', 'readiness check threw', { check: name, error: thrown });
244
- }
245
- }
246
- return Object.fromEntries(results);
155
+ return runReadinessChecks((name, thrown) => {
156
+ report('warn', 'readiness check threw', { check: name, error: thrown });
157
+ });
247
158
  }
248
159
 
249
160
  export function inflightCount(): number {
@@ -369,7 +280,7 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
369
280
  report('warn', 'X_SHUTDOWN_TIMEOUT', {
370
281
  code: 'X_SHUTDOWN_TIMEOUT',
371
282
  cause: `the "${registration.name}" shutdown hook (phase: ${phase}) was still running at the ${deadlineMs}ms drain deadline and has been ABANDONED — the process exits without it, so anything it had in flight may be incomplete`,
372
- fix: `raise the budget past the work this hook does — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute job — and set terminationGracePeriodSeconds to at least as many seconds, or make the "${registration.name}" hook return once it has stopped accepting work rather than once it has finished`,
283
+ fix: `raise the budget past the work this hook does — set drain: { deadlineMs: 600_000 } in app.config.ts for a 10-minute job (configureLifecycle({ deadlineMs: 600_000 }) outside a framework boot) — and give the platform's kill timer at least as many seconds (x deploy --method helm sizes the chart's from it), or make the "${registration.name}" hook return once it has stopped accepting work rather than once it has finished`,
373
284
  hook: registration.name,
374
285
  phase,
375
286
  });
@@ -401,7 +312,7 @@ async function runDrain(signal: string): Promise<void> {
401
312
  report('warn', 'X_SHUTDOWN_TIMEOUT', {
402
313
  code: 'X_SHUTDOWN_TIMEOUT',
403
314
  cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
404
- fix: 'raise the budget past the slowest handler — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds, or shorten the handler',
315
+ fix: 'raise the budget past the slowest handler — set drain: { deadlineMs: 600_000 } in app.config.ts for a 10-minute one (configureLifecycle({ deadlineMs: 600_000 }) outside a framework boot) — and give the platform kill timer at least as many seconds, or shorten the handler',
405
316
  });
406
317
  }
407
318
 
@@ -458,7 +369,7 @@ export function healthReport(mode: ReadinessMode = readinessMode): HealthReport
458
369
  inflight,
459
370
  buildId: process.env['BUILD_ID'] ?? 'dev',
460
371
  checks,
461
- registered: readiness.size,
372
+ registered: readinessCheckCount(),
462
373
  };
463
374
  }
464
375
 
@@ -496,5 +407,5 @@ export function resetLifecycle(): void {
496
407
  registrations = [];
497
408
  drainPromise = undefined;
498
409
  idleWaiters = [];
499
- readiness.clear();
410
+ clearReadinessChecks();
500
411
  }
@@ -2,7 +2,7 @@
2
2
  // requests, which is why it is tier 0 rather than `@ultimat3/i18n`'s: `@ultimat3/ui`'s provider
3
3
  // reflects `dir` onto `<html>` from the locale it was handed, and reaching the i18n barrel for
4
4
  // that one function put the whole framework catalog into every browser chunk with a `UiProvider`
5
- // in it (issue #490). i18n re-exports these under the same names, so no caller moved.
5
+ // in it (issue #490). Import them from `@ultimat3/core`: i18n's re-export was deleted in 25.0.0.
6
6
 
7
7
  export type Direction = 'ltr' | 'rtl';
8
8
 
package/src/logger.ts CHANGED
@@ -54,11 +54,10 @@ export interface LoggerOptions {
54
54
  }
55
55
 
56
56
  /**
57
- * LOWERCASE, always: `isRedactedKey` lowercases its lookup, so `apiKey`/`accessToken`/
58
- * `refreshToken` sat here for three releases matching nothing — and those are the exact field
59
- * names on `@ultimat3/auth`'s `OAuthTokens`. Matching is exact-key and never substring, so a
60
- * spelling that is not in this set is not redacted: both the camel and the snake wire spelling of
61
- * each credential is listed. Add through `redactKeys()` (which lowercases) rather than here.
57
+ * The exact-key FAST PATH. LOWERCASE, always: `isRedactedKey` lowercases its lookup, so
58
+ * `apiKey`/`accessToken`/`refreshToken` sat here for three releases matching nothing — and those
59
+ * are the exact field names on `@ultimat3/auth`'s `OAuthTokens`. Add through `redactKeys()` (which
60
+ * lowercases) rather than here. A name this set misses still meets `CREDENTIAL_NAME` below.
62
61
  */
63
62
  const redactedKeys = new Set<string>([
64
63
  'password',
@@ -81,15 +80,78 @@ const redactedKeys = new Set<string>([
81
80
  'client_secret',
82
81
  'privatekey',
83
82
  'private_key',
83
+ // The framework's own columns (`@ultimat3/auth`): a hash is what an offline guess runs against.
84
+ 'passwordhash',
85
+ 'tokenhash',
86
+ 'keyhash',
84
87
  ]);
85
88
 
89
+ /**
90
+ * The second half, for the names no list can enumerate. Exact-key matching alone let every
91
+ * COMPOUND credential through — `currentPassword`, `mfaSecret`, `resetToken`, `recoveryCode` — and
92
+ * `@ultimat3/action`'s audit walk asks this same predicate, so each was persisted in clear.
93
+ *
94
+ * Tested against the key lowercased with `_` and `-` removed, so one pattern covers the camel, the
95
+ * snake and the header spelling. It names what BEARS a credential and nothing wider, because a
96
+ * redacted field is one an operator cannot correlate on:
97
+ *
98
+ * - `password` / `passphrase` anywhere — no ordinary field carries the word.
99
+ * - `secret` as the LAST word (`mfaSecret`, `webhookSecret`, `appSecrets`, `secretAccessKey`), so
100
+ * `clientSecretEnv` and `secretsPath` — a variable name and a path — stay readable.
101
+ * - a `token` is a bearer UNLESS its qualifier says it is not: fail closed, with the exceptions
102
+ * named. `idempotencyToken`, `pageToken`, `continuationToken`, `cursorToken`, `syncToken` are
103
+ * dedupe and paging keys an operator greps for; everything else ending in `token` — `resetToken`,
104
+ * `githubToken`, `NPM_TOKEN` — is redacted without a provider list to keep current. The PLURAL
105
+ * is the reverse: `maxTokens` / `inputTokens` are counts on every `@ultimat3/ai` usage line, so
106
+ * `tokens` is redacted only behind a bearer qualifier (`accessTokens`).
107
+ * - key MATERIAL by its qualifier (`apiKey`, `privateKey`, `signingKey`, `encryptionKey`,
108
+ * `masterKey`, `hmacKey`, `secretsKey`, `accessKey`, `retiredKeys`) and the id half of a key
109
+ * pair (`accessKeyId`). A LOOKUP key — `cacheKey`, `primaryKey`, `idempotencyKey` — and a key's
110
+ * own id (`signingKeyId`) carry no qualifier on this list and stay readable.
111
+ * - a value that EMBEDS a credential: `connectionString`, `dsn`, a registry `authConfig`, and the
112
+ * service URLs that carry `user:password@` (`databaseUrl`, `REDIS_URL`). A bare `url` does not.
113
+ * - the one-time codes by name. Never a `code` suffix: that is the error contract's own field.
114
+ * - a stored hash of any of them: it is what an offline guess runs against.
115
+ * - a bearer by its kind as the LAST word: `credentials`, `jwt`, `bearer`, `cookies`, a session's
116
+ * id or key (`sessionId` IS the session), and key material by encoding (`privateKeyPem`).
117
+ * `credentialId`, `jwtIssuer`, `cookieName`, `privateKeyId` name a credential, not hold one.
118
+ * - card verification codes (`cvv`, `cvc2`) as a suffix, and a `pin` only as the WHOLE word
119
+ * behind an owner qualifier — three letters inside `spinner`, `shipping` or `pinned` are not one.
120
+ *
121
+ * Built from constant alternatives with no nested quantifier, so there is no input it backtracks on.
122
+ */
123
+ const CREDENTIAL_NAME = new RegExp(
124
+ [
125
+ 'passw(?:or)?d|passphrase',
126
+ 'secrets?$',
127
+ '(?:api|private|signing|encryption|master|hmac|secrets?|access|retired)keys?$|accesskeyid$',
128
+ '(?:token|key)hash(?:es)?$',
129
+ '(?<!idempotency|page|continuation|cursor|sync)token$',
130
+ '(?:access|refresh|id|session|reset|bearer|auth|api|csrf|xsrf|captcha|card|verification|invite|magic|magiclink|device|push|workload|oauth)tokens$',
131
+ 'authconfig$|connectionstring$|dsn$',
132
+ '(?:database|db|redis|replication|nats|smtp|amqp|mongo)ur[li]s?$',
133
+ '^totp$|totpcode$|otp$|otpcode$',
134
+ '(?:recovery|backup|mfa)codes?(?:hash(?:es)?)?$',
135
+ 'credentials?$|jwts?$|bearer$|cookies?$|session(?:id|key)s?$',
136
+ 'privatekey(?:pem|der|jwk)$',
137
+ 'cv[vc]2?$',
138
+ '^(?:card|atm|security|account|user|wallet)?pin(?:code|number)?(?:hash(?:es)?)?$',
139
+ ].join('|'),
140
+ );
141
+
86
142
  /** Mark keys as secret everywhere. `defineEnv()` calls this for every `secret: true` var. */
87
143
  export function redactKeys(keys: Iterable<string>): void {
88
144
  for (const key of keys) redactedKeys.add(key.toLowerCase());
89
145
  }
90
146
 
147
+ /**
148
+ * The framework's ONE answer to "is this field a credential?" — the log line, the error monitor's
149
+ * envelope and `@ultimat3/action`'s audit row all ask it, so a value that is `[redacted]` in one
150
+ * cannot be plaintext in another.
151
+ */
91
152
  export function isRedactedKey(key: string): boolean {
92
- return redactedKeys.has(key.toLowerCase());
153
+ const lower = key.toLowerCase();
154
+ return redactedKeys.has(lower) || CREDENTIAL_NAME.test(lower.replace(/[_-]/g, ''));
93
155
  }
94
156
 
95
157
  /**
@@ -224,7 +286,12 @@ function entryValue(source: Record<string, unknown>, key: string, depth: number)
224
286
  }
225
287
  }
226
288
 
227
- function redactFields(fields: LogFields): Record<string, unknown> {
289
+ /**
290
+ * A caller's record made safe to SERIALISE and safe to SHIP: credentials replaced by key and by
291
+ * value, a bigint / cycle / hostile getter degraded per field. Exported for the one other sink
292
+ * that sends a caller's record off the box — `error-reporter-sentry.ts`.
293
+ */
294
+ export function redactFields(fields: LogFields): Record<string, unknown> {
228
295
  const out: Record<string, unknown> = {};
229
296
  const source = fields as Record<string, unknown>;
230
297
  // `Object.keys` before the values, so the read of each value is its own guarded step: a field
@@ -286,7 +353,7 @@ function timestamp(clock: Clock): string {
286
353
  * `LOG_LEVEL`, and the default where there is no environment to read it from.
287
354
  *
288
355
  * A BROWSER has no `process` binding at all, and this read runs at MODULE INIT — `logger` at the
289
- * foot of this file is `createLogger()` evaluated when the module is. Measured on ai-maxxing's
356
+ * foot of this file is `structuredLogger()` evaluated when the module is. Measured on ai-maxxing's
290
357
  * session console island: `@ultimat3/realtime`'s `channel.ts` calls `logger.warn`, so the shaker
291
358
  * keeps `logger`, and the island's chunk died on `ReferenceError: process is not defined` before a
292
359
  * line of the app's own code ran — the wrapper rendered `data-x-failed="process is not defined"`
@@ -304,9 +371,18 @@ function timestamp(clock: Clock): string {
304
371
  */
305
372
  function envLevel(): LogLevel {
306
373
  const raw = typeof process === 'undefined' ? undefined : process.env['LOG_LEVEL'];
307
- return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
308
- ? (raw as LogLevel)
309
- : 'info';
374
+ // Unset and EMPTY are the same answer — `LOG_LEVEL=` is how a compose file spells "not set".
375
+ if (raw === undefined || raw === '') return 'info';
376
+ // REFUSED, as `resolveLevel` refuses the same value from `structuredLogger({ level })`. It fell back
377
+ // to `info` in silence, so `LOG_LEVEL=verbose` — or `DEBUG`, the spelling half the ecosystem
378
+ // uses — gave an operator who asked for MORE lines fewer, and nothing said the variable was the
379
+ // reason. This runs at module init, so the refusal is the first thing the process prints.
380
+ assert(
381
+ (LOG_LEVELS as readonly string[]).includes(raw),
382
+ `LOG_LEVEL=${renderCauseValue(raw)} is not a log level`,
383
+ `set LOG_LEVEL=info (one of ${LOG_LEVELS.join(', ')}, lowercase), or unset LOG_LEVEL`,
384
+ );
385
+ return raw as LogLevel;
310
386
  }
311
387
 
312
388
  /**
@@ -325,7 +401,7 @@ function resolveLevel(declared: LogLevel): LogLevel {
325
401
  // level that arrived from a config file can be either — the refusal must not be replaced by
326
402
  // a `TypeError` from building its own message.
327
403
  `${renderCauseValue(declared)} is not a log level`,
328
- `pass one of ${LOG_LEVELS.join(', ')} to createLogger({ level })`,
404
+ `pass one of ${LOG_LEVELS.join(', ')} to structuredLogger({ level })`,
329
405
  );
330
406
  return declared;
331
407
  }
@@ -346,7 +422,7 @@ function unreserved(fields: Record<string, unknown>): Record<string, unknown> {
346
422
  return out;
347
423
  }
348
424
 
349
- export function createLogger(options?: LoggerOptions): Logger {
425
+ export function structuredLogger(options?: LoggerOptions): Logger {
350
426
  const level = options?.level === undefined ? envLevel() : resolveLevel(options.level);
351
427
  const bound = options?.fields ?? {};
352
428
  const clock = options?.clock ?? systemClock;
@@ -377,10 +453,10 @@ export function createLogger(options?: LoggerOptions): Logger {
377
453
  warn: (message, fields) => emit('warn', message, fields),
378
454
  error: (message, fields) => emit('error', message, fields),
379
455
  fatal: (message, fields) => emit('fatal', message, fields),
380
- child: (fields) => createLogger({ level, clock, writer, fields: { ...bound, ...fields } }),
381
- withLevel: (next) => createLogger({ level: next, clock, writer, fields: bound }),
456
+ child: (fields) => structuredLogger({ level, clock, writer, fields: { ...bound, ...fields } }),
457
+ withLevel: (next) => structuredLogger({ level: next, clock, writer, fields: bound }),
382
458
  };
383
459
  }
384
460
 
385
461
  /** The process-wide logger. Prefer `ctx.logger` inside a request — it carries the ids. */
386
- export const logger: Logger = createLogger();
462
+ export const logger: Logger = structuredLogger();
@@ -1,14 +1,71 @@
1
- // The one answer to "did this primitive opt into being an MCP tool?" — a literal `expose: true`.
2
- // Core owns it because its readers span tiers 3-5 — `action`, `query`, `mcp`, `ai`, `manifest` —
3
- // and this is the only tier all of them reach, the same reason `timing-safe-equal.ts` lives here.
1
+ // The ONE declaration of a primitive's `mcp` block, and the one answer to "did this primitive opt
2
+ // into being an MCP tool?" — a literal `expose: true`. Core owns both because the block's readers
3
+ // span tiers 3-5 — `action`, `query`, `mcp`, `ai`, `manifest` — and this is the only tier all of
4
+ // them reach. `ActionMcp`, `QueryMcp` and `McpExposure` each restated the block until 25.0.0.
4
5
 
5
6
  /**
6
- * The `mcp` block, read structurally. Each package keeps its own richer declaration —
7
- * `ActionMcp` carries `visibleTo`, `@ultimat3/mcp`'s `McpExposure` carries `name` — and hands it
8
- * here; restating the one field they share binds this to none of them.
7
+ * MCP's four tool hints, as the spec (2025-06-18) spells them. Hints for a client's confirmation
8
+ * UI — the primitive's `policy` decides every call whatever they say.
9
+ */
10
+ export interface McpAnnotationHints {
11
+ readonly readOnlyHint?: boolean;
12
+ readonly destructiveHint?: boolean;
13
+ readonly idempotentHint?: boolean;
14
+ readonly openWorldHint?: boolean;
15
+ }
16
+
17
+ /** One comparison a list filter accepts. A filter key is `<field><op>`: `status_eq`. */
18
+ export type McpListFilterOp = '_eq' | '_in' | '_gt' | '_lt' | '_cont';
19
+
20
+ /**
21
+ * The list whitelist an MCP meta surface composes over: `filters` field → operators, sortable
22
+ * `sort` fields, pickable `fields`, `maxLimit`. Flat keys: the query's own `input` declares
23
+ * `status_eq`, `sort`, `fields`, `cursor`, `limit` and implements them; `manage_resource` refuses
24
+ * anything outside the whitelist before it runs.
25
+ */
26
+ export interface McpListParams {
27
+ readonly filters?: Readonly<Record<string, readonly McpListFilterOp[]>>;
28
+ readonly sort?: readonly string[];
29
+ readonly fields?: readonly string[];
30
+ /** Ceiling for `limit`. `@ultimat3/mcp`'s `DEFAULT_LIST_MAX_LIMIT` when absent. */
31
+ readonly maxLimit?: number;
32
+ }
33
+
34
+ /**
35
+ * `mcp: { … }` on an `action`, a `mutator`, a `query` or an `llm()`/`agent()` factory — the block
36
+ * an author writes and `@ultimat3/mcp`'s `toolFrom` reads. An action's view omits `listParams`
37
+ * (it has no list to compose); every other field means the same on every primitive.
9
38
  */
10
39
  export interface McpExposureDeclaration {
11
- readonly expose?: boolean | undefined;
40
+ /** Opt-in: only a literal `true` makes the primitive a tool. Silence exposes nothing. */
41
+ readonly expose: boolean;
42
+ /**
43
+ * Contract text, NOT UI text — deliberately outside `t()`. It becomes the OpenAPI operation
44
+ * `summary`, and `buildOpenApi`'s bytes are what `x verify` diffs for contract drift. Resolving
45
+ * it through the ambient, request-scoped translator would make `openapi.json` depend on
46
+ * whichever locale was active when it was generated. Two ways to describe one tool is the
47
+ * drift axiom 1 rejects, so there is no localised twin here.
48
+ */
49
+ readonly description?: string;
50
+ /**
51
+ * Roles that may SEE the projected tool. A CATALOG audience, never an authz rule — the `policy`
52
+ * still decides every call. Fail-closed where it lands: a caller whose role is not named,
53
+ * including one with no role at all, gets the answer an ABSENT tool gets, never `Forbidden`,
54
+ * which would confirm the tool exists. A plain role list, never a predicate: a declared fact
55
+ * stays static and serialisable. Omitted means every caller may enumerate it.
56
+ */
57
+ readonly visibleTo?: readonly string[];
58
+ /** The tool's display name in an MCP client's UI. Contract text, like `description`. */
59
+ readonly title?: string;
60
+ /**
61
+ * Overrides of the hints `@ultimat3/mcp` derives, key by key: an action derives
62
+ * `readOnlyHint: false`, `destructiveHint: true` and `idempotentHint` from `idempotent`; a query
63
+ * `readOnlyHint: true`. `annotations: { destructiveHint: false }` for a write that destroys
64
+ * nothing, `openWorldHint: true` for one that reaches outside the app.
65
+ */
66
+ readonly annotations?: McpAnnotationHints;
67
+ /** A list query's whitelist, carried to the tool for the meta surface. Never an action's. */
68
+ readonly listParams?: McpListParams;
12
69
  }
13
70
 
14
71
  /**
@@ -17,6 +74,9 @@ export interface McpExposureDeclaration {
17
74
  * handed to every agent that can reach the surface — and writing an action is not a request to
18
75
  * hand one out.
19
76
  *
77
+ * Read through `Partial<Pick<…>>` because a reader holding only a descriptor (`manifest`'s
78
+ * diff, a hand-built test primitive) may carry no `expose` at all, and that is the same "no".
79
+ *
20
80
  * Six readers decided it three ways until 2026-08: `=== true` where a tool is actually built,
21
81
  * `!== false` in the OpenAPI hint and `?? true` in the manifest fact. So an action with no `mcp`
22
82
  * block was published as a tool by the contract and refused by every surface that could have
@@ -27,6 +87,8 @@ export interface McpExposureDeclaration {
27
87
  * `expose: false` withdraws a tool. That surface says so in `mcp-tools.ts` and in
28
88
  * `wiki/Admin-Dashboard.md`. Nothing else may grow a second default.
29
89
  */
30
- export function isMcpExposed(declared: McpExposureDeclaration | undefined): boolean {
90
+ export function isMcpExposed(
91
+ declared: Partial<Pick<McpExposureDeclaration, 'expose'>> | undefined,
92
+ ): boolean {
31
93
  return declared?.expose === true;
32
94
  }