@ultimat3/core 1.2.0 → 3.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 (46) hide show
  1. package/CLAUDE.md +272 -0
  2. package/README.md +248 -10
  3. package/package.json +2 -1
  4. package/src/actor.ts +118 -4
  5. package/src/app-version.ts +32 -0
  6. package/src/assert.ts +5 -1
  7. package/src/config.ts +47 -12
  8. package/src/context.ts +30 -3
  9. package/src/cursor.ts +25 -4
  10. package/src/env-example.ts +2 -1
  11. package/src/env.ts +14 -3
  12. package/src/environment.ts +39 -13
  13. package/src/error-codes.ts +15 -1
  14. package/src/error-render.ts +249 -0
  15. package/src/error-reporter-sentry.ts +175 -0
  16. package/src/error-reporter.ts +212 -0
  17. package/src/error-retry.ts +112 -0
  18. package/src/errors.ts +55 -7
  19. package/src/exports/error-contract.ts +62 -0
  20. package/src/exports/observability.ts +161 -0
  21. package/src/exports/secrets.ts +71 -0
  22. package/src/ids.ts +49 -7
  23. package/src/impersonate.ts +62 -0
  24. package/src/index.ts +279 -113
  25. package/src/intl-cache.ts +43 -0
  26. package/src/lifecycle-deadline.ts +73 -0
  27. package/src/lifecycle-errors.ts +33 -0
  28. package/src/lifecycle.ts +237 -34
  29. package/src/logger.ts +99 -9
  30. package/src/mcp-exposure.ts +32 -0
  31. package/src/metrics.ts +0 -0
  32. package/src/otlp-metric-exporter.ts +136 -0
  33. package/src/otlp-span-exporter.ts +170 -0
  34. package/src/otlp.ts +217 -0
  35. package/src/read-capped.ts +47 -0
  36. package/src/runtime-metrics.ts +15 -0
  37. package/src/safe-url.ts +50 -0
  38. package/src/sampler.ts +126 -0
  39. package/src/schema-error-codes.ts +28 -0
  40. package/src/secrets-errors.ts +143 -0
  41. package/src/secrets-store.ts +173 -0
  42. package/src/secrets.ts +292 -0
  43. package/src/telemetry.ts +43 -11
  44. package/src/timing-safe-equal.ts +18 -0
  45. package/src/type-pins.ts +93 -0
  46. package/src/version.ts +53 -4
