@ultimat3/core 1.2.0 → 3.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 +272 -0
- package/README.md +248 -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 +15 -1
- 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 +112 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +62 -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 +279 -113
- package/src/intl-cache.ts +43 -0
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +237 -34
- 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/sampler.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Single responsibility: the sampling decision — the one lever between "tracing is on" and "the
|
|
2
|
+
// collector melts". Separate from `telemetry.ts` because the decision is a policy an app replaces,
|
|
3
|
+
// while span construction is not.
|
|
4
|
+
|
|
5
|
+
import { logger } from './logger';
|
|
6
|
+
import type { SpanAttributes, SpanContext } from './telemetry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The seam. `parent` is the inbound span context when there is one — an upstream that decided
|
|
10
|
+
* "not sampled" has said so in `parent.traceFlags`, and a sampler that ignores it splits one
|
|
11
|
+
* distributed trace into a sampled half and an unsampled half, which is worse than either.
|
|
12
|
+
*/
|
|
13
|
+
export interface Sampler {
|
|
14
|
+
shouldSample(name: string, parent: SpanContext | undefined, attributes: SpanAttributes): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const OTEL_SAMPLER_KEY = 'OTEL_TRACES_SAMPLER';
|
|
18
|
+
export const OTEL_SAMPLER_ARG_KEY = 'OTEL_TRACES_SAMPLER_ARG';
|
|
19
|
+
|
|
20
|
+
/** Unset means on, exactly as OTel's own `parentbased_always_on` default does. */
|
|
21
|
+
export const DEFAULT_SAMPLE_RATIO = 1;
|
|
22
|
+
|
|
23
|
+
export const alwaysOnSampler: Sampler = Object.freeze({
|
|
24
|
+
shouldSample: (): boolean => true,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const alwaysOffSampler: Sampler = Object.freeze({
|
|
28
|
+
shouldSample: (): boolean => false,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function parentSampled(parent: SpanContext | undefined): boolean | undefined {
|
|
32
|
+
return parent === undefined ? undefined : (parent.traceFlags & 1) === 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Honour the parent, else sample a `ratio` fraction of new traces.
|
|
37
|
+
*
|
|
38
|
+
* `random()` rather than a hash of the trace id: this sampler only ever decides for a ROOT span —
|
|
39
|
+
* a span with a parent takes the parent's bit verbatim — so there is no second service whose
|
|
40
|
+
* independent decision has to agree with ours, which is the only thing trace-id hashing buys.
|
|
41
|
+
* `random` is injectable so the ratio is a test and not a coin flip.
|
|
42
|
+
*/
|
|
43
|
+
export function parentBasedRatioSampler(
|
|
44
|
+
ratio: number,
|
|
45
|
+
random: () => number = Math.random,
|
|
46
|
+
): Sampler {
|
|
47
|
+
return {
|
|
48
|
+
shouldSample(_name, parent): boolean {
|
|
49
|
+
const inherited = parentSampled(parent);
|
|
50
|
+
if (inherited !== undefined) return inherited;
|
|
51
|
+
if (ratio >= 1) return true;
|
|
52
|
+
if (ratio <= 0) return false;
|
|
53
|
+
return random() < ratio;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `always_off` / `always_on` without the parent-based prefix ignore the inbound decision. */
|
|
59
|
+
export function ratioSampler(ratio: number, random: () => number = Math.random): Sampler {
|
|
60
|
+
return {
|
|
61
|
+
shouldSample(): boolean {
|
|
62
|
+
if (ratio >= 1) return true;
|
|
63
|
+
if (ratio <= 0) return false;
|
|
64
|
+
return random() < ratio;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readRatio(raw: string | undefined, spelling: string): number {
|
|
70
|
+
if (raw === undefined || raw.trim() === '') return DEFAULT_SAMPLE_RATIO;
|
|
71
|
+
const parsed = Number.parseFloat(raw);
|
|
72
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
73
|
+
// Warn rather than throw: this is read at the first span, not at boot, and a process that
|
|
74
|
+
// dies mid-request over a sampling typo has turned an observability misconfiguration into an
|
|
75
|
+
// outage. Falling back to 1 keeps the traces — losing them silently is the worse failure.
|
|
76
|
+
logger.warn('X_TELEMETRY_SAMPLER_ARG_INVALID', {
|
|
77
|
+
cause: `${spelling}="${raw}" is not a ratio between 0 and 1; sampling every trace instead`,
|
|
78
|
+
fix: `set ${spelling} to a value between 0 and 1, e.g. ${spelling}=0.05`,
|
|
79
|
+
});
|
|
80
|
+
return DEFAULT_SAMPLE_RATIO;
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The sampler the env asks for. Recognises the OTel spellings an operator already knows; anything
|
|
87
|
+
* else falls through to parent-based ratio, which is the default behaviour either way.
|
|
88
|
+
*/
|
|
89
|
+
export function samplerFromEnv(
|
|
90
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
91
|
+
): Sampler {
|
|
92
|
+
const name = (env[OTEL_SAMPLER_KEY] ?? '').trim().toLowerCase();
|
|
93
|
+
const ratio = readRatio(env[OTEL_SAMPLER_ARG_KEY], OTEL_SAMPLER_ARG_KEY);
|
|
94
|
+
switch (name) {
|
|
95
|
+
case 'always_off':
|
|
96
|
+
return alwaysOffSampler;
|
|
97
|
+
case 'always_on':
|
|
98
|
+
return alwaysOnSampler;
|
|
99
|
+
case 'traceidratio':
|
|
100
|
+
return ratioSampler(ratio);
|
|
101
|
+
case 'parentbased_always_off':
|
|
102
|
+
return parentBasedRatioSampler(0);
|
|
103
|
+
default:
|
|
104
|
+
// `parentbased_always_on`, `parentbased_traceidratio` and the unset case are one sampler:
|
|
105
|
+
// honour the parent, else the ratio — which is 1 when nothing set an arg.
|
|
106
|
+
return parentBasedRatioSampler(ratio);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let cached: Sampler | undefined;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read at the first span, never at module scope: `installSecrets()` and `defineEnv()` both land
|
|
114
|
+
* values in `process.env` during boot, and a module-scope read would pin whatever was set before
|
|
115
|
+
* the app configured itself — the same defect `cursor.ts` fixed by moving its secret read into
|
|
116
|
+
* `sign()`.
|
|
117
|
+
*/
|
|
118
|
+
export function defaultSampler(): Sampler {
|
|
119
|
+
if (cached === undefined) cached = samplerFromEnv();
|
|
120
|
+
return cached;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Test-only: forget the env-derived sampler so the next read sees the current environment. */
|
|
124
|
+
export function resetDefaultSampler(): void {
|
|
125
|
+
cached = undefined;
|
|
126
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: register `@ultimat3/schema`'s error codes so their titles render for any
|
|
2
|
+
// process that imports `@ultimat3/core` — not just the CLI. `@ultimat3/schema` is tier 0 alongside
|
|
3
|
+
// this package, so it cannot call `registerErrorCodes()` itself (that would mean importing core,
|
|
4
|
+
// a same-tier import) and this package cannot import schema to read its declarations back (same
|
|
5
|
+
// reason, the other direction). The codes below are a deliberate, tested duplicate of
|
|
6
|
+
// `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts` — `schema-error-codes-pin.test.ts`, in a
|
|
7
|
+
// package that may legally import both (`@ultimat3/cli`), asserts them equal so a title edited in
|
|
8
|
+
// one place and not the other fails the build instead of quietly disagreeing at runtime.
|
|
9
|
+
|
|
10
|
+
import { registerErrorCodes } from './error-codes';
|
|
11
|
+
|
|
12
|
+
/** Mirrors `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`. Keep the titles identical. */
|
|
13
|
+
export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object.freeze({
|
|
14
|
+
X_VALIDATION_FAILED: 'value did not match its schema',
|
|
15
|
+
X_SCHEMA_UNSUPPORTED: 'the active schema provider cannot do this',
|
|
16
|
+
X_SCHEMA_DISCRIMINANT_INVALID: 'a discriminated union member can never be dispatched to',
|
|
17
|
+
X_SCHEMA_DEFAULT_UNSHAREABLE: 'a schema default cannot be copied per parse',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because core does not own
|
|
21
|
+
// these codes — `@ultimat3/schema` does — and `registerErrorCodes` is the one mechanism that
|
|
22
|
+
// raises `X_ERROR_CODE_DUPLICATE` if a package that DOES own one of them ever tries to register it
|
|
23
|
+
// too, which pins ownership even though the titles live in two files.
|
|
24
|
+
registerErrorCodes(
|
|
25
|
+
Object.fromEntries(
|
|
26
|
+
Object.entries(SCHEMA_ERROR_CODE_TITLES).map(([code, title]) => [code, { title }]),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Single responsibility: the seven X_SECRETS_* conditions and the errors that carry them. Split
|
|
2
|
+
// from `secrets.ts` so the crypto module reads as crypto — and there are seven codes rather than
|
|
3
|
+
// one "secrets are broken" because each names a different thing an operator did and a different
|
|
4
|
+
// command that undoes it. No error here ever carries a key, a ciphertext or a decrypted value.
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes } from './error-codes';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
/** Codes `@ultimat3/core` owns for the encrypted-secrets file. */
|
|
10
|
+
export const SECRETS_ERROR_CODES = [
|
|
11
|
+
'X_SECRETS_KEY_MISSING',
|
|
12
|
+
'X_SECRETS_KEY_INVALID',
|
|
13
|
+
'X_SECRETS_KEY_MISMATCH',
|
|
14
|
+
'X_SECRETS_FILE_MISSING',
|
|
15
|
+
'X_SECRETS_FILE_INVALID',
|
|
16
|
+
'X_SECRETS_TAMPERED',
|
|
17
|
+
'X_SECRETS_PLAINTEXT_INVALID',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type SecretsErrorCode = (typeof SECRETS_ERROR_CODES)[number];
|
|
21
|
+
|
|
22
|
+
const SECRETS_ERROR_TITLES: Readonly<Record<SecretsErrorCode, string>> = {
|
|
23
|
+
X_SECRETS_KEY_MISSING: 'no master key for the encrypted secrets file',
|
|
24
|
+
X_SECRETS_KEY_INVALID: 'the master key is not 32 bytes of hex',
|
|
25
|
+
X_SECRETS_KEY_MISMATCH: 'the secrets file was sealed with a different master key',
|
|
26
|
+
X_SECRETS_FILE_MISSING: 'the encrypted secrets file does not exist',
|
|
27
|
+
X_SECRETS_FILE_INVALID: 'the encrypted secrets file is not a readable envelope',
|
|
28
|
+
X_SECRETS_TAMPERED: 'the secrets file failed its authentication tag',
|
|
29
|
+
X_SECRETS_PLAINTEXT_INVALID: 'the decrypted secrets are not a flat map of env values',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because these codes and the
|
|
33
|
+
// module that throws them ship together: `registerErrorCodes` is the documented way a set of codes
|
|
34
|
+
// joins the registry, and it raises `X_ERROR_CODE_DUPLICATE` if anything else ever claims one.
|
|
35
|
+
registerErrorCodes(
|
|
36
|
+
Object.fromEntries(
|
|
37
|
+
Object.entries(SECRETS_ERROR_TITLES).map(([code, title]) => [code, { title }]),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* No key in the environment and none on disk, while an encrypted file exists. Deliberately fatal
|
|
43
|
+
* rather than a warning: booting without the secrets a deploy was configured with produces an app
|
|
44
|
+
* that authenticates against nothing and reports itself healthy.
|
|
45
|
+
*/
|
|
46
|
+
export class SecretsKeyMissingError extends UltimateError {
|
|
47
|
+
constructor(input: { envVar: string; keyPath: string }) {
|
|
48
|
+
super({
|
|
49
|
+
code: 'X_SECRETS_KEY_MISSING',
|
|
50
|
+
cause: `${input.envVar} is unset and ${input.keyPath} does not exist, so nothing can open the encrypted secrets`,
|
|
51
|
+
fix: `export ${input.envVar}="$(cat ${input.keyPath})" # in a repo that has no key yet: x secrets init`,
|
|
52
|
+
meta: { keyPath: input.keyPath },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Key material that is not 64 lowercase hex characters. A truncated paste is the usual cause. */
|
|
58
|
+
export class SecretsKeyInvalidError extends UltimateError {
|
|
59
|
+
constructor(input: { at: string; found: number; expected: number }) {
|
|
60
|
+
super({
|
|
61
|
+
code: 'X_SECRETS_KEY_INVALID',
|
|
62
|
+
cause: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`,
|
|
63
|
+
fix: `export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)" # the key file holds the ${input.expected} characters verbatim, no newline of its own`,
|
|
64
|
+
meta: { at: input.at },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A well-formed key that is not the one this file was sealed with. Distinguishable from tampering
|
|
71
|
+
* only because the envelope carries a key id — a domain-separated SHA-256 of the key, which is
|
|
72
|
+
* safe to commit and is what turns "it will not decrypt" into two different instructions.
|
|
73
|
+
*/
|
|
74
|
+
export class SecretsKeyMismatchError extends UltimateError {
|
|
75
|
+
constructor(input: { at: string; keyAt: string; sealedWith: string; found: string }) {
|
|
76
|
+
super({
|
|
77
|
+
code: 'X_SECRETS_KEY_MISMATCH',
|
|
78
|
+
cause: `${input.at} was sealed with master key ${input.sealedWith} and ${input.keyAt} holds ${input.found}`,
|
|
79
|
+
fix: `git checkout -- ${input.at} # or point ULTIMATE_SECRETS_KEY at the key whose id is ${input.sealedWith}`,
|
|
80
|
+
meta: { at: input.at, sealedWith: input.sealedWith, found: input.found },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** No encrypted file at all — this repo has never run `x secrets init`. */
|
|
86
|
+
export class SecretsFileMissingError extends UltimateError {
|
|
87
|
+
constructor(input: { at: string }) {
|
|
88
|
+
super({
|
|
89
|
+
code: 'X_SECRETS_FILE_MISSING',
|
|
90
|
+
cause: `${input.at} does not exist, so this app declares no encrypted secrets`,
|
|
91
|
+
fix: 'x secrets init',
|
|
92
|
+
meta: { at: input.at },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The envelope itself will not parse: not JSON, an unknown version or algorithm, a header field
|
|
99
|
+
* that is not base64, or a body too short to hold a 16-byte tag. Separate from `X_SECRETS_TAMPERED`
|
|
100
|
+
* because nothing here got as far as authentication — this is a file a merge or an editor mangled.
|
|
101
|
+
*/
|
|
102
|
+
export class SecretsFileInvalidError extends UltimateError {
|
|
103
|
+
constructor(input: { at: string; reason: string }) {
|
|
104
|
+
super({
|
|
105
|
+
code: 'X_SECRETS_FILE_INVALID',
|
|
106
|
+
cause: `${input.at} ${input.reason}`,
|
|
107
|
+
fix: `git checkout -- ${input.at} # this file is written only by x secrets, never by hand`,
|
|
108
|
+
meta: { at: input.at },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The AES-256-GCM tag rejected the ciphertext or the header bound into it as AAD. Under a key whose
|
|
115
|
+
* id already matched, that means the committed bytes changed after they were sealed — a bad merge,
|
|
116
|
+
* a partial write, or an edit. Silent garbage is the alternative this code exists to prevent.
|
|
117
|
+
*/
|
|
118
|
+
export class SecretsTamperedError extends UltimateError {
|
|
119
|
+
constructor(input: { at: string }) {
|
|
120
|
+
super({
|
|
121
|
+
code: 'X_SECRETS_TAMPERED',
|
|
122
|
+
cause: `the AES-256-GCM authentication tag rejected ${input.at}: the ciphertext or its header changed after it was sealed`,
|
|
123
|
+
fix: `git checkout -- ${input.at} # then confirm the restored file opens: x secrets show --json`,
|
|
124
|
+
meta: { at: input.at },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decryption succeeded and the payload is not what a secrets file holds. Reachable two ways: an
|
|
131
|
+
* `x secrets edit` buffer saved as something other than a flat object, and a value that is not a
|
|
132
|
+
* non-empty string. Both are refused BEFORE sealing, so a file that opens always installs.
|
|
133
|
+
*/
|
|
134
|
+
export class SecretsPlaintextInvalidError extends UltimateError {
|
|
135
|
+
constructor(input: { at: string; reason: string }) {
|
|
136
|
+
super({
|
|
137
|
+
code: 'X_SECRETS_PLAINTEXT_INVALID',
|
|
138
|
+
cause: `the secrets for ${input.at} ${input.reason}`,
|
|
139
|
+
fix: 'x secrets edit # the buffer is one flat JSON object: {"SESSION_SECRET": "s3cr3t"} — env var names to non-empty strings',
|
|
140
|
+
meta: { at: input.at },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Single responsibility: where the two secrets files live, how the master key is found, and the
|
|
2
|
+
// ONE path from a decrypted value to a `defineEnv`-declared variable — `installSecrets()` writes
|
|
3
|
+
// each value into the process environment, under its own name, only where the real environment has
|
|
4
|
+
// nothing. `secrets.ts` owns the envelope; this owns the filesystem and `process.env`.
|
|
5
|
+
|
|
6
|
+
// `node:fs` sync, by necessity twice over: Bun.write takes no mode, and a world-readable master key
|
|
7
|
+
// is the whole failure this file exists to prevent — and `installSecrets()` runs once, at boot,
|
|
8
|
+
// before the process is serving anything, so there is nothing for an async read to overlap with.
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
// Bun exposes no path-join primitive.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { SecretValues } from './secrets';
|
|
13
|
+
import { masterKeyId, openSecrets, parseMasterKey, sealSecrets } from './secrets';
|
|
14
|
+
import { SecretsFileMissingError, SecretsKeyMissingError } from './secrets-errors';
|
|
15
|
+
|
|
16
|
+
/** Committed. Encrypted at rest, diffable, and the only file `x secrets` writes into the repo. */
|
|
17
|
+
export const SECRETS_FILE = 'secrets.enc.json';
|
|
18
|
+
/** Never committed — `x secrets init` writes the ignore rule before it writes this. */
|
|
19
|
+
export const SECRETS_KEY_FILE = '.secrets.key';
|
|
20
|
+
/** Read first, so a container gets its key from the platform's secret store and never from a file. */
|
|
21
|
+
export const SECRETS_KEY_ENV = 'ULTIMATE_SECRETS_KEY';
|
|
22
|
+
/** Owner read/write. A key file the rest of the box can read is a key file that has leaked. */
|
|
23
|
+
export const SECRETS_KEY_MODE = 0o600;
|
|
24
|
+
|
|
25
|
+
export type MasterKeySource = 'env' | 'file';
|
|
26
|
+
|
|
27
|
+
export interface MasterKeyRef {
|
|
28
|
+
readonly hex: string;
|
|
29
|
+
readonly source: MasterKeySource;
|
|
30
|
+
/** The env var name or the file path, for an error's `cause` — never the key itself. */
|
|
31
|
+
readonly at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type EnvRecord = Record<string, string | undefined>;
|
|
35
|
+
|
|
36
|
+
export const secretsPath = (root: string): string => join(root, SECRETS_FILE);
|
|
37
|
+
export const masterKeyPath = (root: string): string => join(root, SECRETS_KEY_FILE);
|
|
38
|
+
export const secretsFileExists = (root: string): boolean => existsSync(secretsPath(root));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Env var first, key file second. That order is what makes one image run everywhere: a container
|
|
42
|
+
* is handed `ULTIMATE_SECRETS_KEY` and never ships a key file, while a checkout has the file and
|
|
43
|
+
* needs no exported variable.
|
|
44
|
+
*/
|
|
45
|
+
export function findMasterKey(
|
|
46
|
+
root: string,
|
|
47
|
+
env: EnvRecord = process.env,
|
|
48
|
+
): MasterKeyRef | undefined {
|
|
49
|
+
const fromEnv = env[SECRETS_KEY_ENV];
|
|
50
|
+
if (fromEnv !== undefined && fromEnv.trim().length > 0) {
|
|
51
|
+
return { hex: fromEnv.trim(), source: 'env', at: SECRETS_KEY_ENV };
|
|
52
|
+
}
|
|
53
|
+
const path = masterKeyPath(root);
|
|
54
|
+
if (!existsSync(path)) return undefined;
|
|
55
|
+
return { hex: readFileSync(path, 'utf-8').trim(), source: 'file', at: path };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function requireMasterKey(root: string, env: EnvRecord = process.env): MasterKeyRef {
|
|
59
|
+
const found = findMasterKey(root, env);
|
|
60
|
+
if (found !== undefined) return found;
|
|
61
|
+
throw new SecretsKeyMissingError({ envVar: SECRETS_KEY_ENV, keyPath: masterKeyPath(root) });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The key's public id. Safe to print, safe to commit — it is what names a rotation in a diff. */
|
|
65
|
+
export const masterKeyIdOf = (key: MasterKeyRef): Promise<string> =>
|
|
66
|
+
masterKeyId(parseMasterKey(key.hex, key.at));
|
|
67
|
+
|
|
68
|
+
/** Decrypt the committed file. `X_SECRETS_FILE_MISSING` when there is none — never an empty map. */
|
|
69
|
+
export async function readSecretsFile(root: string, key: MasterKeyRef): Promise<SecretValues> {
|
|
70
|
+
const path = secretsPath(root);
|
|
71
|
+
if (!existsSync(path)) throw new SecretsFileMissingError({ at: path });
|
|
72
|
+
return openSecrets(readFileSync(path, 'utf-8'), key.hex, { file: path, key: key.at });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Seal and commit. The only writer of `secrets.enc.json`; plaintext never reaches this path. */
|
|
76
|
+
export async function writeSecretsFile(
|
|
77
|
+
root: string,
|
|
78
|
+
values: SecretValues,
|
|
79
|
+
key: MasterKeyRef,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
const path = secretsPath(root);
|
|
82
|
+
await Bun.write(path, await sealSecrets(values, key.hex, { file: path, key: key.at }));
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Write the master key at 0600. Callers must have made the ignore rule true first. */
|
|
87
|
+
export function writeMasterKeyFile(root: string, keyHex: string): string {
|
|
88
|
+
const path = masterKeyPath(root);
|
|
89
|
+
writeFileSync(path, `${keyHex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE });
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SecretsInstallOptions {
|
|
94
|
+
/** The app root holding `secrets.enc.json`. Defaults to the process's working directory. */
|
|
95
|
+
readonly root?: string | undefined;
|
|
96
|
+
/** Read for the key and written with the values. Defaults to `process.env`. */
|
|
97
|
+
readonly env?: EnvRecord | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Names only. A report that carried a value would be a secret in every log that printed it. */
|
|
101
|
+
export interface SecretsInstallReport {
|
|
102
|
+
readonly present: boolean;
|
|
103
|
+
readonly path: string;
|
|
104
|
+
readonly keySource: MasterKeySource | undefined;
|
|
105
|
+
readonly keyId: string | undefined;
|
|
106
|
+
/** Variables this call set. */
|
|
107
|
+
readonly installed: readonly string[];
|
|
108
|
+
/** Variables the real environment already supplied, so the file's value was not used. */
|
|
109
|
+
readonly skipped: readonly string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Decrypt the committed secrets into the environment, then let `defineEnv` do what it already does.
|
|
114
|
+
*
|
|
115
|
+
* ```ts
|
|
116
|
+
* await installSecrets(); // app.config.ts, first line
|
|
117
|
+
* export const envSchema = { SESSION_SECRET: { type: 'string', secret: true } } satisfies EnvSchema;
|
|
118
|
+
* export const env = defineEnv(envSchema);
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* This is the whole integration, and it is one path rather than two on purpose. A secret has one
|
|
122
|
+
* name — the env var it becomes — so it keeps one declaration (`envSchema`), one row in
|
|
123
|
+
* `.env.example`, one masker (`maskedEnvValues`), one redaction entry (`redactKeys`) and one
|
|
124
|
+
* reader (`env.SESSION_SECRET`). A `secrets.get('…')` accessor would mint values with no
|
|
125
|
+
* declaration, no type and no mask, and every one of those five would need a second implementation.
|
|
126
|
+
*
|
|
127
|
+
* The real environment always wins: a platform-injected `DATABASE_URL` beats the committed file, so
|
|
128
|
+
* the same image runs in Compose and K8s without a second secrets file per deploy. A file that does
|
|
129
|
+
* not exist is not an error — an app may declare no secrets — but a file that exists with no key to
|
|
130
|
+
* open it is `X_SECRETS_KEY_MISSING`, because booting past it produces an app that authenticates
|
|
131
|
+
* against nothing and reports itself healthy.
|
|
132
|
+
*
|
|
133
|
+
* Forgetting the `await` fails loudly rather than silently: `defineEnv` runs first and throws
|
|
134
|
+
* `X_ENV_MISSING` naming every variable the file would have supplied.
|
|
135
|
+
*/
|
|
136
|
+
export async function installSecrets(
|
|
137
|
+
options: SecretsInstallOptions = {},
|
|
138
|
+
): Promise<SecretsInstallReport> {
|
|
139
|
+
const root = options.root ?? process.cwd();
|
|
140
|
+
const env = options.env ?? (process.env as EnvRecord);
|
|
141
|
+
const path = secretsPath(root);
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
return {
|
|
144
|
+
present: false,
|
|
145
|
+
path,
|
|
146
|
+
keySource: undefined,
|
|
147
|
+
keyId: undefined,
|
|
148
|
+
installed: [],
|
|
149
|
+
skipped: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const key = requireMasterKey(root, env);
|
|
153
|
+
const values = await readSecretsFile(root, key);
|
|
154
|
+
const installed: string[] = [];
|
|
155
|
+
const skipped: string[] = [];
|
|
156
|
+
for (const [name, value] of Object.entries(values)) {
|
|
157
|
+
const current = env[name];
|
|
158
|
+
if (current === undefined || current === '') {
|
|
159
|
+
env[name] = value;
|
|
160
|
+
installed.push(name);
|
|
161
|
+
} else {
|
|
162
|
+
skipped.push(name);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
present: true,
|
|
167
|
+
path,
|
|
168
|
+
keySource: key.source,
|
|
169
|
+
keyId: await masterKeyIdOf(key),
|
|
170
|
+
installed: installed.sort(),
|
|
171
|
+
skipped: skipped.sort(),
|
|
172
|
+
};
|
|
173
|
+
}
|