@ultimat3/core 1.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/src/context.ts ADDED
@@ -0,0 +1,210 @@
1
+ // Single responsibility: the ambient request context. Authz, tracing, locale, tz and the
2
+ // service bag reach every layer through AsyncLocalStorage instead of being threaded as
3
+ // parameters — otherwise every signature in the framework grows a `ctx` argument twice.
4
+
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { type Actor, anonymousActor } from './actor';
7
+ import { type Clock, systemClock } from './clock';
8
+ import { UltimateError } from './errors';
9
+ import { traceId as newTraceId, uuid } from './ids';
10
+ import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
11
+ import { type Role, resolveRole } from './roles';
12
+ import { installedServices, isManagedService } from './service';
13
+
14
+ /**
15
+ * Augment to attach typed services (`ctx.posts`, `ctx.jobs`, `ctx.mail`):
16
+ *
17
+ * ```ts
18
+ * declare module '@ultimat3/core' {
19
+ * interface CtxServices { readonly posts: PostRepo }
20
+ * }
21
+ * ```
22
+ */
23
+ export interface CtxServices {
24
+ readonly [service: string]: unknown;
25
+ }
26
+
27
+ export interface ServiceBag {
28
+ readonly [service: string]: unknown;
29
+ }
30
+
31
+ export interface Ctx extends CtxServices {
32
+ readonly requestId: string;
33
+ /** W3C trace id — the same value crosses HTTP -> job -> live query. */
34
+ readonly traceId: string;
35
+ readonly actor: Actor;
36
+ /** BCP-47 tag. */
37
+ readonly locale: string;
38
+ /** IANA time zone. Never format a date without it. */
39
+ readonly tz: string;
40
+ readonly buildId: string;
41
+ readonly role: Role;
42
+ readonly clock: Clock;
43
+ now(): Date;
44
+ readonly logger: Logger;
45
+ readonly signal: AbortSignal;
46
+ /** Late-bound services, for anything not worth a type augmentation. */
47
+ readonly services: ServiceBag;
48
+ }
49
+
50
+ export interface CtxInit {
51
+ readonly requestId?: string | undefined;
52
+ readonly traceId?: string | undefined;
53
+ readonly actor?: Actor | undefined;
54
+ readonly locale?: string | undefined;
55
+ readonly tz?: string | undefined;
56
+ readonly buildId?: string | undefined;
57
+ readonly role?: Role | undefined;
58
+ readonly clock?: Clock | undefined;
59
+ readonly logger?: Logger | undefined;
60
+ readonly signal?: AbortSignal | undefined;
61
+ readonly services?: ServiceBag | undefined;
62
+ }
63
+
64
+ export type CtxPatch = Omit<CtxInit, 'requestId'>;
65
+
66
+ const storage = new AsyncLocalStorage<Ctx>();
67
+
68
+ const neverAborted = new AbortController().signal;
69
+
70
+ export const DEFAULT_LOCALE = 'en';
71
+ export const DEFAULT_TIME_ZONE = 'UTC';
72
+
73
+ function buildId(): string {
74
+ return process.env['BUILD_ID'] ?? 'dev';
75
+ }
76
+
77
+ export function createContext(init: CtxInit = {}): Ctx {
78
+ const clock = init.clock ?? systemClock;
79
+ const requestId = init.requestId ?? uuid(clock);
80
+ const trace = init.traceId ?? newTraceId();
81
+ const base = init.logger ?? rootLogger;
82
+ const explicit: ServiceBag = Object.freeze({ ...(init.services ?? {}) });
83
+ const fields = {
84
+ requestId,
85
+ traceId: trace,
86
+ actor: init.actor ?? anonymousActor(),
87
+ locale: init.locale ?? DEFAULT_LOCALE,
88
+ tz: init.tz ?? DEFAULT_TIME_ZONE,
89
+ buildId: init.buildId ?? buildId(),
90
+ role: init.role ?? resolveRole(),
91
+ clock,
92
+ now: () => clock.now(),
93
+ logger: base.child({ requestId, traceId: trace }),
94
+ signal: init.signal ?? neverAborted,
95
+ };
96
+ // A registered service (`defineService`) closes over the ctx it is built for — actor, clock,
97
+ // tz — so it has to run HERE, against this exact call's fields, rather than once at boot and
98
+ // be cached: a cached instance would answer every impersonated actor with the first one's
99
+ // tenant. `preview` carries everything a factory may read except a sibling service, which is
100
+ // what stops factories from depending on one another's instances. Explicit `init.services`
101
+ // wins over an auto-installed one of the same name — a test's hand-built mock overrides the
102
+ // real thing on purpose.
103
+ const preview = Object.freeze({ ...explicit, ...fields, services: explicit }) as Ctx;
104
+ const services: ServiceBag = Object.freeze({ ...installedServices(preview), ...explicit });
105
+ const ctx = {
106
+ // Services ride ON the context, not only under `ctx.services`: `CtxServices` exists to be
107
+ // augmented, so `ctx.posts` has to BE the service. Spread first, so a service that collides
108
+ // with a framework field (`actor`, `logger`) loses — it stays reachable as
109
+ // `ctx.services.actor`, and the context's own meaning never depends on what an app named a
110
+ // service. The assertion is the one thing this package cannot prove: an augmentation
111
+ // declares which services exist, only the boot code knows whether it passed them, or
112
+ // registered a factory for them. So a service nothing installed reads as `undefined`
113
+ // through `ctx.posts` — this is a frozen plain object, and it stays one on purpose: a
114
+ // get-trap proxy that threw on absent keys would also throw on `await ctx` (the runtime
115
+ // probes `.then`), on `JSON.stringify`, and on every optional-property check.
116
+ // `useService(name)` is the path that names the failure instead of leaving a bare
117
+ // `TypeError` at the first call: it throws `X_SERVICE_MISSING`, with the installed names
118
+ // and the fix.
119
+ ...services,
120
+ ...fields,
121
+ services,
122
+ } as Ctx;
123
+ return Object.freeze(ctx);
124
+ }
125
+
126
+ export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
127
+ return storage.run(ctx, fn);
128
+ }
129
+
130
+ /** The context, or `undefined` outside a request. Prefer `useContext()` in app code. */
131
+ export function tryUseContext(): Ctx | undefined {
132
+ return storage.getStore();
133
+ }
134
+
135
+ export function useContext(): Ctx {
136
+ const ctx = storage.getStore();
137
+ if (ctx === undefined) {
138
+ throw new UltimateError({
139
+ code: 'X_NO_CONTEXT',
140
+ cause: 'useContext() was called outside of runWithContext()',
141
+ fix: 'wrap the entry point in runWithContext(createContext({ ... }), fn)',
142
+ });
143
+ }
144
+ return ctx;
145
+ }
146
+
147
+ export function hasContext(): boolean {
148
+ return storage.getStore() !== undefined;
149
+ }
150
+
151
+ /**
152
+ * Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
153
+ * `requestId` is deliberately not patchable: one request, one id.
154
+ */
155
+ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
156
+ const parent = useContext();
157
+ // A factory-managed service was built for the PARENT's actor; forwarding it verbatim into an
158
+ // impersonated child would answer every call with the parent's tenant. `createContext` below
159
+ // rebuilds every registered factory fresh against the child's own actor, so only services no
160
+ // factory owns — a hand-built mock nothing registered — carry forward unrebuilt.
161
+ const carried = Object.fromEntries(
162
+ Object.entries(parent.services).filter(([name]) => !isManagedService(name)),
163
+ );
164
+ const child = createContext({
165
+ requestId: parent.requestId,
166
+ traceId: patch.traceId ?? parent.traceId,
167
+ actor: patch.actor ?? parent.actor,
168
+ locale: patch.locale ?? parent.locale,
169
+ tz: patch.tz ?? parent.tz,
170
+ buildId: parent.buildId,
171
+ role: patch.role ?? parent.role,
172
+ clock: patch.clock ?? parent.clock,
173
+ logger: patch.logger ?? parent.logger,
174
+ signal: patch.signal ?? parent.signal,
175
+ services: { ...carried, ...(patch.services ?? {}) },
176
+ });
177
+ return storage.run(child, fn);
178
+ }
179
+
180
+ /** Resolve a late-bound service. Throws `X_SERVICE_MISSING` rather than returning undefined. */
181
+ export function useService<T>(name: string): T {
182
+ const ctx = useContext();
183
+ const service = ctx.services[name];
184
+ if (service === undefined) {
185
+ throw new UltimateError({
186
+ code: 'X_SERVICE_MISSING',
187
+ cause: `"${name}" is not on ctx.services (have: ${Object.keys(ctx.services).join(', ')})`,
188
+ fix: `pass it in createContext({ services: { ${name} } })`,
189
+ meta: { name },
190
+ });
191
+ }
192
+ return service as T;
193
+ }
194
+
195
+ /** Throws `X_ABORTED` if the caller has gone away — call before expensive work. */
196
+ export function throwIfAborted(ctx: Ctx = useContext()): void {
197
+ if (!ctx.signal.aborted) return;
198
+ throw new UltimateError({
199
+ code: 'X_ABORTED',
200
+ cause: `request ${ctx.requestId} was aborted by the caller`,
201
+ fix: 'no action needed — stop work and return',
202
+ meta: { requestId: ctx.requestId },
203
+ });
204
+ }
205
+
206
+ // Every log line inside a request gets the ids for free.
207
+ setLoggerContextFields(() => {
208
+ const ctx = storage.getStore();
209
+ return ctx === undefined ? undefined : { requestId: ctx.requestId, traceId: ctx.traceId };
210
+ });
package/src/cursor.ts ADDED
@@ -0,0 +1,116 @@
1
+ // Single responsibility: the one keyset-cursor codec. A page position is signed here and
2
+ // verified here, so the repo, the read primitive and the admin cannot drift into three formats
3
+ // with three trust levels.
4
+ //
5
+ // Signed, not encrypted: the client already holds the rows the cursor points at. What the
6
+ // signature buys is that a client cannot *invent* a position, and what `scope` buys is that a
7
+ // cursor from one read cannot be replayed against another — either is `X_CURSOR_INVALID`, never
8
+ // a silently wrong page. It is tamper-evidence, not authorization: policy still runs per page.
9
+
10
+ import { UltimateError } from './errors';
11
+
12
+ export interface CursorPayload {
13
+ /** What this cursor belongs to: one read plus its arguments. A cursor is not portable. */
14
+ readonly scope: string;
15
+ /** The ordering tuple of the last row on the page, in `orderBy` order. */
16
+ readonly key: readonly unknown[];
17
+ /** Primary key of that row — the tiebreak that makes the sort order total. */
18
+ readonly id: string;
19
+ }
20
+
21
+ export class CursorInvalidError extends UltimateError {
22
+ override readonly name = 'CursorInvalidError';
23
+
24
+ constructor(reason: string) {
25
+ super({
26
+ code: 'X_CURSOR_INVALID',
27
+ cause: `cursor rejected: ${reason}`,
28
+ fix: 'drop the cursor and request the first page again (after: null)',
29
+ meta: { reason },
30
+ });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Dev default so `x dev` pages without configuration. Production sets `ULTIMATE_CURSOR_SECRET`
36
+ * — a fixed literal rather than a per-process random one on purpose: a random secret would make
37
+ * a cursor issued by one instance fail on the next, and that failure only shows up under scale.
38
+ */
39
+ const DEV_SECRET = 'ultimate-dev-cursor-secret';
40
+
41
+ let secret = Bun.env['ULTIMATE_CURSOR_SECRET'] ?? DEV_SECRET;
42
+
43
+ /** Set once at boot from the app secret. Rotating it invalidates every open cursor. */
44
+ export function configureCursorSigning(next: string): void {
45
+ secret = next;
46
+ }
47
+
48
+ /** True while cursors are signed with the shipped dev key — `x doctor` reports it. */
49
+ export function usesDevCursorSecret(): boolean {
50
+ return secret === DEV_SECRET;
51
+ }
52
+
53
+ /** `base64url(payload).signature`. Opaque by contract: callers must never parse it. */
54
+ export function encodeCursor(payload: CursorPayload): string {
55
+ const body = encodeBody(JSON.stringify([payload.scope, payload.id, payload.key]));
56
+ return `${body}.${sign(body)}`;
57
+ }
58
+
59
+ /**
60
+ * The only way back. `scope` is required rather than optional because an optional check is one
61
+ * a call site can forget, and a forgotten check is a cursor from another query paging this one.
62
+ */
63
+ export function decodeCursor(cursor: string, scope: string): CursorPayload {
64
+ const dot = cursor.lastIndexOf('.');
65
+ if (dot <= 0) throw new CursorInvalidError('not a signed cursor');
66
+ const body = cursor.slice(0, dot);
67
+ if (!sameSignature(sign(body), cursor.slice(dot + 1))) {
68
+ throw new CursorInvalidError('signature does not match — tampered with, or the secret rotated');
69
+ }
70
+
71
+ const parsed = parseBody(body);
72
+ if (!Array.isArray(parsed) || parsed.length !== 3) throw new CursorInvalidError('not a cursor');
73
+ const [encodedScope, id, key] = parsed as readonly unknown[];
74
+ if (typeof encodedScope !== 'string' || typeof id !== 'string' || !Array.isArray(key)) {
75
+ throw new CursorInvalidError('not a cursor');
76
+ }
77
+ if (encodedScope !== scope) {
78
+ throw new CursorInvalidError('it belongs to a different query, filter or sort order');
79
+ }
80
+ return { scope: encodedScope, key, id };
81
+ }
82
+
83
+ /** Truncated HMAC-SHA256. 128 bits is far past forging a page position. */
84
+ function sign(body: string): string {
85
+ return new Bun.CryptoHasher('sha256', secret).update(body).digest('hex').slice(0, 32);
86
+ }
87
+
88
+ /** Constant time: the comparison must not leak how much of a forged signature was right. */
89
+ function sameSignature(expected: string, actual: string): boolean {
90
+ if (expected.length !== actual.length) return false;
91
+ let difference = 0;
92
+ for (let index = 0; index < expected.length; index += 1) {
93
+ difference |= expected.charCodeAt(index) ^ actual.charCodeAt(index);
94
+ }
95
+ return difference === 0;
96
+ }
97
+
98
+ // A cursor travels in a query string and carries row values, so the encoding has to survive
99
+ // both: base64url (no `+`, `/` or `=` for a caller to re-encode) over UTF-8 bytes — `btoa`
100
+ // alone throws above code point 0xFF, and one accented title would break pagination.
101
+ function encodeBody(text: string): string {
102
+ const bytes = new TextEncoder().encode(text);
103
+ let binary = '';
104
+ for (const byte of bytes) binary += String.fromCharCode(byte);
105
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
106
+ }
107
+
108
+ function parseBody(body: string): unknown {
109
+ const padded = body.replaceAll('-', '+').replaceAll('_', '/');
110
+ try {
111
+ const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '='));
112
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
113
+ } catch {
114
+ throw new CursorInvalidError('payload is not readable');
115
+ }
116
+ }
package/src/env.ts ADDED
@@ -0,0 +1,259 @@
1
+ // Single responsibility: typed environment validation at boot. Every missing or malformed key
2
+ // is reported in ONE error — an agent should never have to restart the process six times to
3
+ // discover six missing variables.
4
+
5
+ import { EnvMissingError } from './errors';
6
+ import { redactKeys } from './logger';
7
+ import { type Role, resolveRole } from './roles';
8
+
9
+ export type EnvVarType = 'string' | 'url' | 'number' | 'integer' | 'port' | 'boolean' | 'enum';
10
+
11
+ interface EnvVarCommon {
12
+ /**
13
+ * Omit for a required variable (the default). `required: false` is the only accepted
14
+ * loosening — there is one way to say "optional".
15
+ */
16
+ readonly required?: false | undefined;
17
+ /** Redacted in logs and masked in `x env check --json`. */
18
+ readonly secret?: boolean | undefined;
19
+ /** Only required when the process runs as one of these roles. */
20
+ readonly role?: Role | readonly Role[] | undefined;
21
+ readonly description?: string | undefined;
22
+ /** Overrides the generic fix line for this key. */
23
+ readonly fix?: string | undefined;
24
+ }
25
+
26
+ export interface EnvStringVar extends EnvVarCommon {
27
+ readonly type: 'string' | 'url';
28
+ readonly default?: string | undefined;
29
+ readonly pattern?: RegExp | undefined;
30
+ }
31
+
32
+ export interface EnvNumberVar extends EnvVarCommon {
33
+ readonly type: 'number' | 'integer' | 'port';
34
+ readonly default?: number | undefined;
35
+ readonly min?: number | undefined;
36
+ readonly max?: number | undefined;
37
+ }
38
+
39
+ export interface EnvBooleanVar extends EnvVarCommon {
40
+ readonly type: 'boolean';
41
+ readonly default?: boolean | undefined;
42
+ }
43
+
44
+ export interface EnvEnumVar<V extends string = string> extends EnvVarCommon {
45
+ readonly type: 'enum';
46
+ readonly values: readonly V[];
47
+ readonly default?: V | undefined;
48
+ }
49
+
50
+ export type EnvVarDecl = EnvStringVar | EnvNumberVar | EnvBooleanVar | EnvEnumVar;
51
+
52
+ export type EnvSchema = Readonly<Record<string, EnvVarDecl>>;
53
+
54
+ type EnvVarValue<D> = D extends { readonly type: 'enum'; readonly values: readonly (infer V)[] }
55
+ ? V
56
+ : D extends { readonly type: 'number' | 'integer' | 'port' }
57
+ ? number
58
+ : D extends { readonly type: 'boolean' }
59
+ ? boolean
60
+ : string;
61
+
62
+ type EnvVarOptional<D> = D extends { readonly default: unknown }
63
+ ? false
64
+ : D extends { readonly role: unknown }
65
+ ? true
66
+ : D extends { readonly required: false }
67
+ ? true
68
+ : false;
69
+
70
+ export type Env<S extends EnvSchema> = {
71
+ readonly [K in keyof S]: EnvVarOptional<S[K]> extends true
72
+ ? EnvVarValue<S[K]> | undefined
73
+ : EnvVarValue<S[K]>;
74
+ };
75
+
76
+ export interface EnvIssue {
77
+ readonly key: string;
78
+ readonly reason: 'missing' | 'invalid';
79
+ readonly expected: string;
80
+ /** Masked when the declaration is `secret: true`. */
81
+ readonly received: string | undefined;
82
+ readonly fix: string;
83
+ }
84
+
85
+ export interface EnvCheckReport {
86
+ readonly ok: boolean;
87
+ readonly issues: readonly EnvIssue[];
88
+ readonly values: Readonly<Record<string, unknown>>;
89
+ }
90
+
91
+ export interface EnvOptions {
92
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
93
+ readonly role?: Role | undefined;
94
+ /** Register `secret: true` keys with the logger's redaction list. Default `true`. */
95
+ readonly redact?: boolean | undefined;
96
+ }
97
+
98
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
99
+ const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
100
+
101
+ function requiredForRole(decl: EnvVarDecl, role: Role): boolean {
102
+ if (decl.required === false) return false;
103
+ if (decl.role === undefined) return true;
104
+ return typeof decl.role === 'string' ? decl.role === role : decl.role.includes(role);
105
+ }
106
+
107
+ function expectation(decl: EnvVarDecl): string {
108
+ switch (decl.type) {
109
+ case 'enum':
110
+ return `one of ${decl.values.join(' | ')}`;
111
+ case 'port':
112
+ return 'an integer port 1-65535';
113
+ case 'integer':
114
+ return 'an integer';
115
+ case 'number':
116
+ return 'a number';
117
+ case 'boolean':
118
+ return `one of ${[...TRUE_VALUES, ...FALSE_VALUES].join(' | ')}`;
119
+ case 'url':
120
+ return 'an absolute URL';
121
+ default:
122
+ return 'a non-empty string';
123
+ }
124
+ }
125
+
126
+ function parseValue(decl: EnvVarDecl, raw: string): { ok: true; value: unknown } | { ok: false } {
127
+ switch (decl.type) {
128
+ case 'boolean': {
129
+ const lowered = raw.toLowerCase();
130
+ if (TRUE_VALUES.has(lowered)) return { ok: true, value: true };
131
+ if (FALSE_VALUES.has(lowered)) return { ok: true, value: false };
132
+ return { ok: false };
133
+ }
134
+ case 'number':
135
+ case 'integer':
136
+ case 'port': {
137
+ const value = Number(raw);
138
+ if (!Number.isFinite(value)) return { ok: false };
139
+ if (decl.type !== 'number' && !Number.isInteger(value)) return { ok: false };
140
+ if (decl.type === 'port' && (value < 1 || value > 65535)) return { ok: false };
141
+ if (decl.min !== undefined && value < decl.min) return { ok: false };
142
+ if (decl.max !== undefined && value > decl.max) return { ok: false };
143
+ return { ok: true, value };
144
+ }
145
+ case 'enum':
146
+ return (decl.values as readonly string[]).includes(raw)
147
+ ? { ok: true, value: raw }
148
+ : { ok: false };
149
+ case 'url':
150
+ return URL.canParse(raw) ? { ok: true, value: raw } : { ok: false };
151
+ default:
152
+ if (decl.pattern !== undefined && !decl.pattern.test(raw)) return { ok: false };
153
+ return { ok: true, value: raw };
154
+ }
155
+ }
156
+
157
+ /** Validate without throwing — this is what `x env check --json` prints. */
158
+ export function checkEnv(schema: EnvSchema, options?: EnvOptions): EnvCheckReport {
159
+ const source = options?.env ?? (process.env as Record<string, string | undefined>);
160
+ const role = options?.role ?? resolveRole({ env: source });
161
+ const issues: EnvIssue[] = [];
162
+ const values: Record<string, unknown> = {};
163
+
164
+ for (const [key, decl] of Object.entries(schema)) {
165
+ const raw = source[key];
166
+ if (raw === undefined || raw === '') {
167
+ if (decl.default !== undefined) {
168
+ values[key] = decl.default;
169
+ continue;
170
+ }
171
+ if (requiredForRole(decl, role)) {
172
+ issues.push({
173
+ key,
174
+ reason: 'missing',
175
+ expected: expectation(decl),
176
+ received: undefined,
177
+ fix: decl.fix ?? `add ${key}= to .env`,
178
+ });
179
+ } else {
180
+ values[key] = undefined;
181
+ }
182
+ continue;
183
+ }
184
+ const parsed = parseValue(decl, raw);
185
+ if (parsed.ok) {
186
+ values[key] = parsed.value;
187
+ continue;
188
+ }
189
+ issues.push({
190
+ key,
191
+ reason: 'invalid',
192
+ expected: expectation(decl),
193
+ received: decl.secret === true ? '***' : raw,
194
+ fix: decl.fix ?? `set ${key} to ${expectation(decl)} in .env`,
195
+ });
196
+ }
197
+
198
+ return { ok: issues.length === 0, issues, values };
199
+ }
200
+
201
+ /**
202
+ * Validate the process environment against `schema` and return a frozen typed object.
203
+ * Throws `X_ENV_MISSING` listing EVERY offending key at once.
204
+ */
205
+ export function defineEnv<const S extends EnvSchema>(schema: S, options?: EnvOptions): Env<S> {
206
+ const report = checkEnv(schema, options);
207
+
208
+ if (options?.redact !== false) {
209
+ const secrets = Object.entries(schema)
210
+ .filter(([, decl]) => decl.secret === true)
211
+ .map(([key]) => key);
212
+ if (secrets.length > 0) redactKeys(secrets);
213
+ }
214
+
215
+ if (!report.ok) {
216
+ const cause = report.issues
217
+ .map((issue) =>
218
+ issue.reason === 'missing'
219
+ ? `${issue.key} is missing (expected ${issue.expected})`
220
+ : `${issue.key}="${issue.received ?? ''}" is not ${issue.expected}`,
221
+ )
222
+ .join('; ');
223
+ const keys = report.issues.map((issue) => issue.key).join(' ');
224
+ throw new EnvMissingError({
225
+ cause,
226
+ fix: `add ${keys} to .env (copy .env.example), then run: x env check`,
227
+ meta: { issues: report.issues },
228
+ });
229
+ }
230
+
231
+ return Object.freeze(report.values) as Env<S>;
232
+ }
233
+
234
+ export interface EnvVarSummary {
235
+ readonly key: string;
236
+ readonly type: EnvVarType;
237
+ readonly required: boolean;
238
+ readonly secret: boolean;
239
+ readonly hasDefault: boolean;
240
+ readonly roles: readonly Role[] | 'all';
241
+ readonly description: string | undefined;
242
+ }
243
+
244
+ function rolesOf(role: Role | readonly Role[]): readonly Role[] {
245
+ return typeof role === 'string' ? [role] : role;
246
+ }
247
+
248
+ /** Declarations only, never values — safe to print, safe to commit to `x.manifest.json`. */
249
+ export function describeEnv(schema: EnvSchema): readonly EnvVarSummary[] {
250
+ return Object.entries(schema).map(([key, decl]) => ({
251
+ key,
252
+ type: decl.type,
253
+ required: decl.required !== false,
254
+ secret: decl.secret === true,
255
+ hasDefault: decl.default !== undefined,
256
+ roles: decl.role === undefined ? 'all' : rolesOf(decl.role),
257
+ description: decl.description,
258
+ }));
259
+ }