package/src/secrets.ts ADDED
@@ -0,0 +1,292 @@
1
+ // Single responsibility: the encrypted-secrets envelope. Seal a flat map of env values under a
2
+ // 32-byte master key and open one back, AES-256-GCM through WebCrypto only — a fresh 12-byte IV per
3
+ // seal, the 128-bit tag verified on open, and the envelope's own header bound in as additional
4
+ // authenticated data so a downgraded `alg` or a swapped key id fails the tag instead of decrypting.
5
+
6
+ import {
7
+ SecretsFileInvalidError,
8
+ SecretsKeyInvalidError,
9
+ SecretsKeyMismatchError,
10
+ SecretsPlaintextInvalidError,
11
+ SecretsTamperedError,
12
+ } from './secrets-errors';
13
+
14
+ export const SECRETS_VERSION = 1;
15
+ export const SECRETS_ALG = 'AES-256-GCM';
16
+ export const SECRETS_KEY_BYTES = 32;
17
+ export const SECRETS_KEY_HEX_LENGTH = SECRETS_KEY_BYTES * 2;
18
+ /** GCM's standard nonce. 96 bits is the size the construction is defined for; never reused. */
19
+ export const SECRETS_IV_BYTES = 12;
20
+ export const SECRETS_TAG_BYTES = 16;
21
+ /** Truncated to 64 bits: enough to name a key, far too little to attack the 256-bit key behind it. */
22
+ export const SECRETS_KEY_ID_LENGTH = 16;
23
+
24
+ /** A secret's name is the env var it becomes — there is no second namespace. */
25
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
26
+
27
+ const HEX_KEY = /^[0-9a-f]+$/;
28
+ const KEY_ID = /^[0-9a-f]{16}$/;
29
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
30
+
31
+ /** Decrypted values: env var name to value. Flat on purpose — see `installSecrets`. */
32
+ export type SecretValues = Readonly<Record<string, string>>;
33
+
34
+ /** The committed file, header first so `git diff` shows a rotation as a one-line `kid` change. */
35
+ export interface SecretsEnvelope {
36
+ readonly v: number;
37
+ readonly alg: string;
38
+ /** Non-secret fingerprint of the master key this was sealed with. */
39
+ readonly kid: string;
40
+ readonly iv: string;
41
+ readonly ct: string;
42
+ }
43
+
44
+ /** Where each half came from, so an error names the file the reader has to act on. */
45
+ export interface SecretsLocation {
46
+ /** The envelope's path, or the name of whatever produced it. */
47
+ readonly file: string;
48
+ /** The master key's origin — an env var name, or the key file's path. */
49
+ readonly key: string;
50
+ }
51
+
52
+ const encoder = new TextEncoder();
53
+
54
+ function encodeHex(bytes: Uint8Array<ArrayBuffer>): string {
55
+ let out = '';
56
+ for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
57
+ return out;
58
+ }
59
+
60
+ function decodeHex(hex: string): Uint8Array<ArrayBuffer> {
61
+ const bytes = new Uint8Array(hex.length / 2);
62
+ for (let i = 0; i < bytes.length; i += 1)
63
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
64
+ return bytes;
65
+ }
66
+
67
+ // `btoa`/`atob` rather than node:buffer — both are standard globals, and a chunk loop avoids the
68
+ // stack blow-up `String.fromCharCode(...bytes)` hits on a spread of any size.
69
+ function encodeBase64(bytes: Uint8Array<ArrayBuffer>): string {
70
+ let binary = '';
71
+ for (const byte of bytes) binary += String.fromCharCode(byte);
72
+ return btoa(binary);
73
+ }
74
+
75
+ function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
76
+ const binary = atob(text);
77
+ const bytes = new Uint8Array(binary.length);
78
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
79
+ return bytes;
80
+ }
81
+
82
+ /** A fresh master key: 32 CSPRNG bytes, hex. The only thing that must never reach the repo. */
83
+ export function generateMasterKey(): string {
84
+ return encodeHex(crypto.getRandomValues(new Uint8Array(SECRETS_KEY_BYTES)));
85
+ }
86
+
87
+ /** 64 lowercase hex characters, or `X_SECRETS_KEY_INVALID`. Whitespace is trimmed, never repaired. */
88
+ export function parseMasterKey(raw: string, at: string): Uint8Array<ArrayBuffer> {
89
+ const hex = raw.trim();
90
+ if (hex.length !== SECRETS_KEY_HEX_LENGTH || !HEX_KEY.test(hex)) {
91
+ throw new SecretsKeyInvalidError({
92
+ at,
93
+ found: hex.length,
94
+ expected: SECRETS_KEY_HEX_LENGTH,
95
+ });
96
+ }
97
+ return decodeHex(hex);
98
+ }
99
+
100
+ /**
101
+ * The key's public name. Domain-separated so this digest can never be replayed as a digest of the
102
+ * key computed for any other purpose, and truncated to 16 hex characters because its only job is
103
+ * telling two keys apart in a committed file and in an error message.
104
+ */
105
+ export async function masterKeyId(key: Uint8Array<ArrayBuffer>): Promise<string> {
106
+ const domain = encoder.encode('ultimate.secrets.kid.v1');
107
+ const material = new Uint8Array(domain.length + key.length);
108
+ material.set(domain, 0);
109
+ material.set(key, domain.length);
110
+ const digest = await crypto.subtle.digest('SHA-256', material);
111
+ return encodeHex(new Uint8Array(digest)).slice(0, SECRETS_KEY_ID_LENGTH);
112
+ }
113
+
114
+ /**
115
+ * The bytes the tag covers besides the ciphertext. Binding the header means an attacker who can
116
+ * write the file cannot downgrade `alg`, renumber `v` or claim a different `kid` without the tag
117
+ * catching it — the fields that decide how the body is read are authenticated with the body.
118
+ */
119
+ const additionalData = (header: Omit<SecretsEnvelope, 'iv' | 'ct'>): Uint8Array<ArrayBuffer> =>
120
+ encoder.encode(`ultimate.secrets|v=${header.v}|alg=${header.alg}|kid=${header.kid}`);
121
+
122
+ const importKey = (key: Uint8Array<ArrayBuffer>): Promise<CryptoKey> =>
123
+ crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
124
+
125
+ /**
126
+ * The plaintext form: one flat JSON object, keys sorted, two-space indent. Deterministic so
127
+ * `x secrets edit` can compare the buffer it handed the editor against the one it got back and
128
+ * skip the write when nothing changed — the ciphertext differs on every seal (the IV is fresh), so
129
+ * without this every edit session would produce a diff whether or not a value moved.
130
+ */
131
+ export function serializeSecretValues(values: SecretValues): string {
132
+ const sorted = Object.fromEntries(Object.entries(values).sort(([a], [b]) => a.localeCompare(b)));
133
+ return `${JSON.stringify(sorted, null, 2)}\n`;
134
+ }
135
+
136
+ /** Flat, env-shaped and non-empty, or `X_SECRETS_PLAINTEXT_INVALID`. Runs before every seal. */
137
+ export function assertSecretValues(value: unknown, at: string): SecretValues {
138
+ const refuse = (reason: string): never => {
139
+ throw new SecretsPlaintextInvalidError({ at, reason });
140
+ };
141
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
142
+ return refuse('are not a JSON object');
143
+ }
144
+ for (const [key, entry] of Object.entries(value)) {
145
+ if (!SECRET_NAME.test(key)) {
146
+ return refuse(`name "${key}", which is not an environment variable name (A-Z, 0-9, _)`);
147
+ }
148
+ if (typeof entry !== 'string') return refuse(`give "${key}" a value that is not a string`);
149
+ if (entry.length === 0) return refuse(`give "${key}" an empty value`);
150
+ }
151
+ return Object.freeze({ ...(value as Record<string, string>) });
152
+ }
153
+
154
+ /** The masked projection — names and lengths only. What `x secrets show` and any log may print. */
155
+ export interface SecretSummary {
156
+ readonly name: string;
157
+ readonly length: number;
158
+ }
159
+
160
+ export function describeSecrets(values: SecretValues): readonly SecretSummary[] {
161
+ return Object.entries(values)
162
+ .map(([name, value]) => ({ name, length: value.length }))
163
+ .sort((a, b) => a.name.localeCompare(b.name));
164
+ }
165
+
166
+ /** Encrypt `values` under `keyHex` and return the exact bytes the committed file holds. */
167
+ export async function sealSecrets(
168
+ values: SecretValues,
169
+ keyHex: string,
170
+ at: SecretsLocation,
171
+ ): Promise<string> {
172
+ const checked = assertSecretValues(values, at.file);
173
+ const key = parseMasterKey(keyHex, at.key);
174
+ const header = { v: SECRETS_VERSION, alg: SECRETS_ALG, kid: await masterKeyId(key) };
175
+ const iv = crypto.getRandomValues(new Uint8Array(SECRETS_IV_BYTES));
176
+ const sealed = await crypto.subtle.encrypt(
177
+ { name: 'AES-GCM', iv, additionalData: additionalData(header), tagLength: 128 },
178
+ await importKey(key),
179
+ encoder.encode(serializeSecretValues(checked)),
180
+ );
181
+ const envelope: SecretsEnvelope = {
182
+ ...header,
183
+ iv: encodeBase64(iv),
184
+ ct: encodeBase64(new Uint8Array(sealed)),
185
+ };
186
+ return `${JSON.stringify(envelope, null, 2)}\n`;
187
+ }
188
+
189
+ function field(record: Record<string, unknown>, name: string, at: string, bytes?: number): string {
190
+ const value = record[name];
191
+ if (typeof value !== 'string' || !BASE64.test(value) || value.length === 0) {
192
+ throw new SecretsFileInvalidError({ at, reason: `has no base64 "${name}" field` });
193
+ }
194
+ const decoded = decodeBase64(value);
195
+ if (bytes !== undefined && decoded.length !== bytes) {
196
+ throw new SecretsFileInvalidError({
197
+ at,
198
+ reason: `has a ${decoded.length}-byte "${name}"; AES-256-GCM uses ${bytes}`,
199
+ });
200
+ }
201
+ return value;
202
+ }
203
+
204
+ /**
205
+ * Read the envelope without needing a key. Every rejection here is `X_SECRETS_FILE_INVALID`: none
206
+ * of it has reached authentication yet, so calling it tampering would send the reader hunting an
207
+ * attacker for what is a truncated write or a merge conflict marker.
208
+ */
209
+ export function parseSecretsEnvelope(text: string, at: string): SecretsEnvelope {
210
+ let parsed: unknown;
211
+ try {
212
+ parsed = JSON.parse(text);
213
+ } catch {
214
+ throw new SecretsFileInvalidError({ at, reason: 'is not JSON' });
215
+ }
216
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
217
+ throw new SecretsFileInvalidError({ at, reason: 'is not a JSON object' });
218
+ }
219
+ const record = parsed as Record<string, unknown>;
220
+ if (record['v'] !== SECRETS_VERSION) {
221
+ throw new SecretsFileInvalidError({
222
+ at,
223
+ reason: `declares envelope version ${JSON.stringify(record['v'])}; this build seals version ${SECRETS_VERSION}`,
224
+ });
225
+ }
226
+ if (record['alg'] !== SECRETS_ALG) {
227
+ throw new SecretsFileInvalidError({
228
+ at,
229
+ reason: `declares algorithm ${JSON.stringify(record['alg'])}; this build seals ${SECRETS_ALG}`,
230
+ });
231
+ }
232
+ const kid = record['kid'];
233
+ if (typeof kid !== 'string' || !KEY_ID.test(kid)) {
234
+ throw new SecretsFileInvalidError({ at, reason: 'has no 16-character hex "kid" field' });
235
+ }
236
+ const iv = field(record, 'iv', at, SECRETS_IV_BYTES);
237
+ const ct = field(record, 'ct', at);
238
+ if (decodeBase64(ct).length <= SECRETS_TAG_BYTES) {
239
+ throw new SecretsFileInvalidError({
240
+ at,
241
+ reason: `has a "ct" too short to hold a ${SECRETS_TAG_BYTES}-byte authentication tag and any content`,
242
+ });
243
+ }
244
+ return { v: SECRETS_VERSION, alg: SECRETS_ALG, kid, iv, ct };
245
+ }
246
+
247
+ /**
248
+ * Open a committed envelope. Four distinct refusals, in the order the facts become knowable: the
249
+ * file is unreadable, the key is malformed, the key is the wrong one (the `kid` says so before any
250
+ * decryption is attempted), or the tag rejected the body.
251
+ */
252
+ export async function openSecrets(
253
+ text: string,
254
+ keyHex: string,
255
+ at: SecretsLocation,
256
+ ): Promise<SecretValues> {
257
+ const envelope = parseSecretsEnvelope(text, at.file);
258
+ const key = parseMasterKey(keyHex, at.key);
259
+ const kid = await masterKeyId(key);
260
+ if (kid !== envelope.kid) {
261
+ throw new SecretsKeyMismatchError({
262
+ at: at.file,
263
+ keyAt: at.key,
264
+ sealedWith: envelope.kid,
265
+ found: kid,
266
+ });
267
+ }
268
+ let plaintext: ArrayBuffer;
269
+ try {
270
+ plaintext = await crypto.subtle.decrypt(
271
+ {
272
+ name: 'AES-GCM',
273
+ iv: decodeBase64(envelope.iv),
274
+ additionalData: additionalData(envelope),
275
+ tagLength: 128,
276
+ },
277
+ await importKey(key),
278
+ decodeBase64(envelope.ct),
279
+ );
280
+ } catch {
281
+ // Deliberately no `sourceError`: WebCrypto's OperationError says nothing this code does not,
282
+ // and an error that wraps a crypto exception is one more object a log could try to serialize.
283
+ throw new SecretsTamperedError({ at: at.file });
284
+ }
285
+ let decoded: unknown;
286
+ try {
287
+ decoded = JSON.parse(new TextDecoder().decode(plaintext));
288
+ } catch {
289
+ throw new SecretsPlaintextInvalidError({ at: at.file, reason: 'are not JSON' });
290
+ }
291
+ return assertSecretValues(decoded, at.file);
292
+ }
package/src/telemetry.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { type Clock, systemClock } from './clock';
7
7
  import { tryUseContext } from './context';
