@ultimat3/core 23.0.0 → 24.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 +6 -0
- package/README.md +34 -3
- package/package.json +2 -2
- package/src/config-defaults.ts +46 -0
- package/src/config-merge.ts +8 -0
- package/src/config-shape.ts +112 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +74 -83
- package/src/context.ts +13 -1
- package/src/cookie.ts +35 -0
- package/src/cursor.ts +4 -1
- package/src/decimal-order.ts +5 -4
- package/src/dev-secrets.ts +1 -1
- package/src/error-reporter-sentry.ts +7 -3
- package/src/flight-gate.ts +16 -4
- package/src/fnv1a.ts +19 -0
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/image/errors.ts +3 -1
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +3 -1
- package/src/index.ts +12 -0
- package/src/logger.ts +77 -10
- 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/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +21 -4
- package/src/retry.ts +15 -2
- 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/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
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. */
|
|
@@ -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;
|
|
@@ -103,9 +120,9 @@ export function registerPrimitiveRegistrar(kind: PrimitiveKind, registrar: Modul
|
|
|
103
120
|
code: 'X_REGISTRAR_CONFLICT',
|
|
104
121
|
cause: `two different ${kind} registrars are loaded, so ${kind} primitives would split across two registries`,
|
|
105
122
|
// 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
|
|
123
|
+
// to one resolved version is the repair. `bun pm why <package>` names the dependents when
|
|
124
|
+
// a range genuinely disagrees and the update cannot converge on its own.
|
|
125
|
+
fix: `bun update ${PRIMITIVE_PACKAGES[kind]}`,
|
|
109
126
|
meta: { kind },
|
|
110
127
|
});
|
|
111
128
|
}
|
|
@@ -127,7 +144,7 @@ export function primitiveRegistrar(kind: PrimitiveKind): ModuleRegistrar {
|
|
|
127
144
|
throw new UltimateError({
|
|
128
145
|
code: 'X_REGISTRAR_MISSING',
|
|
129
146
|
cause: `no ${kind} registrar is loaded, so ${kind} primitives cannot be registered`,
|
|
130
|
-
fix: `bun add
|
|
147
|
+
fix: `bun add ${PRIMITIVE_PACKAGES[kind]}`,
|
|
131
148
|
meta: { kind },
|
|
132
149
|
});
|
|
133
150
|
}
|
package/src/retry.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { type BackoffCurve, backoffDelay, type JitterMode, type Random } from './backoff';
|
|
7
7
|
import { systemClock } from './clock';
|
|
8
8
|
import { classifyThrown, type ErrorRetry, statedDelayMs } from './error-retry';
|
|
9
|
+
import { finiteCount, finiteOption } from './finite-option';
|
|
9
10
|
|
|
10
11
|
export interface RetryPolicy {
|
|
11
12
|
/** Total attempts INCLUDING the first. `attempts: 1` means no retry. */
|
|
@@ -63,6 +64,9 @@ export function retryDecision(
|
|
|
63
64
|
error: unknown,
|
|
64
65
|
random?: Random,
|
|
65
66
|
): RetryDecision {
|
|
67
|
+
// Screened HERE and not only in `retry()`: this function is exported so a caller can write its
|
|
68
|
+
// own loop, and `attempt >= NaN` is false for every attempt — the loop that asks it never ends.
|
|
69
|
+
const attempts = finiteCount('a retry policy', 'attempts', policy.attempts);
|
|
66
70
|
const classification = classifyThrown(error);
|
|
67
71
|
const stop = (stoppedBy: RetryStopReason): RetryDecision => ({
|
|
68
72
|
retry: false,
|
|
@@ -74,7 +78,7 @@ export function retryDecision(
|
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
if (classification === 'terminal') return stop('terminal');
|
|
77
|
-
if (attempt >=
|
|
81
|
+
if (attempt >= attempts) return stop('attempts-exhausted');
|
|
78
82
|
|
|
79
83
|
const computed = backoffDelay({
|
|
80
84
|
attempt,
|
|
@@ -111,6 +115,16 @@ export async function retry<T>(
|
|
|
111
115
|
policy: RetryPolicy,
|
|
112
116
|
deps: RetryDeps,
|
|
113
117
|
): Promise<T> {
|
|
118
|
+
// Both bounds are refused BEFORE the first try: a policy that cannot stop the loop is a defect in
|
|
119
|
+
// the call, and running the work once first would report it as the work's own failure.
|
|
120
|
+
// `finiteOption` for the budget, not `finiteCount`: it is a duration a caller computes from a
|
|
121
|
+
// monotonic clock, so a fraction is real and a spent (negative) one means "do not wait at all".
|
|
122
|
+
// Zero stays legal and means what it always did — one try, no retry (`retry.test.ts` pins it).
|
|
123
|
+
finiteCount('a retry policy', 'attempts', policy.attempts);
|
|
124
|
+
const budget =
|
|
125
|
+
policy.timeBudgetMs === undefined
|
|
126
|
+
? undefined
|
|
127
|
+
: finiteOption('a retry policy', 'timeBudgetMs', policy.timeBudgetMs);
|
|
114
128
|
const now = deps.now ?? ((): number => systemClock.monotonic());
|
|
115
129
|
// Read once even when no budget is set: a clock call per attempt would be a cost the common case
|
|
116
130
|
// does not owe. `startedAt` is only compared against when `timeBudgetMs` is present.
|
|
@@ -122,7 +136,6 @@ export async function retry<T>(
|
|
|
122
136
|
} catch (error) {
|
|
123
137
|
const decision = retryDecision(policy, attempt, error, deps.random);
|
|
124
138
|
if (!decision.retry) throw error;
|
|
125
|
-
const budget = policy.timeBudgetMs;
|
|
126
139
|
// Decided BEFORE the wait, never after: a loop that sleeps and then discovers it is out of
|
|
127
140
|
// budget has already spent the caller's deadline on a wait nobody could use.
|
|
128
141
|
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
|
}
|
package/src/source-mask.ts
CHANGED
|
@@ -45,7 +45,11 @@ function endOfInterpolation(text: string, from: number): number {
|
|
|
45
45
|
let i = from;
|
|
46
46
|
while (i < text.length) {
|
|
47
47
|
const ch = text[i] as string;
|
|
48
|
-
|
|
48
|
+
// A regex before the comment test: in `${p.replace(/^\//, '')}` the `\//` is the regex's own
|
|
49
|
+
// escaped slash and its close, and read as a `//` comment it swallowed the `}` and the closing
|
|
50
|
+
// backtick — every declaration below masked as template text (`admin/src/errors.ts` hid one).
|
|
51
|
+
if (ch === '/' && opensRegex(text, i, from)) i = endOfRegex(text, i);
|
|
52
|
+
else if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
|
|
49
53
|
const line = text[i + 1] === '/';
|
|
50
54
|
const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
|
|
51
55
|
i = end === -1 ? text.length : line ? end : end + 2;
|
|
@@ -66,19 +70,21 @@ function endOfInterpolation(text: string, from: number): number {
|
|
|
66
70
|
* Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
|
|
67
71
|
* avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
|
|
68
72
|
* above, a number, `)`, `]`, a string's closing quote. Every other position is an operator's and
|
|
69
|
-
* opens one; `</` and `/>` are JSX delimiters. Read from the masked
|
|
73
|
+
* opens one; `</` and `/>` are JSX delimiters; `//` and `/*` open comments. Read from the masked
|
|
74
|
+
* prefix, so a comment is space — or, inside a `${}`, from the raw body from `floor` on, where the
|
|
75
|
+
* placeholder's own start is an operator position.
|
|
70
76
|
*/
|
|
71
|
-
function opensRegex(out:
|
|
72
|
-
if (out[at + 1] === '>') return false;
|
|
77
|
+
function opensRegex(out: ArrayLike<string>, at: number, floor = 0): boolean {
|
|
78
|
+
if (out[at + 1] === '>' || out[at + 1] === '/' || out[at + 1] === '*') return false;
|
|
73
79
|
let i = at - 1;
|
|
74
|
-
while (i >=
|
|
75
|
-
if (i <
|
|
80
|
+
while (i >= floor && /\s/.test(out[i] as string)) i -= 1;
|
|
81
|
+
if (i < floor) return true;
|
|
76
82
|
const ch = out[i] as string;
|
|
77
83
|
if (ch === '<' || ch === ')' || ch === ']' || QUOTES.has(ch)) return false;
|
|
78
84
|
if (!WORD.test(ch)) return true;
|
|
79
85
|
let start = i;
|
|
80
|
-
while (start >=
|
|
81
|
-
return REGEX_AFTER_WORDS.has(
|
|
86
|
+
while (start >= floor && WORD.test(out[start] as string)) start -= 1;
|
|
87
|
+
return REGEX_AFTER_WORDS.has(Array.prototype.slice.call(out, start + 1, i + 1).join(''));
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Which store backs a framework seam in this process — the in-memory one or the database — decided
|
|
2
|
+
// once. Three apps each wrote the ternary with a different predicate, and under `x dev` one seam
|
|
3
|
+
// sat in memory while every repository read the embedded Postgres.
|
|
4
|
+
|
|
5
|
+
import { resolveEnvironment } from './environment';
|
|
6
|
+
|
|
7
|
+
export const STORE_MODES = ['memory', 'database'] as const;
|
|
8
|
+
|
|
9
|
+
export type StoreMode = (typeof STORE_MODES)[number];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `memory` under `test`, `database` everywhere else. The environment and never `DATABASE_URL`:
|
|
13
|
+
* `x dev` installs the embedded PGlite as the process client and sets no URL, and a container
|
|
14
|
+
* gets its pool from the URL — both are `database`. `bun test` installs no client at all, so a
|
|
15
|
+
* statement there would have nothing to reach; that is the one carve-out. An unknown
|
|
16
|
+
* `ULTIMATE_ENV` throws `X_ENVIRONMENT_INVALID`, as `resolveEnvironment` does.
|
|
17
|
+
*
|
|
18
|
+
* `env` is required: the store a module picks at load must be testable without mutating the
|
|
19
|
+
* process environment, so the caller passes `Bun.env`.
|
|
20
|
+
*/
|
|
21
|
+
export function storeMode(env: Readonly<Record<string, string | undefined>>): StoreMode {
|
|
22
|
+
return resolveEnvironment({ env }) === 'test' ? 'memory' : 'database';
|
|
23
|
+
}
|