@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.
- package/CLAUDE.md +272 -0
- package/README.md +248 -10
- package/package.json +2 -1
- package/src/actor.ts +118 -4
- package/src/app-version.ts +32 -0
- package/src/assert.ts +5 -1
- package/src/config.ts +47 -12
- package/src/context.ts +30 -3
- package/src/cursor.ts +25 -4
- package/src/env-example.ts +2 -1
- package/src/env.ts +14 -3
- package/src/environment.ts +39 -13
- package/src/error-codes.ts +15 -1
- package/src/error-render.ts +249 -0
- package/src/error-reporter-sentry.ts +175 -0
- package/src/error-reporter.ts +212 -0
- package/src/error-retry.ts +112 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +62 -0
- package/src/exports/observability.ts +161 -0
- package/src/exports/secrets.ts +71 -0
- package/src/ids.ts +49 -7
- package/src/impersonate.ts +62 -0
- package/src/index.ts +279 -113
- package/src/intl-cache.ts +43 -0
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +237 -34
- package/src/logger.ts +99 -9
- package/src/mcp-exposure.ts +32 -0
- package/src/metrics.ts +0 -0
- package/src/otlp-metric-exporter.ts +136 -0
- package/src/otlp-span-exporter.ts +170 -0
- package/src/otlp.ts +217 -0
- package/src/read-capped.ts +47 -0
- package/src/runtime-metrics.ts +15 -0
- package/src/safe-url.ts +50 -0
- package/src/sampler.ts +126 -0
- package/src/schema-error-codes.ts +28 -0
- package/src/secrets-errors.ts +143 -0
- package/src/secrets-store.ts +173 -0
- package/src/secrets.ts +292 -0
- package/src/telemetry.ts +43 -11
- package/src/timing-safe-equal.ts +18 -0
- package/src/type-pins.ts +93 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
package/src/type-pins.ts
ADDED
|
@@ -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
|
|
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 ${
|
|
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
|
-
|
|
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
|
+
}
|