@ultimat3/core 1.2.0 → 2.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 +252 -0
- package/README.md +210 -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 +13 -0
- 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 +100 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +61 -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 +277 -113
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +178 -16
- 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/actor.ts
CHANGED
|
@@ -1,11 +1,75 @@
|
|
|
1
1
|
// Single responsibility: who is making the request. `agent` is a first-class kind because
|
|
2
2
|
// every action is also an MCP tool, and MCP callers go through the same authz as humans.
|
|
3
|
+
//
|
|
4
|
+
// `ActorFacts` is the extension seam: an app declares its own authz facts once, by module
|
|
5
|
+
// augmentation, and they ride on the SAME actor every surface already hands the policy layer —
|
|
6
|
+
// so a relational rule ("a friend of the author") never needs a second authz path.
|
|
3
7
|
|
|
4
8
|
/** `agent` = an MCP/LLM caller acting on behalf of a user or a service. */
|
|
5
9
|
export type ActorKind = 'user' | 'service' | 'agent' | 'anonymous';
|
|
6
10
|
|
|
7
11
|
export const ACTOR_KINDS = ['user', 'service', 'agent', 'anonymous'] as const;
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* **The channel for every app-specific fact about who is calling.** `Actor` itself carries only
|
|
15
|
+
* what every app has — `kind`, `id`, `orgId`, `roles`, `scopes` — and it never grows a field for
|
|
16
|
+
* one app's vocabulary. A `memberId`, a `tz`, a plan tier, the friend set, the block set, the org
|
|
17
|
+
* row: each is declared here, by module augmentation, and resolved ONCE per request, because a
|
|
18
|
+
* policy predicate is synchronous and may not query.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* declare module '@ultimat3/core' {
|
|
22
|
+
* interface ActorFacts {
|
|
23
|
+
* readonly memberId: string;
|
|
24
|
+
* readonly tz: string;
|
|
25
|
+
* readonly viewer: Viewer;
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Then `withFacts(actor, { memberId, tz })` at the request boundary and `actorFact(actor,
|
|
31
|
+
* 'memberId')` everywhere else. Do not thread a second identity object beside the actor and do
|
|
32
|
+
* not ask for a field on `Actor`/`ActorInit`: a fact declared here rides the SAME actor every
|
|
33
|
+
* surface already hands the policy layer, so a relational rule never needs a second authz path.
|
|
34
|
+
*
|
|
35
|
+
* Same shape as `CtxServices` and `PermissionRegistry`, for the same reason: the app declares
|
|
36
|
+
* once and every reader — predicate, action handler, component — is typed from that declaration
|
|
37
|
+
* without a single surface package learning the app's vocabulary.
|
|
38
|
+
*/
|
|
39
|
+
export interface ActorFacts {
|
|
40
|
+
/** Phantom member; never augment or read this key. */
|
|
41
|
+
readonly __ultimate?: never;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generic over the interface so `type-pins.ts` can instantiate the machinery against a sample
|
|
46
|
+
* fact set. Augmenting `ActorFacts` inside the framework would declare that fact for every app.
|
|
47
|
+
*/
|
|
48
|
+
export type FactKeysOf<F> = Exclude<keyof F, '__ultimate'>;
|
|
49
|
+
|
|
50
|
+
export type FactMapOf<F> = { readonly [K in FactKeysOf<F>]?: F[K] | undefined };
|
|
51
|
+
|
|
52
|
+
export type ActorFactKey = FactKeysOf<ActorFacts>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every declared fact, each independently absent.
|
|
56
|
+
*
|
|
57
|
+
* Optional per key, and that is the load-bearing decision: nothing can prove a fact was
|
|
58
|
+
* resolved — an actor is also minted by a test, a job runner and an MCP token exchange — so an
|
|
59
|
+
* unresolved fact reads as `undefined` and a predicate must branch on it. An absent fact is not
|
|
60
|
+
* a satisfied one, and here that is a type error rather than a convention.
|
|
61
|
+
*/
|
|
62
|
+
export type ActorFactMap = FactMapOf<ActorFacts>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Who is REALLY acting, when the effective actor is someone else. Two fields, both bounded and
|
|
66
|
+
* both already log-safe — a support engineer's id and kind, never their email or name.
|
|
67
|
+
*/
|
|
68
|
+
export interface ActorOrigin {
|
|
69
|
+
readonly actorId: string;
|
|
70
|
+
readonly actorKind: ActorKind;
|
|
71
|
+
}
|
|
72
|
+
|
|
9
73
|
export interface Actor {
|
|
10
74
|
readonly kind: ActorKind;
|
|
11
75
|
readonly id: string;
|
|
@@ -14,6 +78,14 @@ export interface Actor {
|
|
|
14
78
|
readonly roles: readonly string[];
|
|
15
79
|
/** Capability strings a policy can require (`post:publish`). */
|
|
16
80
|
readonly scopes: readonly string[];
|
|
81
|
+
/** App-declared facts. Read it through `actorFact()`; never logged — `actorLabel` is id-only. */
|
|
82
|
+
readonly facts?: ActorFactMap | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Set by `impersonate()`. Its absence is a positive statement — this actor is acting for
|
|
85
|
+
* themselves — which is what makes a refund issued during impersonation distinguishable from
|
|
86
|
+
* one the customer issued. Every surface that renders an actor renders this with it.
|
|
87
|
+
*/
|
|
88
|
+
readonly onBehalfOf?: ActorOrigin | undefined;
|
|
17
89
|
}
|
|
18
90
|
|
|
19
91
|
export interface ActorInit {
|
|
@@ -21,13 +93,19 @@ export interface ActorInit {
|
|
|
21
93
|
readonly orgId?: string | undefined;
|
|
22
94
|
readonly roles?: readonly string[] | undefined;
|
|
23
95
|
readonly scopes?: readonly string[] | undefined;
|
|
96
|
+
readonly facts?: ActorFactMap | undefined;
|
|
97
|
+
/** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
|
|
98
|
+
readonly onBehalfOf?: ActorOrigin | undefined;
|
|
24
99
|
}
|
|
25
100
|
|
|
101
|
+
const NO_FACTS: ActorFactMap = Object.freeze({});
|
|
102
|
+
|
|
26
103
|
const ANONYMOUS: Actor = Object.freeze({
|
|
27
104
|
kind: 'anonymous',
|
|
28
105
|
id: 'anonymous',
|
|
29
106
|
roles: Object.freeze([]),
|
|
30
107
|
scopes: Object.freeze([]),
|
|
108
|
+
facts: NO_FACTS,
|
|
31
109
|
});
|
|
32
110
|
|
|
33
111
|
function build(kind: ActorKind, init: ActorInit): Actor {
|
|
@@ -37,6 +115,8 @@ function build(kind: ActorKind, init: ActorInit): Actor {
|
|
|
37
115
|
orgId: init.orgId,
|
|
38
116
|
roles: Object.freeze([...(init.roles ?? [])]),
|
|
39
117
|
scopes: Object.freeze([...(init.scopes ?? [])]),
|
|
118
|
+
facts: Object.freeze({ ...init.facts }),
|
|
119
|
+
onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
|
|
40
120
|
});
|
|
41
121
|
}
|
|
42
122
|
|
|
@@ -73,9 +153,43 @@ export function hasScope(actor: Actor, scope: string): boolean {
|
|
|
73
153
|
return actor.scopes.includes(scope);
|
|
74
154
|
}
|
|
75
155
|
|
|
76
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Attach resolved facts to an actor, once, at the request boundary — the one place that already
|
|
158
|
+
* awaited the database. Returns a new frozen actor, so the actor a predicate reads later cannot
|
|
159
|
+
* be edited under it; later facts win over earlier ones for the same key.
|
|
160
|
+
*/
|
|
161
|
+
export function withFacts(actor: Actor, facts: ActorFactMap): Actor {
|
|
162
|
+
return Object.freeze({ ...actor, facts: Object.freeze({ ...actor.facts, ...facts }) });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The one way to read a declared fact. Takes `Actor | null` because that is exactly what a policy
|
|
167
|
+
* predicate is handed, and returns `undefined` for an anonymous, absent or unresolved actor —
|
|
168
|
+
* which is what makes "absent fact" a denial by construction rather than by review.
|
|
169
|
+
*/
|
|
170
|
+
export function actorFact<K extends ActorFactKey>(
|
|
171
|
+
actor: Actor | null | undefined,
|
|
172
|
+
key: K,
|
|
173
|
+
): ActorFacts[K] | undefined {
|
|
174
|
+
return actor?.facts?.[key];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The origin tuple for an actor, for stamping onto whoever they go on to impersonate. */
|
|
178
|
+
export function actorOrigin(actor: Actor): ActorOrigin {
|
|
179
|
+
return Object.freeze({ actorId: actor.id, actorKind: actor.kind });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Log/trace-safe identity — no email, no token, stable across surfaces. Under impersonation it
|
|
184
|
+
* renders `service:eng-7→user:cust-99@org-3`: the real actor, an arrow, the effective one. One
|
|
185
|
+
* string, so no reader of a log line, a span or an audit row can see the second half alone.
|
|
186
|
+
*/
|
|
77
187
|
export function actorLabel(actor: Actor): string {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
188
|
+
const base =
|
|
189
|
+
actor.orgId === undefined
|
|
190
|
+
? `${actor.kind}:${actor.id}`
|
|
191
|
+
: `${actor.kind}:${actor.id}@${actor.orgId}`;
|
|
192
|
+
return actor.onBehalfOf === undefined
|
|
193
|
+
? base
|
|
194
|
+
: `${actor.onBehalfOf.actorKind}:${actor.onBehalfOf.actorId}→${base}`;
|
|
81
195
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Single responsibility: which build of the APP this process is running. The third key in the
|
|
2
|
+
// same family as `environment.ts` (`ULTIMATE_ENV` — which deploy) and `roles.ts` (`ROLE` — what
|
|
3
|
+
// this process does): one key, one spelling, one default.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately NOT `version.ts`. That answers what version of the FRAMEWORK shipped, read from
|
|
6
|
+
// `@ultimat3/core`'s own manifest; this answers what the deploy calls itself, and on every release
|
|
7
|
+
// that does not bump both they are different strings.
|
|
8
|
+
|
|
9
|
+
export const APP_VERSION_KEY = 'APP_VERSION';
|
|
10
|
+
|
|
11
|
+
/** An unset key is a local process, never a build nobody can name. */
|
|
12
|
+
export const DEFAULT_APP_VERSION = 'dev';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `APP_VERSION`, else `dev`.
|
|
16
|
+
*
|
|
17
|
+
* One reader rather than one per caller because the value is DURABLE: it is written into
|
|
18
|
+
* `x_migrations` rows by `@ultimat3/db` and `x_backfills` rows by `@ultimat3/jobs`, both of which
|
|
19
|
+
* outlive the process that wrote them. Two packages defaulting it differently would put two names
|
|
20
|
+
* on one build, in two tables an operator reads side by side. `jobs` cannot reach `db` for it —
|
|
21
|
+
* `db` is tier 1 and off that package's import list — so the shared answer lives here, at tier 0.
|
|
22
|
+
*/
|
|
23
|
+
export function appVersion(
|
|
24
|
+
env: Readonly<Record<string, string | undefined>> = process.env as Record<
|
|
25
|
+
string,
|
|
26
|
+
string | undefined
|
|
27
|
+
>,
|
|
28
|
+
): string {
|
|
29
|
+
const declared = env[APP_VERSION_KEY];
|
|
30
|
+
// Empty is unset: a platform that exports the key with no value has named no build.
|
|
31
|
+
return declared === undefined || declared === '' ? DEFAULT_APP_VERSION : declared;
|
|
32
|
+
}
|
package/src/assert.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Single responsibility: compile-time-backed runtime assertions. Exhaustive switches and
|
|
2
2
|
// invariants both fail with a real error code, never a bare Error.
|
|
3
3
|
|
|
4
|
+
import { renderCauseValue } from './error-render';
|
|
4
5
|
import { UltimateError } from './errors';
|
|
5
6
|
|
|
6
7
|
export interface InvariantOptions {
|
|
@@ -15,7 +16,10 @@ export interface InvariantOptions {
|
|
|
15
16
|
export function assertNever(value: never, fix?: string): never {
|
|
16
17
|
throw new UltimateError({
|
|
17
18
|
code: 'X_UNREACHABLE',
|
|
18
|
-
|
|
19
|
+
// `JSON.stringify` raises on a bigint and on a cycle, and `String()` raises on a symbol, so
|
|
20
|
+
// the cause threw BEFORE `X_UNREACHABLE` existed: the caller caught a TypeError where a coded
|
|
21
|
+
// refusal belongs, and catching by code found nothing.
|
|
22
|
+
cause: `unhandled variant: ${renderCauseValue(value)}`,
|
|
19
23
|
fix: fix ?? 'add a case for the variant named in cause',
|
|
20
24
|
meta: { value },
|
|
21
25
|
});
|
package/src/config.ts
CHANGED
|
@@ -18,6 +18,20 @@ export interface ThemeConfig {
|
|
|
18
18
|
readonly tokens: Readonly<Record<string, string>>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Where a browser that failed `auth: 'required'` is sent, and where it lands afterwards.
|
|
23
|
+
*
|
|
24
|
+
* `signInPath: null` is the default and the redirect stays off until an app names its page: the
|
|
25
|
+
* framework may not invent one of its app's routes, and an app that spells it `/login` would send
|
|
26
|
+
* every unauthenticated visitor to a 404. Null means the visitor gets the problem document — the
|
|
27
|
+
* right answer for an agent, and what a browser got in production until this existed.
|
|
28
|
+
*/
|
|
29
|
+
export interface AuthConfig {
|
|
30
|
+
readonly signInPath: string | null;
|
|
31
|
+
/** Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin. */
|
|
32
|
+
readonly afterSignInPath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
export interface PwaConfig {
|
|
22
36
|
readonly enabled: boolean;
|
|
23
37
|
readonly offline: OfflineStrategy;
|
|
@@ -26,13 +40,24 @@ export interface PwaConfig {
|
|
|
26
40
|
readonly push: boolean;
|
|
27
41
|
}
|
|
28
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Deliberately thin. `urlEnv`, `poolSize` and `schema` were removed 2026-08 because **nothing
|
|
45
|
+
* read them** — the only reader of any `config.database.*` field in the repo was this file's own
|
|
46
|
+
* validator, and each of the three was unfixable where it sat:
|
|
47
|
+
*
|
|
48
|
+
* - `poolSize` — `@ultimat3/db`'s `baseClient()` layers `DATABASE_POOL_MAX` over the role profile,
|
|
49
|
+
* so the knob works; this was a second, non-functioning spelling of it.
|
|
50
|
+
* - `urlEnv` — `client.ts` reads `process.env['DATABASE_URL']` as a hardcoded literal, so a
|
|
51
|
+
* different key here could not be honoured.
|
|
52
|
+
* - `schema` — nothing emits `SET search_path`.
|
|
53
|
+
*
|
|
54
|
+
* Wiring them instead would need a tier-0 → tier-1 read the tier table forbids. Deleting is axiom
|
|
55
|
+
* 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
|
|
56
|
+
* worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
|
|
57
|
+
*/
|
|
29
58
|
export interface DatabaseConfig {
|
|
30
59
|
readonly driver: 'postgres';
|
|
31
|
-
/** Env key holding the connection string — never the string itself. */
|
|
32
|
-
readonly urlEnv: string;
|
|
33
|
-
readonly poolSize: number;
|
|
34
60
|
readonly ssl: boolean;
|
|
35
|
-
readonly schema: string;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
export interface CacheConfig {
|
|
@@ -77,6 +102,7 @@ export interface AppConfig {
|
|
|
77
102
|
readonly defaultTimeZone: string;
|
|
78
103
|
readonly defaultCurrency: string;
|
|
79
104
|
readonly theme: ThemeConfig;
|
|
105
|
+
readonly auth: AuthConfig;
|
|
80
106
|
readonly pwa: PwaConfig;
|
|
81
107
|
readonly roles: readonly Role[];
|
|
82
108
|
readonly database: DatabaseConfig;
|
|
@@ -99,6 +125,7 @@ export interface AppConfigInput {
|
|
|
99
125
|
readonly defaultTimeZone?: string | undefined;
|
|
100
126
|
readonly defaultCurrency?: string | undefined;
|
|
101
127
|
readonly theme?: Input<ThemeConfig> | undefined;
|
|
128
|
+
readonly auth?: Input<AuthConfig> | undefined;
|
|
102
129
|
readonly pwa?: Input<PwaConfig> | undefined;
|
|
103
130
|
readonly roles?: readonly Role[] | undefined;
|
|
104
131
|
readonly database?: Input<DatabaseConfig> | undefined;
|
|
@@ -125,6 +152,19 @@ function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A deliberate duplicate of `CURRENCY_CODE_PATTERN` in `packages/schema/src/money-value.ts`, which
|
|
158
|
+
* is the framework's ONE declaration of what an ISO 4217 code looks like and the source
|
|
159
|
+
* `isCurrencyCode`, the published OpenAPI `pattern` and `@ultimat3/entity`'s Postgres CHECK all
|
|
160
|
+
* derive from. This file cannot import it: `core` and `schema` are both tier 0 and `core → schema`
|
|
161
|
+
* is not in `SIDEWAYS_ALLOW` (`scripts/lib/tiers.ts`), the same wall that makes `describeValue` a
|
|
162
|
+
* character-for-character copy in `error-render.ts`.
|
|
163
|
+
*
|
|
164
|
+
* So keep the two identical, and keep the pattern inside the syntax ECMAScript, JSON Schema and
|
|
165
|
+
* POSIX ERE spell identically — a `defaultCurrency` this accepts and `t.money` refuses is an app
|
|
166
|
+
* whose configured currency cannot be written to a row.
|
|
167
|
+
*/
|
|
128
168
|
const CURRENCY_RE = /^[A-Z]{3}$/;
|
|
129
169
|
|
|
130
170
|
function isTimeZone(value: string): boolean {
|
|
@@ -151,6 +191,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
151
191
|
defaultTimeZone: 'UTC',
|
|
152
192
|
defaultCurrency: 'USD',
|
|
153
193
|
theme: { defaultMode: 'system', tokens: {} },
|
|
194
|
+
auth: { signInPath: null, afterSignInPath: '/' },
|
|
154
195
|
pwa: {
|
|
155
196
|
enabled: false,
|
|
156
197
|
offline: 'network-only',
|
|
@@ -159,13 +200,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
159
200
|
push: false,
|
|
160
201
|
},
|
|
161
202
|
roles: [...ROLES],
|
|
162
|
-
database: {
|
|
163
|
-
driver: 'postgres',
|
|
164
|
-
urlEnv: 'DATABASE_URL',
|
|
165
|
-
poolSize: 10,
|
|
166
|
-
ssl: false,
|
|
167
|
-
schema: 'public',
|
|
168
|
-
},
|
|
203
|
+
database: { driver: 'postgres', ssl: false },
|
|
169
204
|
cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
|
|
170
205
|
jobs: {
|
|
171
206
|
driver: 'postgres',
|
|
@@ -206,7 +241,6 @@ function validate(config: AppConfig): void {
|
|
|
206
241
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
207
242
|
}
|
|
208
243
|
if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
|
|
209
|
-
if (config.database.poolSize < 1) issues.push('database.poolSize must be >= 1');
|
|
210
244
|
if (config.jobs.concurrency < 1) issues.push('jobs.concurrency must be >= 1');
|
|
211
245
|
if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
|
|
212
246
|
if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
|
|
@@ -248,6 +282,7 @@ export function defineConfig(
|
|
|
248
282
|
defaultTimeZone: merged.defaultTimeZone ?? base.defaultTimeZone,
|
|
249
283
|
defaultCurrency: merged.defaultCurrency ?? base.defaultCurrency,
|
|
250
284
|
theme: section(base.theme, merged.theme),
|
|
285
|
+
auth: section(base.auth, merged.auth),
|
|
251
286
|
pwa: section(base.pwa, merged.pwa),
|
|
252
287
|
roles: merged.roles ?? base.roles,
|
|
253
288
|
database: section(base.database, merged.database),
|
package/src/context.ts
CHANGED
|
@@ -19,6 +19,15 @@ import { installedServices, isManagedService } from './service';
|
|
|
19
19
|
* interface CtxServices { readonly posts: PostRepo }
|
|
20
20
|
* }
|
|
21
21
|
* ```
|
|
22
|
+
*
|
|
23
|
+
* KNOWN GAP, and the one place in this file that axiom 3 does not hold: the index signature
|
|
24
|
+
* below makes `ctx.<anything>` a legal expression typed `unknown`, so a service nobody declared
|
|
25
|
+
* and nobody installed is not a compile error — it is a `TS18046` at its first use, or nothing
|
|
26
|
+
* at all where the value is only passed on. The reference app shipped `ctx.storage.ensureBucket()`
|
|
27
|
+
* against a method that exists in no package for exactly this reason. Closing it means deleting
|
|
28
|
+
* the index signature, which is a breaking change for every app that reaches a service through
|
|
29
|
+
* `ctx` without declaring it, and it makes `ServiceBag`'s late-bound half (`ctx.services['mail']`)
|
|
30
|
+
* the only untyped path — which is what it is for.
|
|
22
31
|
*/
|
|
23
32
|
export interface CtxServices {
|
|
24
33
|
readonly [service: string]: unknown;
|
|
@@ -198,13 +207,31 @@ export function throwIfAborted(ctx: Ctx = useContext()): void {
|
|
|
198
207
|
throw new UltimateError({
|
|
199
208
|
code: 'X_ABORTED',
|
|
200
209
|
cause: `request ${ctx.requestId} was aborted by the caller`,
|
|
201
|
-
fix: '
|
|
210
|
+
fix: 'add throwIfAborted(ctx) before expensive work, or pass fetch(url, { signal: ctx.signal }) — the caller is gone, so unwind instead of finishing',
|
|
202
211
|
meta: { requestId: ctx.requestId },
|
|
203
212
|
});
|
|
204
213
|
}
|
|
205
214
|
|
|
206
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Every log line inside a request gets the ids for free.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately bounded and deliberately non-PII: ids, kinds and the runtime role, never an email,
|
|
219
|
+
* a name or a token — a log store is not a place to discover you shipped one. What is here is
|
|
220
|
+
* exactly what an incident query needs: `orgId` to scope to a tenant, `actorId` to scope to a
|
|
221
|
+
* user, `role` to scope to a fleet, and `onBehalfOfId` so a line written under impersonation is
|
|
222
|
+
* never mistaken for the customer's own.
|
|
223
|
+
*/
|
|
207
224
|
setLoggerContextFields(() => {
|
|
208
225
|
const ctx = storage.getStore();
|
|
209
|
-
|
|
226
|
+
if (ctx === undefined) return undefined;
|
|
227
|
+
const { actor } = ctx;
|
|
228
|
+
return {
|
|
229
|
+
requestId: ctx.requestId,
|
|
230
|
+
traceId: ctx.traceId,
|
|
231
|
+
role: ctx.role,
|
|
232
|
+
actorKind: actor.kind,
|
|
233
|
+
actorId: actor.id,
|
|
234
|
+
...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
|
|
235
|
+
...(actor.onBehalfOf === undefined ? {} : { onBehalfOfId: actor.onBehalfOf.actorId }),
|
|
236
|
+
};
|
|
210
237
|
});
|
package/src/cursor.ts
CHANGED
|
@@ -38,16 +38,37 @@ export class CursorInvalidError extends UltimateError {
|
|
|
38
38
|
*/
|
|
39
39
|
const DEV_SECRET = 'ultimate-dev-cursor-secret';
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** `configureCursorSigning`'s value, when an app has called it. `undefined` means "read the env". */
|
|
42
|
+
let configured: string | undefined;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read at CALL time, never at module scope — the same rule `@ultimat3/auth`'s `oauth-cookie.ts`
|
|
46
|
+
* and `oauth-exchange.ts` follow, and for the same reason: an app that loads secrets through
|
|
47
|
+
* `openSecrets()` during boot sets `ULTIMATE_CURSOR_SECRET` *after* this module was imported, so a
|
|
48
|
+
* module-scope read signed every cursor of that process with the shipped dev key. `x doctor`
|
|
49
|
+
* warned and nothing failed.
|
|
50
|
+
*/
|
|
51
|
+
function currentSecret(): string {
|
|
52
|
+
return configured ?? Bun.env['ULTIMATE_CURSOR_SECRET'] ?? DEV_SECRET;
|
|
53
|
+
}
|
|
42
54
|
|
|
43
55
|
/** Set once at boot from the app secret. Rotating it invalidates every open cursor. */
|
|
44
56
|
export function configureCursorSigning(next: string): void {
|
|
45
|
-
|
|
57
|
+
configured = next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Test seam: forget `configureCursorSigning`, so signing falls back to the environment.
|
|
62
|
+
* The counterpart to `resetIdCounter` — a suite that configured a secret has a way back to
|
|
63
|
+
* "unconfigured", which restoring a literal cannot express.
|
|
64
|
+
*/
|
|
65
|
+
export function resetCursorSigning(): void {
|
|
66
|
+
configured = undefined;
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/** True while cursors are signed with the shipped dev key — `x doctor` reports it. */
|
|
49
70
|
export function usesDevCursorSecret(): boolean {
|
|
50
|
-
return
|
|
71
|
+
return currentSecret() === DEV_SECRET;
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
/** `base64url(payload).signature`. Opaque by contract: callers must never parse it. */
|
|
@@ -82,7 +103,7 @@ export function decodeCursor(cursor: string, scope: string): CursorPayload {
|
|
|
82
103
|
|
|
83
104
|
/** Truncated HMAC-SHA256. 128 bits is far past forging a page position. */
|
|
84
105
|
function sign(body: string): string {
|
|
85
|
-
return new Bun.CryptoHasher('sha256',
|
|
106
|
+
return new Bun.CryptoHasher('sha256', currentSecret()).update(body).digest('hex').slice(0, 32);
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
/** Constant time: the comparison must not leak how much of a forged signature was right. */
|
package/src/env-example.ts
CHANGED
|
@@ -64,7 +64,8 @@ export interface EnvExampleOptions {
|
|
|
64
64
|
/** Deterministic: declaration order in, declaration order out, so a rewrite diffs to nothing. */
|
|
65
65
|
export function renderEnvExample(schema: EnvSchema, options?: EnvExampleOptions): string {
|
|
66
66
|
const lines = [
|
|
67
|
-
'# Generated from defineEnv() — regenerate with
|
|
67
|
+
'# Generated from defineEnv() — regenerate with `x env example`. Never hand-edited: drift',
|
|
68
|
+
'# fails `x verify`.',
|
|
68
69
|
'# Commit this file. Never commit .env: Bun loads .env, .env.<mode> and .env.local for you.',
|
|
69
70
|
];
|
|
70
71
|
for (const [key, decl] of Object.entries(schema)) {
|
package/src/env.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// is reported in ONE error — an agent should never have to restart the process six times to
|
|
3
3
|
// discover six missing variables.
|
|
4
4
|
|
|
5
|
+
import { describeValue } from './error-render';
|
|
5
6
|
import { EnvMissingError } from './errors';
|
|
6
7
|
import { REDACTED, redactKeys } from './logger';
|
|
7
8
|
import { type Role, resolveRole } from './roles';
|
|
@@ -213,18 +214,28 @@ export function defineEnv<const S extends EnvSchema>(schema: S, options?: EnvOpt
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
if (!report.ok) {
|
|
216
|
-
|
|
217
|
+
// The SHAPE of a rejected value, never its content. `secret: true` masks by declaration
|
|
218
|
+
// (`:193`), but the leak is the variable nobody declared secret: the scaffold's own
|
|
219
|
+
// `DATABASE_URL` is not marked, so a malformed `postgres://user:pw@host/db` wrote its
|
|
220
|
+
// password into this `cause` — which is the boot log line AND the `--json` field, where no
|
|
221
|
+
// key is left to redact it by. The value itself still has a printer: `x env check`, through
|
|
222
|
+
// `maskedEnvValues`. `checkEnv().values` and `EnvCheckReport.issues` are untouched.
|
|
223
|
+
const described = report.issues.map((issue) => ({
|
|
224
|
+
...issue,
|
|
225
|
+
received: issue.received === undefined ? undefined : describeValue(issue.received),
|
|
226
|
+
}));
|
|
227
|
+
const cause = described
|
|
217
228
|
.map((issue) =>
|
|
218
229
|
issue.reason === 'missing'
|
|
219
230
|
? `${issue.key} is missing (expected ${issue.expected})`
|
|
220
|
-
: `${issue.key}
|
|
231
|
+
: `${issue.key} is not ${issue.expected} (received ${issue.received ?? 'undefined'})`,
|
|
221
232
|
)
|
|
222
233
|
.join('; ');
|
|
223
234
|
const keys = report.issues.map((issue) => issue.key).join(' ');
|
|
224
235
|
throw new EnvMissingError({
|
|
225
236
|
cause,
|
|
226
237
|
fix: `add ${keys} to .env (copy .env.example), then run: x env check`,
|
|
227
|
-
meta: { issues:
|
|
238
|
+
meta: { issues: described },
|
|
228
239
|
});
|
|
229
240
|
}
|
|
230
241
|
|
package/src/environment.ts
CHANGED
|
@@ -35,6 +35,22 @@ export interface ResolveEnvironmentOptions {
|
|
|
35
35
|
readonly fallback?: Environment | undefined;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** One read of the two keys, so the throwing and the non-throwing entry point cannot disagree. */
|
|
39
|
+
type EnvironmentRead =
|
|
40
|
+
| { readonly ok: true; readonly environment: Environment }
|
|
41
|
+
| { readonly ok: false; readonly declared: string };
|
|
42
|
+
|
|
43
|
+
function readEnvironment(options?: ResolveEnvironmentOptions): EnvironmentRead {
|
|
44
|
+
const source = options?.env ?? (process.env as Record<string, string | undefined>);
|
|
45
|
+
const declared = source[ENVIRONMENT_KEY];
|
|
46
|
+
if (declared !== undefined && declared !== '') {
|
|
47
|
+
return isEnvironment(declared) ? { ok: true, environment: declared } : { ok: false, declared };
|
|
48
|
+
}
|
|
49
|
+
const inherited = source['NODE_ENV'];
|
|
50
|
+
if (isEnvironment(inherited)) return { ok: true, environment: inherited };
|
|
51
|
+
return { ok: true, environment: options?.fallback ?? DEFAULT_ENVIRONMENT };
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
/**
|
|
39
55
|
* `ULTIMATE_ENV`, else `NODE_ENV`, else `development`.
|
|
40
56
|
*
|
|
@@ -43,19 +59,29 @@ export interface ResolveEnvironmentOptions {
|
|
|
43
59
|
* it is not ours to police, and CI images set it to values ("ci", "qa") that must not stop a boot.
|
|
44
60
|
*/
|
|
45
61
|
export function resolveEnvironment(options?: ResolveEnvironmentOptions): Environment {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
const read = readEnvironment(options);
|
|
63
|
+
if (read.ok) return read.environment;
|
|
64
|
+
throw new EnvironmentInvalidError({
|
|
65
|
+
cause: `${ENVIRONMENT_KEY}="${read.declared}" is not one of ${ENVIRONMENTS.join(' | ')}`,
|
|
66
|
+
fix: `export ${ENVIRONMENT_KEY}=${ENVIRONMENTS.join('|')} — one of those exact values`,
|
|
67
|
+
meta: { key: ENVIRONMENT_KEY, received: read.declared, allowed: ENVIRONMENTS },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The same resolution for a caller that must answer rather than fail — a response being rendered,
|
|
73
|
+
* a report being tagged. `undefined` means exactly one thing: `ULTIMATE_ENV` is set to something
|
|
74
|
+
* that is not an environment, the one case `resolveEnvironment` throws on. Every other input
|
|
75
|
+
* answers identically, so the key still has one reader and only the failure policy differs.
|
|
76
|
+
*
|
|
77
|
+
* The caller names its own fallback (`?? DEFAULT_ENVIRONMENT`) rather than getting one here: a
|
|
78
|
+
* process that cannot say which deploy it is has a policy about that, and it is never this file's.
|
|
79
|
+
*/
|
|
80
|
+
export function tryResolveEnvironment(
|
|
81
|
+
options?: ResolveEnvironmentOptions,
|
|
82
|
+
): Environment | undefined {
|
|
83
|
+
const read = readEnvironment(options);
|
|
84
|
+
return read.ok ? read.environment : undefined;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
/** The one production test. Anything that is not literally `production` is not production. */
|
package/src/error-codes.ts
CHANGED
|
@@ -36,22 +36,35 @@ const CORE_CODE_TITLES = {
|
|
|
36
36
|
X_ENV_MISSING: 'required environment variables are missing or invalid',
|
|
37
37
|
X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
|
|
38
38
|
X_ERROR_CODE_DUPLICATE: 'error code registered twice',
|
|
39
|
+
X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
|
|
40
|
+
X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
|
|
39
41
|
X_ID_INVALID: 'value is not a valid id',
|
|
40
42
|
X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
|
|
41
43
|
X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
|
|
42
44
|
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
43
45
|
X_INTERNAL: 'unexpected internal framework error',
|
|
44
46
|
X_INVARIANT: 'invariant violated',
|
|
47
|
+
X_METRIC_CARDINALITY:
|
|
48
|
+
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
45
49
|
X_METRIC_NAME_INVALID: 'metric name is malformed or already declared with another kind',
|
|
46
50
|
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
47
51
|
X_NO_CONTEXT: 'no request context is active',
|
|
48
52
|
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
53
|
+
X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
|
|
54
|
+
X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
|
|
55
|
+
X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
|
|
49
56
|
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
50
57
|
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
51
58
|
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
52
59
|
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
53
60
|
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
54
61
|
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
|
62
|
+
X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
|
|
63
|
+
// Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
|
|
64
|
+
// calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
|
|
65
|
+
// and a caller that went away are one pair of facts, so they are titled and classified in one
|
|
66
|
+
// place rather than by whichever package happened to raise one first.
|
|
67
|
+
X_TIMEOUT: 'operation exceeded its deadline',
|
|
55
68
|
X_UNREACHABLE: 'unreachable branch was reached',
|
|
56
69
|
} as const;
|
|
57
70
|
|