@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.
Files changed (45) hide show
  1. package/CLAUDE.md +252 -0
  2. package/README.md +210 -10
  3. package/package.json +2 -1
  4. package/src/actor.ts +118 -4
  5. package/src/app-version.ts +32 -0
  6. package/src/assert.ts +5 -1
  7. package/src/config.ts +47 -12
  8. package/src/context.ts +30 -3
  9. package/src/cursor.ts +25 -4
  10. package/src/env-example.ts +2 -1
  11. package/src/env.ts +14 -3
  12. package/src/environment.ts +39 -13
  13. package/src/error-codes.ts +13 -0
  14. package/src/error-render.ts +249 -0
  15. package/src/error-reporter-sentry.ts +175 -0
  16. package/src/error-reporter.ts +212 -0
  17. package/src/error-retry.ts +100 -0
  18. package/src/errors.ts +55 -7
  19. package/src/exports/error-contract.ts +61 -0
  20. package/src/exports/observability.ts +161 -0
  21. package/src/exports/secrets.ts +71 -0
  22. package/src/ids.ts +49 -7
  23. package/src/impersonate.ts +62 -0
  24. package/src/index.ts +277 -113
  25. package/src/lifecycle-deadline.ts +73 -0
  26. package/src/lifecycle-errors.ts +33 -0
  27. package/src/lifecycle.ts +178 -16
  28. package/src/logger.ts +99 -9
  29. package/src/mcp-exposure.ts +32 -0
  30. package/src/metrics.ts +0 -0
  31. package/src/otlp-metric-exporter.ts +136 -0
  32. package/src/otlp-span-exporter.ts +170 -0
  33. package/src/otlp.ts +217 -0
  34. package/src/read-capped.ts +47 -0
  35. package/src/runtime-metrics.ts +15 -0
  36. package/src/safe-url.ts +50 -0
  37. package/src/sampler.ts +126 -0
  38. package/src/schema-error-codes.ts +28 -0
  39. package/src/secrets-errors.ts +143 -0
  40. package/src/secrets-store.ts +173 -0
  41. package/src/secrets.ts +292 -0
  42. package/src/telemetry.ts +43 -11
  43. package/src/timing-safe-equal.ts +18 -0
  44. package/src/type-pins.ts +93 -0
  45. 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
- /** Log/trace-safe identity — no email, no token, stable across surfaces. */
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
- return actor.orgId === undefined
79
- ? `${actor.kind}:${actor.id}`
80
- : `${actor.kind}:${actor.id}@${actor.orgId}`;
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
- cause: `unhandled variant: ${JSON.stringify(value) ?? String(value)}`,
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: 'no action neededstop work and return',
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
- // Every log line inside a request gets the ids for free.
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
- return ctx === undefined ? undefined : { requestId: ctx.requestId, traceId: ctx.traceId };
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
- let secret = Bun.env['ULTIMATE_CURSOR_SECRET'] ?? DEV_SECRET;
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
- secret = next;
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 secret === DEV_SECRET;
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', secret).update(body).digest('hex').slice(0, 32);
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. */
@@ -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 renderEnvExample() from @ultimat3/core.',
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
- const cause = report.issues
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}="${issue.received ?? ''}" is not ${issue.expected}`,
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: report.issues },
238
+ meta: { issues: described },
228
239
  });
229
240
  }
230
241
 
@@ -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 source = options?.env ?? (process.env as Record<string, string | undefined>);
47
- const declared = source[ENVIRONMENT_KEY];
48
- if (declared !== undefined && declared !== '') {
49
- if (isEnvironment(declared)) return declared;
50
- throw new EnvironmentInvalidError({
51
- cause: `${ENVIRONMENT_KEY}="${declared}" is not one of ${ENVIRONMENTS.join(' | ')}`,
52
- fix: `export ${ENVIRONMENT_KEY}=${ENVIRONMENTS.join('|')} — one of those exact values`,
53
- meta: { key: ENVIRONMENT_KEY, received: declared, allowed: ENVIRONMENTS },
54
- });
55
- }
56
- const inherited = source['NODE_ENV'];
57
- if (isEnvironment(inherited)) return inherited;
58
- return options?.fallback ?? DEFAULT_ENVIRONMENT;
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. */
@@ -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