@ultimat3/core 24.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 +24 -27
- package/README.md +71 -34
- 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 +18 -11
- 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 +1 -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 +0 -33
- package/src/config.ts +71 -94
- package/src/context.ts +16 -12
- package/src/cookie.ts +267 -3
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +23 -5
- 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/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 +29 -14
- package/src/generation-fence.ts +1 -1
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/pipeline.ts +17 -5
- package/src/image/raster.ts +24 -1
- package/src/index.ts +89 -35
- 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 +16 -7
- 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/page.ts +4 -2
- package/src/registrar.ts +1 -0
- package/src/retry.ts +25 -5
- 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/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
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
|
@@ -112,6 +112,11 @@ const redactedKeys = new Set<string>([
|
|
|
112
112
|
* service URLs that carry `user:password@` (`databaseUrl`, `REDIS_URL`). A bare `url` does not.
|
|
113
113
|
* - the one-time codes by name. Never a `code` suffix: that is the error contract's own field.
|
|
114
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.
|
|
115
120
|
*
|
|
116
121
|
* Built from constant alternatives with no nested quantifier, so there is no input it backtracks on.
|
|
117
122
|
*/
|
|
@@ -127,6 +132,10 @@ const CREDENTIAL_NAME = new RegExp(
|
|
|
127
132
|
'(?:database|db|redis|replication|nats|smtp|amqp|mongo)ur[li]s?$',
|
|
128
133
|
'^totp$|totpcode$|otp$|otpcode$',
|
|
129
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)?)?$',
|
|
130
139
|
].join('|'),
|
|
131
140
|
);
|
|
132
141
|
|
|
@@ -344,7 +353,7 @@ function timestamp(clock: Clock): string {
|
|
|
344
353
|
* `LOG_LEVEL`, and the default where there is no environment to read it from.
|
|
345
354
|
*
|
|
346
355
|
* A BROWSER has no `process` binding at all, and this read runs at MODULE INIT — `logger` at the
|
|
347
|
-
* foot of this file is `
|
|
356
|
+
* foot of this file is `structuredLogger()` evaluated when the module is. Measured on ai-maxxing's
|
|
348
357
|
* session console island: `@ultimat3/realtime`'s `channel.ts` calls `logger.warn`, so the shaker
|
|
349
358
|
* keeps `logger`, and the island's chunk died on `ReferenceError: process is not defined` before a
|
|
350
359
|
* line of the app's own code ran — the wrapper rendered `data-x-failed="process is not defined"`
|
|
@@ -364,7 +373,7 @@ function envLevel(): LogLevel {
|
|
|
364
373
|
const raw = typeof process === 'undefined' ? undefined : process.env['LOG_LEVEL'];
|
|
365
374
|
// Unset and EMPTY are the same answer — `LOG_LEVEL=` is how a compose file spells "not set".
|
|
366
375
|
if (raw === undefined || raw === '') return 'info';
|
|
367
|
-
// REFUSED, as `resolveLevel` refuses the same value from `
|
|
376
|
+
// REFUSED, as `resolveLevel` refuses the same value from `structuredLogger({ level })`. It fell back
|
|
368
377
|
// to `info` in silence, so `LOG_LEVEL=verbose` — or `DEBUG`, the spelling half the ecosystem
|
|
369
378
|
// uses — gave an operator who asked for MORE lines fewer, and nothing said the variable was the
|
|
370
379
|
// reason. This runs at module init, so the refusal is the first thing the process prints.
|
|
@@ -392,7 +401,7 @@ function resolveLevel(declared: LogLevel): LogLevel {
|
|
|
392
401
|
// level that arrived from a config file can be either — the refusal must not be replaced by
|
|
393
402
|
// a `TypeError` from building its own message.
|
|
394
403
|
`${renderCauseValue(declared)} is not a log level`,
|
|
395
|
-
`pass one of ${LOG_LEVELS.join(', ')} to
|
|
404
|
+
`pass one of ${LOG_LEVELS.join(', ')} to structuredLogger({ level })`,
|
|
396
405
|
);
|
|
397
406
|
return declared;
|
|
398
407
|
}
|
|
@@ -413,7 +422,7 @@ function unreserved(fields: Record<string, unknown>): Record<string, unknown> {
|
|
|
413
422
|
return out;
|
|
414
423
|
}
|
|
415
424
|
|
|
416
|
-
export function
|
|
425
|
+
export function structuredLogger(options?: LoggerOptions): Logger {
|
|
417
426
|
const level = options?.level === undefined ? envLevel() : resolveLevel(options.level);
|
|
418
427
|
const bound = options?.fields ?? {};
|
|
419
428
|
const clock = options?.clock ?? systemClock;
|
|
@@ -444,10 +453,10 @@ export function createLogger(options?: LoggerOptions): Logger {
|
|
|
444
453
|
warn: (message, fields) => emit('warn', message, fields),
|
|
445
454
|
error: (message, fields) => emit('error', message, fields),
|
|
446
455
|
fatal: (message, fields) => emit('fatal', message, fields),
|
|
447
|
-
child: (fields) =>
|
|
448
|
-
withLevel: (next) =>
|
|
456
|
+
child: (fields) => structuredLogger({ level, clock, writer, fields: { ...bound, ...fields } }),
|
|
457
|
+
withLevel: (next) => structuredLogger({ level: next, clock, writer, fields: bound }),
|
|
449
458
|
};
|
|
450
459
|
}
|
|
451
460
|
|
|
452
461
|
/** The process-wide logger. Prefer `ctx.logger` inside a request — it carries the ids. */
|
|
453
|
-
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
|
}
|
package/src/measurement-actor.ts
CHANGED
|
@@ -30,6 +30,9 @@ export function defineMeasurementActor(factory: MeasurementActorFactory): void {
|
|
|
30
30
|
/**
|
|
31
31
|
* `kind: 'service'` and `'*'`: weighing bytes needs no data authority, and an app's own
|
|
32
32
|
* `requireMember()` has nothing to resolve for a service actor, which is the honest answer.
|
|
33
|
+
* No org and no roles, so a page whose policy names a role or whose `load` reads a tenant-scoped
|
|
34
|
+
* entity is refused under it — the `budgets` step's `X_BUDGET_UNMEASURED` names this actor, the
|
|
35
|
+
* refusal and `defineMeasurementActor()` for exactly that page.
|
|
33
36
|
*/
|
|
34
37
|
const defaultMeasurementActor = (): Actor =>
|
|
35
38
|
serviceActor({ id: MEASUREMENT_ACTOR_ID, permissions: ['*'] });
|
|
@@ -41,11 +44,23 @@ export async function measurementActor(): Promise<Actor> {
|
|
|
41
44
|
assert(
|
|
42
45
|
typeof actor === 'object' && actor !== null && typeof actor.id === 'string',
|
|
43
46
|
'the factory handed to defineMeasurementActor() answered no actor',
|
|
44
|
-
|
|
47
|
+
// Placeholders, not `roles: ["member"]`: the actor exists to pass the app's OWN gates, and a
|
|
48
|
+
// literal role here is one a `dev`-gated console refuses too (#675).
|
|
49
|
+
'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", orgId: "<an org your dev seed creates>", roles: ["<the role your gated pages require>"] }))',
|
|
45
50
|
);
|
|
46
51
|
return actor;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The factory declared now, or `undefined` for the default — read, never called. The test
|
|
56
|
+
* preload's file boundary snapshots it beside the other process registries: `app.config.ts`
|
|
57
|
+
* declares at module scope, ONCE per process, so a file that imported an app's config left every
|
|
58
|
+
* later file measuring as that app's actor (`prerender-actor.test.ts`, denied X_FORBIDDEN).
|
|
59
|
+
*/
|
|
60
|
+
export function declaredMeasurementActor(): MeasurementActorFactory | undefined {
|
|
61
|
+
return declared;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/** Back to the default. For a test, and for nothing else. */
|
|
50
65
|
export function resetMeasurementActor(): void {
|
|
51
66
|
declared = undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Single responsibility: the two coded refusals a metric VALUE or a series CEILING earns, and the
|
|
2
|
+
// finiteness screen every recording path runs first. The name grammar's refusal is
|
|
3
|
+
// `metric-names.ts`'s; `metrics.ts` re-exports all three, so callers keep one import path.
|
|
4
|
+
|
|
5
|
+
import { type CodedErrorInit, UltimateError } from './errors';
|
|
6
|
+
|
|
7
|
+
export class MetricValueInvalidError extends UltimateError {
|
|
8
|
+
static readonly code = 'X_METRIC_VALUE_INVALID';
|
|
9
|
+
override readonly name = 'MetricValueInvalidError';
|
|
10
|
+
constructor(init: CodedErrorInit) {
|
|
11
|
+
super({ ...init, code: MetricValueInvalidError.code });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MetricCardinalityError extends UltimateError {
|
|
16
|
+
static readonly code = 'X_METRIC_CARDINALITY';
|
|
17
|
+
override readonly name = 'MetricCardinalityError';
|
|
18
|
+
constructor(init: CodedErrorInit) {
|
|
19
|
+
super({ ...init, code: MetricCardinalityError.code });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function finite(name: string, value: number): number {
|
|
24
|
+
if (!Number.isFinite(value)) {
|
|
25
|
+
throw new MetricValueInvalidError({
|
|
26
|
+
cause: `${name} was given ${String(value)}, which is not a finite number`,
|
|
27
|
+
fix: `guard the value at the call site: Number.isFinite(v) before recording into ${name}`,
|
|
28
|
+
meta: { metric: name, received: String(value) },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Single responsibility: the instrument registry — one declaration per metric name, its shape
|
|
2
|
+
// refused at declaration (bounds, ceiling, a conflicting redeclaration) rather than at the first
|
|
3
|
+
// recording. `metric-series.ts` stores points under an instrument; `metrics.ts` is the public seam.
|
|
4
|
+
|
|
5
|
+
import { MetricCardinalityError } from './metric-errors';
|
|
6
|
+
import { assertMetricName, MetricNameInvalidError } from './metric-names';
|
|
7
|
+
import type {
|
|
8
|
+
GaugeOptions,
|
|
9
|
+
HistogramOptions,
|
|
10
|
+
MetricAttributes,
|
|
11
|
+
MetricDescriptor,
|
|
12
|
+
MetricKind,
|
|
13
|
+
} from './metrics-types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The per-instrument series ceiling. 2000 is roomy for a bounded label set — every route pattern
|
|
17
|
+
* times every status class times every method — and small enough that the process notices an
|
|
18
|
+
* unbounded one long before the scrape body does.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_MAX_SERIES = 2000;
|
|
21
|
+
|
|
22
|
+
/** OTel's default explicit bucket boundaries for a duration histogram, in seconds. */
|
|
23
|
+
export const DEFAULT_HISTOGRAM_BOUNDS: readonly number[] = Object.freeze([
|
|
24
|
+
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export interface Series {
|
|
28
|
+
readonly attributes: MetricAttributes;
|
|
29
|
+
value: number;
|
|
30
|
+
count: number;
|
|
31
|
+
min: number;
|
|
32
|
+
max: number;
|
|
33
|
+
buckets: number[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Instrument {
|
|
37
|
+
readonly descriptor: MetricDescriptor;
|
|
38
|
+
readonly series: Map<string, Series>;
|
|
39
|
+
readonly bounds: readonly number[];
|
|
40
|
+
readonly observe: (() => number) | undefined;
|
|
41
|
+
readonly maxSeries: number;
|
|
42
|
+
/** Reported once. A cardinality blow-up is one bug, not one log line per call. */
|
|
43
|
+
overflowed: boolean;
|
|
44
|
+
/** Reported once, for the same reason: a scrape every 15s must not become a log every 15s. */
|
|
45
|
+
observeFailed: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const instruments = new Map<string, Instrument>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bounds are strictly ascending finite numbers, refused at DECLARATION like `maxSeries` beside it.
|
|
52
|
+
* `record` takes the first bound an observation fits, and the exposition format emits one
|
|
53
|
+
* cumulative `le` series per bound in array order — so `[1, 0.5, 5]` both counted observations
|
|
54
|
+
* into a bucket that was not theirs and rendered a non-monotonic `le` series that Prometheus and
|
|
55
|
+
* OpenMetrics each reject. Two wrong numbers, neither visible from the other, and nothing at the
|
|
56
|
+
* call site to notice: the observations themselves were all valid.
|
|
57
|
+
*/
|
|
58
|
+
function assertBounds(name: string, bounds: readonly number[] | undefined): void {
|
|
59
|
+
if (bounds === undefined) return;
|
|
60
|
+
const bad = bounds.findIndex((bound, index) => {
|
|
61
|
+
const previous = index === 0 ? Number.NEGATIVE_INFINITY : (bounds[index - 1] as number);
|
|
62
|
+
return !Number.isFinite(bound) || bound <= previous;
|
|
63
|
+
});
|
|
64
|
+
if (bad === -1) return;
|
|
65
|
+
const repaired = [...new Set(bounds.filter((bound) => Number.isFinite(bound)))].sort(
|
|
66
|
+
(left, right) => left - right,
|
|
67
|
+
);
|
|
68
|
+
throw new MetricNameInvalidError({
|
|
69
|
+
cause: `${name} declared bounds [${bounds.map((bound) => String(bound)).join(', ')}], which are not strictly ascending finite numbers — [${String(bad)}] is ${String(bounds[bad])}`,
|
|
70
|
+
fix: `sort the bounds and drop the duplicates: histogram('${name}', { bounds: [${repaired.join(', ')}] })`,
|
|
71
|
+
meta: { metric: name, bounds: bounds.map((bound) => String(bound)), at: bad },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function declare(
|
|
76
|
+
name: string,
|
|
77
|
+
kind: MetricKind,
|
|
78
|
+
options: GaugeOptions & HistogramOptions,
|
|
79
|
+
): Instrument {
|
|
80
|
+
assertMetricName(name);
|
|
81
|
+
assertBounds(name, options.bounds);
|
|
82
|
+
const existing = instruments.get(name);
|
|
83
|
+
if (existing !== undefined) {
|
|
84
|
+
if (existing.descriptor.kind !== kind) {
|
|
85
|
+
throw new MetricNameInvalidError({
|
|
86
|
+
cause: `"${name}" is already declared as a ${existing.descriptor.kind}, redeclared as a ${kind}`,
|
|
87
|
+
fix: `rename one of the two instruments named "${name}" — one metric name, one kind`,
|
|
88
|
+
meta: { name, declared: existing.descriptor.kind, requested: kind },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
assertSameDeclaration(name, existing, options);
|
|
92
|
+
return existing;
|
|
93
|
+
}
|
|
94
|
+
const maxSeries = options.maxSeries ?? DEFAULT_MAX_SERIES;
|
|
95
|
+
if (!Number.isInteger(maxSeries) || maxSeries < 1) {
|
|
96
|
+
throw new MetricCardinalityError({
|
|
97
|
+
cause: `${name} declared maxSeries ${String(maxSeries)}, which is not a positive integer`,
|
|
98
|
+
fix: `pass a positive integer: counter('${name}', { maxSeries: ${DEFAULT_MAX_SERIES} })`,
|
|
99
|
+
meta: { metric: name, maxSeries: String(maxSeries) },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const instrument: Instrument = {
|
|
103
|
+
descriptor: {
|
|
104
|
+
name,
|
|
105
|
+
kind,
|
|
106
|
+
unit: options.unit ?? '1',
|
|
107
|
+
description: options.description ?? '',
|
|
108
|
+
},
|
|
109
|
+
series: new Map<string, Series>(),
|
|
110
|
+
bounds: options.bounds ?? DEFAULT_HISTOGRAM_BOUNDS,
|
|
111
|
+
observe: options.observe,
|
|
112
|
+
maxSeries,
|
|
113
|
+
overflowed: false,
|
|
114
|
+
observeFailed: false,
|
|
115
|
+
};
|
|
116
|
+
instruments.set(name, instrument);
|
|
117
|
+
return instrument;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A second declaration that STATES a different shape is refused. The first declaration wins, so a
|
|
122
|
+
* second `histogram(name, { bounds })` recorded into buckets another module chose and a second
|
|
123
|
+
* `gauge(name, { observe })` was collected through the first module's observer — silently, in both
|
|
124
|
+
* cases, which is the whole failure. An OMITTED option is not a conflict: `gauge(name)` is how a
|
|
125
|
+
* module takes a handle on an instrument someone else declared, and `maxSeries` keeps its shipped
|
|
126
|
+
* first-declaration-wins rule because it decides a ceiling rather than what gets recorded.
|
|
127
|
+
*/
|
|
128
|
+
function assertSameDeclaration(
|
|
129
|
+
name: string,
|
|
130
|
+
existing: Instrument,
|
|
131
|
+
options: GaugeOptions & HistogramOptions,
|
|
132
|
+
): void {
|
|
133
|
+
const { bounds, observe } = options;
|
|
134
|
+
if (bounds !== undefined && !sameBounds(existing.bounds, bounds)) {
|
|
135
|
+
throw new MetricNameInvalidError({
|
|
136
|
+
cause: `"${name}" is already declared with bounds [${existing.bounds.join(', ')}] and is redeclared with [${bounds.join(', ')}]; the first declaration wins, so the second set would never be used`,
|
|
137
|
+
fix: `declare "${name}" once and export the handle — import it where you record — or give the second instrument its own name`,
|
|
138
|
+
meta: { name, declared: existing.bounds.join(','), requested: bounds.join(',') },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (observe !== undefined && observe !== existing.observe) {
|
|
142
|
+
throw new MetricNameInvalidError({
|
|
143
|
+
cause: `"${name}" is already declared with an observe() callback and is redeclared with a different one; the first declaration wins, so the second callback would never be read`,
|
|
144
|
+
fix: `declare "${name}" once and export the handle — import it where you read — or give the second gauge its own name`,
|
|
145
|
+
meta: { name },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const sameBounds = (left: readonly number[], right: readonly number[]): boolean =>
|
|
151
|
+
left.length === right.length && left.every((bound, index) => bound === right[index]);
|