@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.
- package/CLAUDE.md +29 -26
- package/README.md +98 -30
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +53 -0
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +9 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +79 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +141 -173
- package/src/context.ts +29 -13
- package/src/cookie.ts +299 -0
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +26 -5
- package/src/decimal-order.ts +5 -4
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/error-reporter-sentry.ts +7 -3
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +43 -16
- package/src/fnv1a.ts +19 -0
- package/src/generation-fence.ts +1 -1
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/errors.ts +3 -1
- package/src/image/pipeline.ts +17 -5
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +27 -2
- package/src/index.ts +99 -33
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +92 -16
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- package/src/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page.ts +4 -2
- package/src/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +22 -4
- package/src/retry.ts +40 -7
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/secrets-errors.ts +14 -3
- package/src/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- 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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export type
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 —
|
|
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 —
|
|
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:
|
|
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
|
-
|
|
410
|
+
clearReadinessChecks();
|
|
500
411
|
}
|
package/src/locale-direction.ts
CHANGED
|
@@ -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).
|
|
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
|
|
58
|
-
* `refreshToken` sat here for three releases matching nothing — and those
|
|
59
|
-
* names on `@ultimat3/auth`'s `OAuthTokens`.
|
|
60
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
381
|
-
withLevel: (next) =>
|
|
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 =
|
|
462
|
+
export const logger: Logger = structuredLogger();
|
package/src/mcp-exposure.ts
CHANGED
|
@@ -1,14 +1,71 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
// and this is the only tier all of
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
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
|
-
|
|
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(
|
|
90
|
+
export function isMcpExposed(
|
|
91
|
+
declared: Partial<Pick<McpExposureDeclaration, 'expose'>> | undefined,
|
|
92
|
+
): boolean {
|
|
31
93
|
return declared?.expose === true;
|
|
32
94
|
}
|