@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.
Files changed (76) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +71 -34
  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 +18 -11
  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 +1 -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 +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +89 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/registrar.ts +1 -0
  67. package/src/retry.ts +25 -5
  68. package/src/secrets-key-file.ts +139 -0
  69. package/src/secrets-store.ts +32 -16
  70. package/src/service.ts +5 -5
  71. package/src/single-flight.ts +1 -1
  72. package/src/telemetry.ts +1 -1
  73. package/src/theme-storage.ts +12 -0
  74. package/src/type-pins.ts +51 -1
  75. package/src/image/fixtures.ts +0 -263
  76. 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 { 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
@@ -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 `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
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 `createLogger({ level })`. It fell back
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 createLogger({ level })`,
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 createLogger(options?: LoggerOptions): Logger {
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) => createLogger({ level, clock, writer, fields: { ...bound, ...fields } }),
448
- 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 }),
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 = 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
  }
@@ -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
- 'return one from it in app.config.ts: defineMeasurementActor(() => userActor({ id: "measure", roles: ["member"] }))',
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]);