@ultimat3/core 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +29 -26
- package/README.md +98 -30
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +53 -0
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +9 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +79 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +141 -173
- package/src/context.ts +29 -13
- package/src/cookie.ts +299 -0
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +26 -5
- package/src/decimal-order.ts +5 -4
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/error-reporter-sentry.ts +7 -3
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +43 -16
- package/src/fnv1a.ts +19 -0
- package/src/generation-fence.ts +1 -1
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/errors.ts +3 -1
- package/src/image/pipeline.ts +17 -5
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +27 -2
- package/src/index.ts +99 -33
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +92 -16
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- package/src/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page.ts +4 -2
- package/src/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +22 -4
- package/src/retry.ts +40 -7
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/secrets-errors.ts +14 -3
- package/src/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- package/src/time-zone-name.ts +0 -14
package/src/otlp.ts
CHANGED
|
@@ -96,7 +96,10 @@ function parseEndpoint(signal: OtlpSignal, raw: string, perSignal: boolean, env:
|
|
|
96
96
|
// The spec's own asymmetry, not ours: a per-signal endpoint is the full URL an operator chose,
|
|
97
97
|
// while the generic one is a base the signal path is appended to.
|
|
98
98
|
if (perSignal) return url.toString();
|
|
99
|
-
|
|
99
|
+
// On the PATH, never on the string: `http://collector:4318?tenant=a` concatenated to
|
|
100
|
+
// `…/?tenant=a/v1/traces`, a request to `/` whose query merely ends in the receiver's path.
|
|
101
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/${signal}`;
|
|
102
|
+
return url.toString();
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/** The endpoint an operator configured, or `undefined` when they configured none. */
|
|
@@ -157,32 +160,43 @@ export function otlpEndpoint(
|
|
|
157
160
|
* and no fix. Refused instead, naming the variable and the header KEY: the value is the
|
|
158
161
|
* collector's credential and a `cause:` is folded into a log line.
|
|
159
162
|
*/
|
|
160
|
-
function decodeHeaderValue(key: string, raw: string): string {
|
|
163
|
+
function decodeHeaderValue(variable: string, key: string, raw: string): string {
|
|
161
164
|
try {
|
|
162
165
|
return decodeURIComponent(raw);
|
|
163
166
|
} catch {
|
|
164
167
|
throw new OtlpHeadersInvalidError({
|
|
165
|
-
cause: `${
|
|
166
|
-
fix: `set ${
|
|
168
|
+
cause: `${variable} carries a malformed percent-escape in the "${key}" value, so the header cannot be decoded`,
|
|
169
|
+
fix: `set ${variable}=${key}=<encoded>, where <encoded> is what bun -e 'console.log(encodeURIComponent(process.argv[1]))' <value> prints — or drop the stray % from the "${key}" value if it was meant literally`,
|
|
167
170
|
meta: { header: key },
|
|
168
171
|
});
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* `key=value,key2=value2`, percent-decoded — the spec's format for collector auth headers.
|
|
177
|
+
*
|
|
178
|
+
* With a `signal`, `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` REPLACES the generic variable for that
|
|
179
|
+
* signal, which is the spec's rule and the one `ENDPOINT` and `PROTOCOL` already followed here:
|
|
180
|
+
* only the generic one was read, so a collector that authenticates traces and metrics with
|
|
181
|
+
* different keys got the same key on both and rejected one of them.
|
|
182
|
+
*/
|
|
173
183
|
export function otlpHeaders(
|
|
174
184
|
explicit?: Readonly<Record<string, string>> | undefined,
|
|
175
185
|
env: OtlpEnv = process.env,
|
|
186
|
+
signal?: OtlpSignal | undefined,
|
|
176
187
|
): Record<string, string> {
|
|
177
188
|
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
178
|
-
const
|
|
189
|
+
const specific = signal === undefined ? undefined : signalKey(signal, 'HEADERS');
|
|
190
|
+
const variable =
|
|
191
|
+
specific !== undefined && (env[specific] ?? '').trim() !== '' ? specific : OTLP_HEADERS_KEY;
|
|
192
|
+
const raw = env[variable];
|
|
179
193
|
if (raw !== undefined) {
|
|
180
194
|
for (const pair of raw.split(',')) {
|
|
181
195
|
const index = pair.indexOf('=');
|
|
182
196
|
if (index <= 0) continue;
|
|
183
197
|
const key = pair.slice(0, index).trim().toLowerCase();
|
|
184
198
|
if (key === '') continue;
|
|
185
|
-
headers[key] = decodeHeaderValue(key, pair.slice(index + 1).trim());
|
|
199
|
+
headers[key] = decodeHeaderValue(variable, key, pair.slice(index + 1).trim());
|
|
186
200
|
}
|
|
187
201
|
}
|
|
188
202
|
for (const [key, value] of Object.entries(explicit ?? {})) headers[key.toLowerCase()] = value;
|
|
@@ -202,22 +216,39 @@ export interface OtlpKeyValue {
|
|
|
202
216
|
readonly value: OtlpAnyValue;
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
|
|
219
|
+
/**
|
|
220
|
+
* `undefined` for a number the wire cannot spell. `NaN` and `±Infinity` serialise as
|
|
221
|
+
* `{"doubleValue":null}`, and a validating collector rejects the WHOLE batch for it — `postOtlp`
|
|
222
|
+
* only warns, so one bad gauge silently cost every span beside it. Dropped, as a missing
|
|
223
|
+
* attribute is the honest reading of "not a number".
|
|
224
|
+
*/
|
|
225
|
+
function anyValue(value: AttributeValue): OtlpAnyValue | undefined {
|
|
206
226
|
if (typeof value === 'string') return { stringValue: value };
|
|
207
227
|
if (typeof value === 'boolean') return { boolValue: value };
|
|
208
228
|
if (typeof value === 'number') {
|
|
229
|
+
if (!Number.isFinite(value)) return undefined;
|
|
209
230
|
// `intValue` is a 64-bit field, so the JSON encoding spells it as a string. A float that
|
|
210
|
-
// happens to be integral is still a double to whoever queries it;
|
|
211
|
-
//
|
|
212
|
-
return Number.
|
|
231
|
+
// happens to be integral is still a double to whoever queries it; SAFE-integer is the signal,
|
|
232
|
+
// because past 2^53 `String(value)` is `"1e+21"` — exponent notation is not an int64.
|
|
233
|
+
return Number.isSafeInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
234
|
+
}
|
|
235
|
+
const values: OtlpAnyValue[] = [];
|
|
236
|
+
for (const item of value) {
|
|
237
|
+
const encoded = anyValue(item);
|
|
238
|
+
if (encoded !== undefined) values.push(encoded);
|
|
213
239
|
}
|
|
214
|
-
return { arrayValue: { values
|
|
240
|
+
return { arrayValue: { values } };
|
|
215
241
|
}
|
|
216
242
|
|
|
217
243
|
export function otlpAttributes(
|
|
218
244
|
attributes: Readonly<Record<string, AttributeValue>>,
|
|
219
245
|
): readonly OtlpKeyValue[] {
|
|
220
|
-
|
|
246
|
+
const out: OtlpKeyValue[] = [];
|
|
247
|
+
for (const [key, raw] of Object.entries(attributes)) {
|
|
248
|
+
const value = anyValue(raw);
|
|
249
|
+
if (value !== undefined) out.push({ key, value });
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
221
252
|
}
|
|
222
253
|
|
|
223
254
|
/** Epoch ms -> the string of nanoseconds OTLP/JSON wants, without losing precision to a float. */
|
package/src/page.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// The client seam's own functions, for the hooks a browser island calls: the one transport, the
|
|
11
11
|
// URL rule, the principal-supersession reader, and the small helpers realtime's browser half uses.
|
|
12
12
|
// Each reaches no titles table — `page-bundle.test.ts` builds all of them together to prove it.
|
|
13
|
-
export {
|
|
13
|
+
export { assertCoded } from './assert';
|
|
14
14
|
export type { AsyncState } from './async-state';
|
|
15
15
|
export type { JitterMode, Random } from './backoff';
|
|
16
16
|
export { backoffDelay } from './backoff';
|
|
@@ -32,7 +32,7 @@ export type { UltimateErrorInit } from './errors';
|
|
|
32
32
|
export { isUltimateError, UltimateError } from './errors';
|
|
33
33
|
export { finiteCount, finiteOption } from './finite-option';
|
|
34
34
|
export { isSuperseded } from './generation-fence';
|
|
35
|
-
export {
|
|
35
|
+
export { uuidV7 } from './ids';
|
|
36
36
|
export { isJsonObject } from './json-object';
|
|
37
37
|
export type { OutboxDrainMessage } from './outbox-drain';
|
|
38
38
|
export { OUTBOX_DRAIN_MESSAGE } from './outbox-drain';
|
|
@@ -54,5 +54,7 @@ export type { RecordEnvelope, RecordRows } from './record-envelope';
|
|
|
54
54
|
export { decodeRecordEnvelope, RECORDS_HEADER } from './record-envelope';
|
|
55
55
|
export type { PageClient, RecordSink } from './record-sink';
|
|
56
56
|
export { pageClient } from './record-sink';
|
|
57
|
+
// The key the theme boot script reads and the toggle island writes.
|
|
58
|
+
export { THEME_STORAGE_KEY } from './theme-storage';
|
|
57
59
|
// A write's public name, so the page's store can recognise the `records` frame its own write made.
|
|
58
60
|
export { isWriteDigest, WRITE_DIGEST_LENGTH, writeDigest } from './write-digest';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The one structural Postgres seam: `query(text, values)` answering rows. Declared at tier 0 so
|
|
2
|
+
// `@ultimat3/http`, `auth`, `action` and `jobs` share it while staying free of `@ultimat3/db` —
|
|
3
|
+
// four packages each declared it, and a copy is a fifth place for the contract to drift.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One method, positional parameters. **`Bun.sql` does not satisfy it** — `Bun.sql.query` is
|
|
7
|
+
* `undefined`; it is a tagged template whose positional form is `unsafe`, so `{ executor: Bun.sql }`
|
|
8
|
+
* would `TypeError` on the first statement. What satisfies it is a client that already speaks
|
|
9
|
+
* `(text, values)`, wrapped in one line — `@ultimat3/cli`'s `pgExecutorFor(client)` over
|
|
10
|
+
* `@ultimat3/db`'s `DbClient.query({ text, values })` is the framework's own — or a transaction
|
|
11
|
+
* handle, which is a client on its own connection. It answers rows, never a command tag.
|
|
12
|
+
*/
|
|
13
|
+
export interface PgExecutor {
|
|
14
|
+
query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Single responsibility: the ONE answer to "may a caller read this 5xx code's `cause`?". Moved down
|
|
2
|
+
// from `@ultimat3/http`'s `problem-meta.ts` because three renderers send an error off the box — the
|
|
3
|
+
// HTTP problem document, the MCP error data and the agent `tool_result` — and only the first asked.
|
|
4
|
+
// Tier 0, so `@ultimat3/mcp` and `@ultimat3/ai` (tier 4) can ask it without importing each other.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The 5xx codes whose `cause` a caller may read. Every other 5xx document carries the code and the
|
|
8
|
+
* request id and a fixed sentence: `X_DB_STATEMENT_FAILED` has a status row, so the old "blank only
|
|
9
|
+
* what nobody classified" rule served the Postgres message and the SQL statement in a production
|
|
10
|
+
* 500. The framework's four are refusals whose cause IS the instruction — back off, retry.
|
|
11
|
+
*/
|
|
12
|
+
const FRAMEWORK_PUBLIC_CAUSE: ReadonlySet<string> = new Set([
|
|
13
|
+
'X_DRAINING',
|
|
14
|
+
'X_OVERLOADED',
|
|
15
|
+
'X_FLIGHT_GATE_OVERLOADED',
|
|
16
|
+
'X_TIMEOUT',
|
|
17
|
+
]);
|
|
18
|
+
const APP_PUBLIC_CAUSE = new Set<string>();
|
|
19
|
+
|
|
20
|
+
/** Whether a 5xx document for `code` may carry its authored `cause`. */
|
|
21
|
+
export const hasPublicCause = (code: string): boolean =>
|
|
22
|
+
FRAMEWORK_PUBLIC_CAUSE.has(code) || APP_PUBLIC_CAUSE.has(code);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The WRITE half, and not an app's door: an app declares a public cause through
|
|
26
|
+
* `registerProblemMeta({ CODE: { publicCause: true } })` in `@ultimat3/http`, which refuses a
|
|
27
|
+
* framework-owned code first and then calls this. The set lives here only so the predicate above
|
|
28
|
+
* has one table to read whichever renderer asks.
|
|
29
|
+
*/
|
|
30
|
+
export const registerPublicCause = (code: string): void => {
|
|
31
|
+
APP_PUBLIC_CAUSE.add(code);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Test seam. Production registers once at boot and never unregisters. */
|
|
35
|
+
export const resetPublicCauses = (): void => {
|
|
36
|
+
APP_PUBLIC_CAUSE.clear();
|
|
37
|
+
};
|
package/src/registrar.ts
CHANGED
|
@@ -27,6 +27,23 @@ export const PRIMITIVE_KINDS = [
|
|
|
27
27
|
|
|
28
28
|
export type PrimitiveKind = (typeof PRIMITIVE_KINDS)[number];
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The package that ANNOUNCES each kind's registrar — a kind is not a package name. Both `fix:`
|
|
32
|
+
* lines below spliced `@ultimat3/${kind}`, so a missing `task` registrar told its reader to
|
|
33
|
+
* `bun add @ultimat3/task`, a package the registry has never had. A `Record` over the union, so a
|
|
34
|
+
* ninth kind fails to compile here before it can ship a fix that 404s.
|
|
35
|
+
*/
|
|
36
|
+
export const PRIMITIVE_PACKAGES = Object.freeze<Record<PrimitiveKind, string>>({
|
|
37
|
+
action: '@ultimat3/action',
|
|
38
|
+
entity: '@ultimat3/entity',
|
|
39
|
+
job: '@ultimat3/jobs',
|
|
40
|
+
mutator: '@ultimat3/action',
|
|
41
|
+
policy: '@ultimat3/policy',
|
|
42
|
+
query: '@ultimat3/query',
|
|
43
|
+
route: '@ultimat3/render',
|
|
44
|
+
task: '@ultimat3/jobs',
|
|
45
|
+
});
|
|
46
|
+
|
|
30
47
|
/** One factory over one primitive: the export's name, the package that ships it, what it returns. */
|
|
31
48
|
export interface PrimitiveFactory {
|
|
32
49
|
readonly factory: string;
|
|
@@ -67,6 +84,7 @@ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
|
|
|
67
84
|
{ factory: 'exportRows', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
68
85
|
{ factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
69
86
|
{ factory: 'webhook', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
87
|
+
{ factory: 'mcpConfirmations', pkg: '@ultimat3/mcp', kind: 'action' },
|
|
70
88
|
{ factory: 'notifier', pkg: '@ultimat3/notify', kind: 'job' },
|
|
71
89
|
{ factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
|
|
72
90
|
] satisfies readonly PrimitiveFactory[]
|
|
@@ -103,9 +121,9 @@ export function registerPrimitiveRegistrar(kind: PrimitiveKind, registrar: Modul
|
|
|
103
121
|
code: 'X_REGISTRAR_CONFLICT',
|
|
104
122
|
cause: `two different ${kind} registrars are loaded, so ${kind} primitives would split across two registries`,
|
|
105
123
|
// One command, because a `fix:` is pasted verbatim: collapsing every range on the package
|
|
106
|
-
// to one resolved version is the repair. `bun pm why
|
|
107
|
-
//
|
|
108
|
-
fix: `bun update
|
|
124
|
+
// to one resolved version is the repair. `bun pm why <package>` names the dependents when
|
|
125
|
+
// a range genuinely disagrees and the update cannot converge on its own.
|
|
126
|
+
fix: `bun update ${PRIMITIVE_PACKAGES[kind]}`,
|
|
109
127
|
meta: { kind },
|
|
110
128
|
});
|
|
111
129
|
}
|
|
@@ -127,7 +145,7 @@ export function primitiveRegistrar(kind: PrimitiveKind): ModuleRegistrar {
|
|
|
127
145
|
throw new UltimateError({
|
|
128
146
|
code: 'X_REGISTRAR_MISSING',
|
|
129
147
|
cause: `no ${kind} registrar is loaded, so ${kind} primitives cannot be registered`,
|
|
130
|
-
fix: `bun add
|
|
148
|
+
fix: `bun add ${PRIMITIVE_PACKAGES[kind]}`,
|
|
131
149
|
meta: { kind },
|
|
132
150
|
});
|
|
133
151
|
}
|
package/src/retry.ts
CHANGED
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
// nothing in the tree consulted it before deciding to try again, so four packages each shipped
|
|
4
4
|
// their own loop and only `@ultimat3/jobs`' asked the classification at all.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type BackoffCurve,
|
|
8
|
+
backoffDelay,
|
|
9
|
+
type JitterMode,
|
|
10
|
+
jitterStatedDelay,
|
|
11
|
+
type Random,
|
|
12
|
+
} from './backoff';
|
|
7
13
|
import { systemClock } from './clock';
|
|
8
14
|
import { classifyThrown, type ErrorRetry, statedDelayMs } from './error-retry';
|
|
15
|
+
import { finiteCount, finiteOption } from './finite-option';
|
|
9
16
|
|
|
10
17
|
export interface RetryPolicy {
|
|
11
18
|
/** Total attempts INCLUDING the first. `attempts: 1` means no retry. */
|
|
12
19
|
readonly attempts: number;
|
|
13
20
|
/** The first delay, in ms. */
|
|
14
21
|
readonly base: number;
|
|
15
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Ceiling for any single delay, in ms. A `retry-after` the responder named is clamped to it
|
|
24
|
+
* too, and then gets its spread on top — at most half again, so a burst told one delay does
|
|
25
|
+
* not wake together (`jitterStatedDelay`).
|
|
26
|
+
*/
|
|
16
27
|
readonly max: number;
|
|
17
28
|
/**
|
|
18
29
|
* Required, unlike `backoffDelay`'s, and that is the point: a retry loop with no jitter is the
|
|
@@ -63,6 +74,9 @@ export function retryDecision(
|
|
|
63
74
|
error: unknown,
|
|
64
75
|
random?: Random,
|
|
65
76
|
): RetryDecision {
|
|
77
|
+
// Screened HERE and not only in `retry()`: this function is exported so a caller can write its
|
|
78
|
+
// own loop, and `attempt >= NaN` is false for every attempt — the loop that asks it never ends.
|
|
79
|
+
const attempts = finiteCount('a retry policy', 'attempts', policy.attempts);
|
|
66
80
|
const classification = classifyThrown(error);
|
|
67
81
|
const stop = (stoppedBy: RetryStopReason): RetryDecision => ({
|
|
68
82
|
retry: false,
|
|
@@ -74,7 +88,7 @@ export function retryDecision(
|
|
|
74
88
|
});
|
|
75
89
|
|
|
76
90
|
if (classification === 'terminal') return stop('terminal');
|
|
77
|
-
if (attempt >=
|
|
91
|
+
if (attempt >= attempts) return stop('attempts-exhausted');
|
|
78
92
|
|
|
79
93
|
const computed = backoffDelay({
|
|
80
94
|
attempt,
|
|
@@ -88,9 +102,19 @@ export function retryDecision(
|
|
|
88
102
|
const stated = classification === 'retry-after' ? statedDelayMs(error) : undefined;
|
|
89
103
|
return {
|
|
90
104
|
retry: true,
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
105
|
+
// The stated delay is a FLOOR plus a spread (`jitterStatedDelay`), never the bare number: a
|
|
106
|
+
// shedding server tells a whole burst `Retry-After: 1`, and waiting exactly that replays the
|
|
107
|
+
// burst in lockstep every second. The floor is clamped by the policy's own ceiling, which is
|
|
108
|
+
// what `max` is for — a responder naming a day is still a responder this deployment has not
|
|
109
|
+
// agreed to wait a day for — and the spread on top is at most half of it.
|
|
110
|
+
// `jitter: 'none'` is honoured here too — the caller's one decision, for a test or a printable
|
|
111
|
+
// schedule — and only that mode gets the bare floor.
|
|
112
|
+
delayMs:
|
|
113
|
+
stated === undefined
|
|
114
|
+
? computed
|
|
115
|
+
: policy.jitter === 'none'
|
|
116
|
+
? Math.min(stated, policy.max)
|
|
117
|
+
: jitterStatedDelay(Math.min(stated, policy.max), policy.max, random),
|
|
94
118
|
attempt,
|
|
95
119
|
nextAttempt: attempt + 1,
|
|
96
120
|
classification,
|
|
@@ -111,6 +135,16 @@ export async function retry<T>(
|
|
|
111
135
|
policy: RetryPolicy,
|
|
112
136
|
deps: RetryDeps,
|
|
113
137
|
): Promise<T> {
|
|
138
|
+
// Both bounds are refused BEFORE the first try: a policy that cannot stop the loop is a defect in
|
|
139
|
+
// the call, and running the work once first would report it as the work's own failure.
|
|
140
|
+
// `finiteOption` for the budget, not `finiteCount`: it is a duration a caller computes from a
|
|
141
|
+
// monotonic clock, so a fraction is real and a spent (negative) one means "do not wait at all".
|
|
142
|
+
// Zero stays legal and means what it always did — one try, no retry (`retry.test.ts` pins it).
|
|
143
|
+
finiteCount('a retry policy', 'attempts', policy.attempts);
|
|
144
|
+
const budget =
|
|
145
|
+
policy.timeBudgetMs === undefined
|
|
146
|
+
? undefined
|
|
147
|
+
: finiteOption('a retry policy', 'timeBudgetMs', policy.timeBudgetMs);
|
|
114
148
|
const now = deps.now ?? ((): number => systemClock.monotonic());
|
|
115
149
|
// Read once even when no budget is set: a clock call per attempt would be a cost the common case
|
|
116
150
|
// does not owe. `startedAt` is only compared against when `timeBudgetMs` is present.
|
|
@@ -122,7 +156,6 @@ export async function retry<T>(
|
|
|
122
156
|
} catch (error) {
|
|
123
157
|
const decision = retryDecision(policy, attempt, error, deps.random);
|
|
124
158
|
if (!decision.retry) throw error;
|
|
125
|
-
const budget = policy.timeBudgetMs;
|
|
126
159
|
// Decided BEFORE the wait, never after: a loop that sleeps and then discovers it is out of
|
|
127
160
|
// budget has already spent the caller's deadline on a wait nobody could use.
|
|
128
161
|
if (budget !== undefined && now() - startedAt + decision.delayMs > budget) throw error;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single responsibility: the ONE precedence between route patterns, as an integer. Tier 0 because
|
|
2
|
+
// three tier-4 readers need it and may not import each other — `@ultimat3/render`'s
|
|
3
|
+
// `compilePattern` (ISR, sitemap extras, the admin), and `@ultimat3/pwa`'s worker rule order.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per segment, how strongly it claims a pathname: a literal 3, a `:param` 2, a `*catch-all` 1, and
|
|
7
|
+
* 4 where the pattern has already ENDED — `/` outranks `/*rest` and `/docs` outranks `/docs/*path`
|
|
8
|
+
* at the bare prefix, as the request router's own terminal outranks its catch-all.
|
|
9
|
+
*/
|
|
10
|
+
const SEGMENT_WEIGHT = { literal: 3, param: 2, catchAll: 1, ended: 4 } as const;
|
|
11
|
+
const WEIGHT_BASE = 5;
|
|
12
|
+
/** 5^22 < 2^53: every rank is an exact integer. A deeper pattern ties past its 22nd segment. */
|
|
13
|
+
export const ROUTE_RANK_SEGMENTS = 22;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The request router's precedence (`@ultimat3/http`'s trie) as ONE number, higher wins: segment by
|
|
17
|
+
* segment, the first segment where two patterns differ decides — literal over `:param` over
|
|
18
|
+
* `*catch-all`. Positional, never a sum: the 100/10/1 sum this replaced ranked `/:a/b/c` above
|
|
19
|
+
* `/a/:x/:y` for `/a/b/c`, where the trie takes the literal first segment. Compare two ranks only
|
|
20
|
+
* with each other; the value itself means nothing.
|
|
21
|
+
*/
|
|
22
|
+
export function routeRank(pattern: string): number {
|
|
23
|
+
const weights = pattern
|
|
24
|
+
.split('/')
|
|
25
|
+
.filter((segment) => segment.length > 0)
|
|
26
|
+
.map((segment) => {
|
|
27
|
+
if (segment.startsWith('*')) return SEGMENT_WEIGHT.catchAll;
|
|
28
|
+
if (segment.startsWith(':')) return SEGMENT_WEIGHT.param;
|
|
29
|
+
return SEGMENT_WEIGHT.literal;
|
|
30
|
+
});
|
|
31
|
+
let rank = 0;
|
|
32
|
+
for (let i = 0; i < ROUTE_RANK_SEGMENTS; i += 1) {
|
|
33
|
+
rank = rank * WEIGHT_BASE + (weights[i] ?? SEGMENT_WEIGHT.ended);
|
|
34
|
+
}
|
|
35
|
+
return rank;
|
|
36
|
+
}
|
package/src/same-origin.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface OriginEvidence {
|
|
|
13
13
|
readonly secFetchSite: string | null;
|
|
14
14
|
/** An EXACT allowance for a sibling origin — never a wildcard, never a suffix match. */
|
|
15
15
|
readonly listed: (origin: string) => boolean;
|
|
16
|
-
/** Where the operator adds an origin, named in the refusal: `http.cors.origins`, `
|
|
16
|
+
/** Where the operator adds an origin, named in the refusal: `http.cors.origins`, `APP_URL`. */
|
|
17
17
|
readonly listName: string;
|
|
18
18
|
}
|
|
19
19
|
|
package/src/sampler.ts
CHANGED
|
@@ -136,9 +136,13 @@ export function samplerFromEnv(
|
|
|
136
136
|
return ratioSampler(ratio);
|
|
137
137
|
case 'parentbased_always_off':
|
|
138
138
|
return parentBasedRatioSampler(0);
|
|
139
|
+
case 'parentbased_always_on':
|
|
140
|
+
// Its own case because it takes NO arg: sharing the ratio branch let a leftover
|
|
141
|
+
// `OTEL_TRACES_SAMPLER_ARG=0.1` thin the roots of a sampler whose name says always.
|
|
142
|
+
return parentBasedRatioSampler(1);
|
|
139
143
|
default:
|
|
140
|
-
// `
|
|
141
|
-
//
|
|
144
|
+
// `parentbased_traceidratio` and the unset case are one sampler: honour the parent, else
|
|
145
|
+
// the ratio — which is 1 when nothing set an arg.
|
|
142
146
|
return parentBasedRatioSampler(ratio);
|
|
143
147
|
}
|
|
144
148
|
}
|
package/src/secrets-errors.ts
CHANGED
|
@@ -77,8 +77,19 @@ export class SecretsKeyInvalidError extends UltimateError {
|
|
|
77
77
|
constructor(input: { at: string; found: number; expected: number }) {
|
|
78
78
|
super({
|
|
79
79
|
code: 'X_SECRETS_KEY_INVALID',
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// The lost-key sentence is CAUSE, not fix: a `fix:` is one command, and no command restores
|
|
81
|
+
// a key file — so the file branch says what cannot be done here and the fix measures it.
|
|
82
|
+
cause:
|
|
83
|
+
input.at === 'ULTIMATE_SECRETS_KEY'
|
|
84
|
+
? `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`
|
|
85
|
+
: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters — the key FILE is what is wrong, so re-exporting it changes nothing: restore it from wherever the team keeps the key, because a lost key cannot be recovered or regenerated`,
|
|
86
|
+
// Branches on WHERE the bad key was read. From the variable, re-reading the file repairs
|
|
87
|
+
// it. From the FILE, that same line reads the truncated file into the variable and is
|
|
88
|
+
// refused again, so the command is the measurement that says when the restore worked.
|
|
89
|
+
fix:
|
|
90
|
+
input.at === 'ULTIMATE_SECRETS_KEY'
|
|
91
|
+
? `export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)" # the key file holds the ${input.expected} characters on one line`
|
|
92
|
+
: `wc -c ${renderFixShellArg(input.at, '<the key file the cause names>')} # ${input.expected + 1} is a whole key and its newline; any other count is the truncated or padded file to restore`,
|
|
82
93
|
meta: { at: input.at },
|
|
83
94
|
});
|
|
84
95
|
}
|
|
@@ -99,7 +110,7 @@ export class SecretsRingKeyInvalidError extends UltimateError {
|
|
|
99
110
|
super({
|
|
100
111
|
code: 'X_SECRETS_KEY_INVALID',
|
|
101
112
|
cause: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`,
|
|
102
|
-
fix: `x secrets edit # ${variable} holds ${input.expected}-character lowercase hex keys separated by commas: correct or remove the entry the cause names`,
|
|
113
|
+
fix: `x secrets edit # ${variable} holds ${input.expected}-character lowercase hex keys separated by commas: correct or remove the entry the cause names — the variable is a line of secrets.enc.json, and a platform that ALSO sets it wins, so correct it there too`,
|
|
103
114
|
meta: { at: input.at },
|
|
104
115
|
});
|
|
105
116
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Single responsibility: writing a master-key file that only its owner can read, on every platform,
|
|
2
|
+
// and renaming one over another without losing to a reader that holds it open. POSIX gets the first
|
|
3
|
+
// from the create mode. Windows ignores the mode — the file inherits the profile directory's ACL —
|
|
4
|
+
// so there the temp file's ACL is cut to the current account BEFORE the rename makes it live.
|
|
5
|
+
|
|
6
|
+
// why: `node:fs` sync — Bun.write takes no mode and has no atomic rename; both run once, at a CLI
|
|
7
|
+
// command or at boot, before anything is served, so there is nothing for an async call to overlap.
|
|
8
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { backoffDelay } from './backoff';
|
|
10
|
+
import { renderCauseValue, stringField } from './error-render';
|
|
11
|
+
import { UltimateError } from './errors';
|
|
12
|
+
|
|
13
|
+
/** How long `icacls` or `whoami` may take before the write is refused as a failed ACL. */
|
|
14
|
+
const ACL_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
/** Owner read/write — the mode every key file is CREATED with. */
|
|
17
|
+
export const OWNER_ONLY_MODE = 0o600;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tries for a rename over a file another process holds open. Windows refuses that rename with
|
|
21
|
+
* EPERM or EBUSY rather than replacing the name the way POSIX does — an editor, an indexer or an
|
|
22
|
+
* antivirus scan holding `.secrets.key` for a moment — so a short, bounded retry outlasts the
|
|
23
|
+
* holder; a holder that never lets go is still an error, after ~150 ms rather than never.
|
|
24
|
+
*/
|
|
25
|
+
export const RENAME_ATTEMPTS = 5;
|
|
26
|
+
const isHeldOpen = (code: string | undefined): boolean => code === 'EPERM' || code === 'EBUSY';
|
|
27
|
+
|
|
28
|
+
/** Every effect, injectable: the Windows branch is proven on Linux with a fake. */
|
|
29
|
+
export interface KeyFileIo {
|
|
30
|
+
readonly platform: string;
|
|
31
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
readonly writeExclusive: (path: string, text: string, mode: number) => void;
|
|
33
|
+
readonly rename: (from: string, to: string) => void;
|
|
34
|
+
readonly remove: (path: string) => void;
|
|
35
|
+
readonly runAcl: (argv: readonly string[]) => { exitCode: number; output: string };
|
|
36
|
+
readonly username: () => string;
|
|
37
|
+
readonly sleep: (ms: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Built per call, never at import: the barrel reaches this module from browser bundles too. */
|
|
41
|
+
const systemIo = (): KeyFileIo => ({
|
|
42
|
+
platform: process.platform,
|
|
43
|
+
env: process.env,
|
|
44
|
+
// `wx`: the mode applies only when a write CREATES the file, so never write into a leftover.
|
|
45
|
+
writeExclusive: (path, text, mode) =>
|
|
46
|
+
writeFileSync(path, text, { encoding: 'utf-8', mode, flag: 'wx' }),
|
|
47
|
+
rename: renameSync,
|
|
48
|
+
remove: (path) => rmSync(path, { force: true }),
|
|
49
|
+
runAcl: (argv) => {
|
|
50
|
+
// Bounded: icacls on one local file answers in milliseconds; a hang must not hold the boot.
|
|
51
|
+
const ran = Bun.spawnSync([...argv], {
|
|
52
|
+
stdout: 'pipe',
|
|
53
|
+
stderr: 'pipe',
|
|
54
|
+
timeout: ACL_TIMEOUT_MS,
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
exitCode: ran.exitCode,
|
|
58
|
+
output: `${ran.stdout.toString()}${ran.stderr.toString()}`.trim(),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
// `whoami` prints `DOMAIN\user`, the form icacls resolves. Never `node:os`'s `userInfo`: the
|
|
62
|
+
// browser polyfill lacks it, and the barrel reaches this module from every island's bundle.
|
|
63
|
+
username: () =>
|
|
64
|
+
Bun.spawnSync(['whoami'], { stdout: 'pipe', timeout: ACL_TIMEOUT_MS }).stdout.toString().trim(),
|
|
65
|
+
sleep: (ms) => Bun.sleepSync(ms),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The account the key is granted to: `DOMAIN\user` where Windows names both. */
|
|
69
|
+
export function aclPrincipal(
|
|
70
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
71
|
+
username: () => string,
|
|
72
|
+
): string {
|
|
73
|
+
const user = env['USERNAME'];
|
|
74
|
+
const domain = env['USERDOMAIN'];
|
|
75
|
+
if (user !== undefined && user !== '') {
|
|
76
|
+
return domain !== undefined && domain !== '' ? `${domain}\\${user}` : user;
|
|
77
|
+
}
|
|
78
|
+
return username();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `/inheritance:r` drops every ACE the directory handed down; `/grant:r` REPLACES any explicit one
|
|
83
|
+
* for the principal with full control. What is left is one entry: the account that wrote the key.
|
|
84
|
+
*/
|
|
85
|
+
export function ownerOnlyAclArgv(path: string, principal: string): readonly string[] {
|
|
86
|
+
return ['icacls', path, '/inheritance:r', '/grant:r', `${principal}:F`];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** icacls refused, so the key was never written: no file is better than a readable one. */
|
|
90
|
+
export class SecretsKeyAclError extends UltimateError {
|
|
91
|
+
constructor(input: { path: string; principal: string; exitCode: number; output: string }) {
|
|
92
|
+
super({
|
|
93
|
+
code: 'X_SECRETS_KEY_ACL_FAILED',
|
|
94
|
+
cause: `icacls could not restrict ${renderCauseValue(input.path)} to ${renderCauseValue(input.principal)} (exit ${input.exitCode}: ${renderCauseValue(input.output)}), so the key was not written — on Windows a file's mode does not keep other accounts out, its ACL does`,
|
|
95
|
+
fix: 'x doctor --json # on Windows: where.exe icacls must resolve (C:\\Windows\\System32) and whoami names the account the key is granted to — or keep no key file and set ULTIMATE_SECRETS_KEY instead',
|
|
96
|
+
meta: { path: input.path, exitCode: input.exitCode },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** On win32, cut `path`'s ACL to the current account. A no-op elsewhere: the mode did it. */
|
|
102
|
+
export function restrictToOwner(path: string, io: KeyFileIo = systemIo()): void {
|
|
103
|
+
if (io.platform !== 'win32') return;
|
|
104
|
+
const principal = aclPrincipal(io.env, io.username);
|
|
105
|
+
const ran = io.runAcl(ownerOnlyAclArgv(path, principal));
|
|
106
|
+
if (ran.exitCode !== 0) {
|
|
107
|
+
throw new SecretsKeyAclError({ path, principal, exitCode: ran.exitCode, output: ran.output });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `rename(from, to)`, retried on EPERM/EBUSY only, `RENAME_ATTEMPTS` tries in all. */
|
|
112
|
+
export function renameOver(from: string, to: string, io: KeyFileIo = systemIo()): void {
|
|
113
|
+
for (let attempt = 1; ; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
io.rename(from, to);
|
|
116
|
+
return;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (attempt >= RENAME_ATTEMPTS || !isHeldOpen(stringField(error, 'code'))) throw error;
|
|
119
|
+
io.sleep(backoffDelay({ attempt, base: 10, max: 160 }));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write `text` to `path`, readable by its owner alone, atomically: a fresh temp file created at
|
|
126
|
+
* 0600 (and ACL-restricted on Windows) is renamed over the target, so a reader sees the old file or
|
|
127
|
+
* the new one, and rotating over a key that was world-readable never leaves the new key so.
|
|
128
|
+
*/
|
|
129
|
+
export function writeOwnerOnlyFile(path: string, text: string, io: KeyFileIo = systemIo()): void {
|
|
130
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
io.writeExclusive(temp, text, OWNER_ONLY_MODE);
|
|
133
|
+
restrictToOwner(temp, io);
|
|
134
|
+
renameOver(temp, path, io);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
io.remove(temp);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|