8
+ import { renderThrowable } from './error-render';
8
9
  import { isUltimateError } from './errors';
9
- import { spanId as newSpanId, traceId as newTraceId } from './ids';
10
+ import { isSpanId, isTraceId, spanId as newSpanId, traceId as newTraceId } from './ids';
11
+ import { defaultSampler, resetDefaultSampler, type Sampler } from './sampler';
10
12
 
11
13
  export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer';
12
14
 
@@ -87,6 +89,8 @@ export interface TelemetryOptions {
87
89
  readonly serviceName?: string | undefined;
88
90
  readonly serviceVersion?: string | undefined;
89
91
  readonly enabled?: boolean | undefined;
92
+ /** Defaults to `defaultSampler()`: honour the parent, else the ratio the env asks for. */
93
+ readonly sampler?: Sampler | undefined;
90
94
  }
91
95
 
92
96
  export const noopExporter: SpanExporter = Object.freeze({
@@ -100,7 +104,7 @@ export interface MemoryExporter extends SpanExporter {
100
104
  reset(): void;
101
105
  }
102
106
 
103
- /** For tests and for `x trace --json`. */
107
+ /** For tests, and for reading back what a run traced with no collector on the box. */
104
108
  export function memoryExporter(): MemoryExporter {
105
109
  const spans: ReadableSpan[] = [];
106
110
  return {
@@ -119,12 +123,14 @@ const activeSpan = new AsyncLocalStorage<Span>();
119
123
  let exporter: SpanExporter = noopExporter;
120
124
  let clock: Clock = systemClock;
121
125
  let enabled = true;
126
+ let sampler: Sampler | undefined;
122
127
  let resource: SpanResource = Object.freeze({ serviceName: 'ultimate', serviceVersion: '0.0.1' });
123
128
 
124
129
  export function configureTelemetry(options: TelemetryOptions): void {
125
130
  if (options.exporter !== undefined) exporter = options.exporter;
126
131
  if (options.clock !== undefined) clock = options.clock;
127
132
  if (options.enabled !== undefined) enabled = options.enabled;
133
+ if (options.sampler !== undefined) sampler = options.sampler;
128
134
  if (options.serviceName !== undefined || options.serviceVersion !== undefined) {
129
135
  resource = Object.freeze({
130
136
  serviceName: options.serviceName ?? resource.serviceName,
@@ -137,6 +143,13 @@ export function resetTelemetry(): void {
137
143
  exporter = noopExporter;
138
144
  clock = systemClock;
139
145
  enabled = true;
146
+ sampler = undefined;
147
+ resetDefaultSampler();
148
+ }
149
+
150
+ /** The sampler in force: whatever `configureTelemetry` was given, else the env's. */
151
+ export function currentSampler(): Sampler {
152
+ return sampler ?? defaultSampler();
140
153
  }
141
154
 
142
155
  /**
@@ -162,12 +175,16 @@ export function currentSpanContext(): SpanContext | undefined {
162
175
 
163
176
  export function startSpan(name: string, options?: StartSpanOptions): Span {
164
177
  const parent = options?.parent ?? currentSpanContext();
178
+ const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
179
+ // The bit is decided ONCE, here, and every child of this span inherits it through `parent` —
180
+ // so one trace is sampled or not sampled as a whole. Before this, `traceFlags` was hardcoded to
181
+ // 1 for a root and `end()` exported regardless, which made the bit a value the framework
182
+ // forwarded and nobody obeyed.
165
183
  const context: SpanContext = {
166
184
  traceId: parent?.traceId ?? newTraceId(),
167
185
  spanId: newSpanId(),
168
- traceFlags: parent?.traceFlags ?? 1,
186
+ traceFlags: currentSampler().shouldSample(name, parent, attributes) ? 1 : 0,
169
187
  };
170
- const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
171
188
  const events: SpanEvent[] = [];
172
189
  const startedAt = clock.now().getTime();
173
190
  const startedMono = clock.monotonic();
@@ -198,7 +215,12 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
198
215
  },
199
216
  recordError(error) {
200
217
  const code = isUltimateError(error) ? error.code : 'X_INTERNAL';
201
- const message = error instanceof Error ? error.message : String(error);
218
+ // `renderThrowable`, never `error instanceof Error ? error.message : String(error)`: both
219
+ // halves are property reads on a value the framework did not build, and this runs inside
220
+ // `withSpan`'s catch — around `cache.invalidate`, `db.<verb>` and every HTTP and job span.
221
+ // A throw here substitutes the tracer's own TypeError for the caller's real failure and
222
+ // leaves the span it was annotating unended.
223
+ const message = renderThrowable(error);
202
224
  events.push({
203
225
  name: 'exception',
204
226
  at: clock.now().getTime(),
@@ -215,6 +237,10 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
215
237
  if (ended) return;
216
238
  ended = true;
217
239
  if (!enabled) return;
240
+ // The whole point of propagating a sampling bit is that somebody obeys it. A span still
241
+ // exists, still parents its children and still carries the decision onward in
242
+ // `traceparent`; it is simply not exported.
243
+ if ((context.traceFlags & 1) === 0) return;
218
244
  const endedAt = clock.now().getTime();
219
245
  const parentSpanId = parent === undefined || parent.spanId === '' ? undefined : parent.spanId;
220
246
  exporter.export({
@@ -272,8 +298,13 @@ export function withSpanContext<T>(context: SpanContext, name: string, fn: (span
272
298
 
273
299
  const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
274
300
 
301
+ /**
302
+ * Round-trips with `traceId()` / `spanId()` from `ids.ts` and with nothing else. A context whose
303
+ * `traceId` came from `uuid()` renders a 36-character dashed header here that `parseTraceparent`
304
+ * — and every OTLP collector — rejects, so mint the pair with those two generators.
305
+ */
275
306
  export function traceparent(context: SpanContext): string {
276
- const flags = context.traceFlags.toString(16).padStart(2, '0');
307
+ const flags = (context.traceFlags & 0xff).toString(16).padStart(2, '0');
277
308
  return `00-${context.traceId}-${context.spanId}-${flags}`;
278
309
  }
279
310
 
@@ -281,11 +312,12 @@ export function parseTraceparent(header: string | null | undefined): SpanContext
281
312
  if (header === null || header === undefined) return undefined;
282
313
  const match = TRACEPARENT_RE.exec(header.trim());
283
314
  if (match === null) return undefined;
284
- return {
285
- traceId: match[1] as string,
286
- spanId: match[2] as string,
287
- traceFlags: Number.parseInt(match[3] as string, 16),
288
- };
315
+ const traceId = match[1] as string;
316
+ const spanId = match[2] as string;
317
+ // `ids.ts` owns what a valid id is, so the all-zero rejection the spec requires lives in one
318
+ // place instead of being a second regex here that drifts from the generator's.
319
+ if (!isTraceId(traceId) || !isSpanId(spanId)) return undefined;
320
+ return { traceId, spanId, traceFlags: Number.parseInt(match[3] as string, 16) };
289
321
  }
290
322
 
291
323
  function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
@@ -0,0 +1,18 @@
1
+ // Single responsibility: a string comparison whose duration does not depend on where two secrets
2
+ // first differ. `@ultimat3/auth` and `@ultimat3/storage` both compared a signature or a hashed
3
+ // token this way and, being tier 1+ packages that both sit below `@ultimat3/core`, neither is the
4
+ // other's dependency — so the one copy lives here, at the tier both can reach.
5
+
6
+ /**
7
+ * Length is compared first and non-constant-time on purpose: every secret this compares is a
8
+ * fixed-width hash, token or signature, so the length carries no information, and the XOR
9
+ * accumulator below is what has to be branch-free.
10
+ */
11
+ export function timingSafeEqual(a: string, b: string): boolean {
12
+ if (a.length !== b.length) return false;
13
+ let diff = 0;
14
+ for (let index = 0; index < a.length; index += 1) {
15
+ diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
16
+ }
17
+ return diff === 0;
18
+ }
@@ -0,0 +1,93 @@
1
+ // Compile-time pins for the actor-facts seam and the config surface. Source, not a `.test.ts`,
2
+ // on purpose:
3
+ // `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
4
+ // type-level assertion written there can never fail. This module emits nothing and exports
5
+ // nothing anybody imports — a regression is a build error, the only enforcement that counts.
6
+
7
+ import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor';
8
+ import type { AppConfigInput, DatabaseConfig } from './config';
9
+
10
+ /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
11
+ type Assert<T extends true> = T;
12
+
13
+ interface Viewer {
14
+ readonly friendIds: ReadonlySet<string>;
15
+ }
16
+
17
+ /**
18
+ * A stand-in for what an app augments `ActorFacts` with. Declared locally rather than by
19
+ * augmenting the real interface: augmenting it HERE would declare `viewer` for every app that
20
+ * imports the framework, and a pin that changes the product it pins is not a pin.
21
+ */
22
+ interface SampleFacts {
23
+ readonly viewer: Viewer;
24
+ }
25
+
26
+ type SampleMap = FactMapOf<SampleFacts>;
27
+
28
+ /** A declared fact reads back as its own type — never `unknown`, never a bag. */
29
+ type _FactIsTyped = Assert<[NonNullable<SampleMap['viewer']>] extends [Viewer] ? true : false>;
30
+
31
+ type _FactIsNotUnknown = Assert<[unknown] extends [SampleMap['viewer']] ? false : true>;
32
+
33
+ /**
34
+ * The denial rule, as a type. Every fact is independently absent because nothing can prove one
35
+ * was resolved — a job runner, a test and an MCP token exchange all mint actors too. A predicate
36
+ * therefore has to branch on `undefined`, so an absent fact cannot silently read as a satisfied
37
+ * one.
38
+ */
39
+ type _AbsentFactIsRepresentable = Assert<undefined extends SampleMap['viewer'] ? true : false>;
40
+
41
+ /** A typo is a build error rather than a fact that is forever absent. */
42
+ type _UnknownFactIsNotAKey = Assert<'viewr' extends keyof SampleMap ? false : true>;
43
+
44
+ /** The phantom that keeps the empty interface from being `{}` is never itself a fact. */
45
+ type _PhantomIsNotAFactKey = Assert<'__ultimate' extends FactKeysOf<SampleFacts> ? false : true>;
46
+
47
+ /** An actor that resolved nothing is still an actor: every key is optional, always. */
48
+ type _NoFactsIsALegalFactMap = Assert<Record<string, never> extends SampleMap ? true : false>;
49
+
50
+ type _ActorFactMapAcceptsNothing = Assert<
51
+ Record<string, never> extends ActorFactMap ? true : false
52
+ >;
53
+
54
+ /**
55
+ * The seam is additive: an actor literal written before it existed still is one. `facts` is
56
+ * optional for that reason and not only for the denial rule — a required member would have been
57
+ * a breaking change to a tier-0 type every package depends on.
58
+ */
59
+ type _ActorWithoutFactsIsStillAnActor = Assert<
60
+ [
61
+ {
62
+ readonly kind: 'user';
63
+ readonly id: string;
64
+ readonly roles: readonly string[];
65
+ readonly scopes: readonly string[];
66
+ },
67
+ ] extends [Actor]
68
+ ? true
69
+ : false
70
+ >;
71
+
72
+ /**
73
+ * The three `config.database` fields deleted 2026-08 must stay deleted. Each produced neither a
74
+ * build error nor a runtime effect, which is the worst state a config field can be in: an SRE set
75
+ * `poolSize: 3`, redeployed, and nothing changed. Re-adding one silently restores that, so the
76
+ * pin is here rather than in a `.test.ts` — a `@ts-expect-error` in an excluded file asserts
77
+ * nothing.
78
+ *
79
+ * `DATABASE_POOL_MAX` is the pool knob that works, `DATABASE_URL` is read as a literal by
80
+ * `@ultimat3/db`'s `client.ts`, and nothing emits `SET search_path`.
81
+ */
82
+ type DeadDatabaseField = 'urlEnv' | 'poolSize' | 'schema';
83
+
84
+ type _DatabaseConfigCarriesNoDeadField = Assert<
85
+ Extract<keyof DatabaseConfig, DeadDatabaseField> extends never ? true : false
86
+ >;
87
+
88
+ /** And the input side with it — `Input<DatabaseConfig>` is what an `app.config.ts` writes. */
89
+ type _DatabaseInputCarriesNoDeadField = Assert<
90
+ Extract<keyof NonNullable<AppConfigInput['database']>, DeadDatabaseField> extends never
91
+ ? true
92
+ : false
93
+ >;
package/src/version.ts CHANGED
@@ -3,11 +3,12 @@
3
3
  // manifest is `private` and carries no `version`, and after `npm install` a walk above the package
4
4
  // lands in `node_modules/` where there is no manifest at all. Both mistakes fail silently.
5
5
 
6
- import { readFileSync } from 'node:fs';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
7
  // Bun has no path-join primitive; `import.meta.dir` is the module's own directory in both the
8
8
  // checked-out `src/` layout and the published `dist/` one, each exactly one level below the
9
9
  // package root.
10
10
  import { resolve } from 'node:path';
11
+ import { renderCauseValue } from './error-render';
11
12
  import { UltimateError } from './errors';
12
13
 
13
14
  const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/;
@@ -15,10 +16,22 @@ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)*$/;
15
16
  /** `@ultimat3/core`'s own manifest — the only file that can answer what version shipped. */
16
17
  export const VERSION_MANIFEST = resolve(import.meta.dir, '..', 'package.json');
17
18
 
19
+ /**
20
+ * The bundler define that carries the version into a build with no manifest to read.
21
+ * `x build --target binary` passes it (`binaryArgs` in `@ultimat3/cli`); the name is declared here
22
+ * so the flag that writes it and the read below cannot drift.
23
+ */
24
+ export const VERSION_DEFINE = 'ULTIMATE_FRAMEWORK_VERSION';
25
+
26
+ // Replaced with a string literal by `bun build --define ULTIMATE_FRAMEWORK_VERSION='"1.2.3"'`, and
27
+ // declared by nothing at runtime — which is why the read is `typeof`-guarded. An unbundled process
28
+ // must see `undefined` here, not a `ReferenceError`.
29
+ declare const ULTIMATE_FRAMEWORK_VERSION: string | undefined;
30
+
18
31
  /**
19
32
  * A package with no readable version is a broken publish, not a runtime condition to degrade
20
33
  * through: an `undefined` version poisons the MCP handshake and every dependency a scaffold pins,
21
- * and does it quietly. Fail at import, where the fix is a release-script change.
34
+ * and does it quietly. Fail where the fix is a release-script change.
22
35
  */
23
36
  export function readPackageVersion(manifestPath: string): string {
24
37
  const raw: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8'));
@@ -27,11 +40,47 @@ export function readPackageVersion(manifestPath: string): string {
27
40
  if (typeof version !== 'string' || !SEMVER.test(version)) {
28
41
  throw new UltimateError({
29
42
  code: 'X_INVARIANT',
30
- cause: `${manifestPath} has no valid semver "version" field (found ${JSON.stringify(version)})`,
43
+ cause: `${manifestPath} has no valid semver "version" field (found ${renderCauseValue(version)})`,
31
44
  fix: `set a semver "version" in ${manifestPath}, then re-run: bun run verify`,
32
45
  });
33
46
  }
34
47
  return version;
35
48
  }
36
49
 
37
- export const FRAMEWORK_VERSION: string = readPackageVersion(VERSION_MANIFEST);
50
+ /**
51
+ * Manifest first, build define second, throw last.
52
+ *
53
+ * A single-file executable carries no `package.json`, so a *missing* manifest is the one absence
54
+ * that is not a broken publish — it falls through to the define. A manifest that exists and
55
+ * declares no semver still throws, because that is the broken publish `readPackageVersion` was
56
+ * written for, and a define must not paper over it. Pure and exported so the compiled-binary case
57
+ * is a unit test rather than a `bun build --compile` nobody runs.
58
+ */
59
+ export function resolveVersion(manifestPath: string, defined: string | undefined): string {
60
+ if (existsSync(manifestPath)) return readPackageVersion(manifestPath);
61
+ if (defined !== undefined && SEMVER.test(defined)) return defined;
62
+ throw new UltimateError({
63
+ code: 'X_INVARIANT',
64
+ cause: `no manifest at ${manifestPath} and no valid ${VERSION_DEFINE} define (found ${JSON.stringify(defined)}) — only the builder that passes the define produces a bootable binary`,
65
+ fix: `x build --target binary`,
66
+ });
67
+ }
68
+
69
+ let resolved: string | undefined;
70
+
71
+ /**
72
+ * The framework's version, resolved on first call and cached for every call after.
73
+ *
74
+ * Lazy is the whole point. As a module-scope constant the read ran before `main` in every process
75
+ * that imported core, so `x build --target binary` produced an artifact that threw at import
76
+ * before any role started — the artifact compiled and could never boot.
77
+ */
78
+ export function frameworkVersion(): string {
79
+ if (resolved === undefined) {
80
+ resolved = resolveVersion(
81
+ VERSION_MANIFEST,
82
+ typeof ULTIMATE_FRAMEWORK_VERSION === 'string' ? ULTIMATE_FRAMEWORK_VERSION : undefined,
83
+ );
84
+ }
85
+ return resolved;
86
+ }