@ultimat3/core 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +23 -2
- package/README.md +93 -4
- package/package.json +2 -2
- package/src/client-paths.ts +26 -6
- package/src/config-defaults.ts +46 -0
- package/src/config-merge.ts +8 -0
- package/src/config-shape.ts +112 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +74 -83
- package/src/context.ts +13 -1
- package/src/cookie.ts +35 -0
- package/src/core-error-codes.ts +5 -0
- package/src/cursor.ts +4 -1
- package/src/decimal-order.ts +5 -4
- package/src/dev-secrets.ts +1 -1
- package/src/error-render.ts +4 -2
- package/src/error-reporter-sentry.ts +7 -3
- package/src/error-retry.ts +8 -0
- package/src/flight-gate.ts +16 -4
- package/src/fnv1a.ts +19 -0
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/image/errors.ts +3 -1
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +3 -1
- package/src/index.ts +32 -0
- package/src/logger.ts +103 -10
- package/src/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page-meta.ts +7 -0
- package/src/page.ts +1 -0
- package/src/pg-executor.ts +15 -0
- package/src/process-metrics.ts +206 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +21 -4
- package/src/retry.ts +15 -2
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/seal-errors.ts +76 -0
- package/src/seal-keys.ts +121 -0
- package/src/seal.ts +259 -0
- package/src/secrets-errors.ts +33 -1
- package/src/secrets.ts +21 -11
- package/src/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
package/src/seal.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Single responsibility: seal ONE value under the app's master key and open it back. The wire form
|
|
2
|
+
// is one string, `x1.<keyId>.<iv>.<ciphertext+tag>`, AES-256-GCM through WebCrypto with a REQUIRED
|
|
3
|
+
// purpose bound in as additional authenticated data. `secrets.ts` is the env envelope — a file of
|
|
4
|
+
// many values; this is the per-value form a column or a stored session holds.
|
|
5
|
+
|
|
6
|
+
import { assert } from './assert';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
import { SealInvalidError, SealKeyUnknownError } from './seal-errors';
|
|
9
|
+
import type { SealKey, SealKeyRing, SealKeySource } from './seal-keys';
|
|
10
|
+
import { resolveSealKeys } from './seal-keys';
|
|
11
|
+
import {
|
|
12
|
+
decodeBase64,
|
|
13
|
+
encodeBase64,
|
|
14
|
+
SECRETS_ALG,
|
|
15
|
+
SECRETS_IV_BYTES,
|
|
16
|
+
SECRETS_TAG_BYTES,
|
|
17
|
+
} from './secrets';
|
|
18
|
+
|
|
19
|
+
/** The format tag. A string without it was never sealed by this function. */
|
|
20
|
+
export const SEAL_VERSION = 'x1';
|
|
21
|
+
|
|
22
|
+
/** What a value was sealed FOR — `entity:connections.password`, `scrape-session`. Never optional. */
|
|
23
|
+
export interface SealPurposeOptions extends SealKeySource {
|
|
24
|
+
/** Bound into the tag: a value sealed for one purpose does not open as another. */
|
|
25
|
+
readonly purpose: string;
|
|
26
|
+
/**
|
|
27
|
+
* A ring already resolved — `await resolveSealKeys()` — for a caller sealing or opening MANY
|
|
28
|
+
* values in one operation. Without it every call finds the master key again (an environment
|
|
29
|
+
* read, or a file read in a checkout); with it a 500-row page asks once. Never held past the
|
|
30
|
+
* operation: a ring kept across requests would outlive a rotation.
|
|
31
|
+
*/
|
|
32
|
+
readonly keys?: SealKeyRing | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ringFor = (options: SealPurposeOptions): Promise<SealKeyRing> | SealKeyRing =>
|
|
36
|
+
options.keys ?? resolveSealKeys(options);
|
|
37
|
+
|
|
38
|
+
export interface SealOptions extends SealPurposeOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Derive the IV from the purpose and the plaintext instead of drawing it, so equal values seal
|
|
41
|
+
* to equal strings and a column can be looked up by equality. It REVEALS EQUALITY to anyone who
|
|
42
|
+
* can read the stored strings: two rows holding the same value are visibly the same. Never use
|
|
43
|
+
* it for a low-entropy value (a boolean, a status, a PIN, a date of birth) — the handful of
|
|
44
|
+
* possible ciphertexts is a lookup table. Under a rotation the same value seals differently per
|
|
45
|
+
* key; `sealAll` returns every candidate.
|
|
46
|
+
*/
|
|
47
|
+
readonly deterministic?: boolean | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 12 bytes are exactly 16 unpadded base64url characters; a 16-byte tag alone is 22.
|
|
51
|
+
const SEALED = /^x1\.([0-9a-f]{16})\.([A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{22,})$/;
|
|
52
|
+
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
|
|
55
|
+
const toUrl = (bytes: Uint8Array<ArrayBuffer>): string =>
|
|
56
|
+
encodeBase64(bytes).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
57
|
+
|
|
58
|
+
function fromUrl(text: string): Uint8Array<ArrayBuffer> {
|
|
59
|
+
const standard = text.replaceAll('-', '+').replaceAll('_', '/');
|
|
60
|
+
return decodeBase64(standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), '='));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fresh copy either way, so a caller's buffer is never the one WebCrypto is handed. */
|
|
64
|
+
const bytesOf = (plaintext: string | Uint8Array): Uint8Array<ArrayBuffer> =>
|
|
65
|
+
typeof plaintext === 'string' ? encoder.encode(plaintext) : new Uint8Array(plaintext);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The bytes the tag covers besides the ciphertext: the format, the algorithm, the key id and the
|
|
69
|
+
* purpose. A string moved to another column, or relabelled with another key id, fails the tag.
|
|
70
|
+
*/
|
|
71
|
+
const additionalData = (keyId: string, purpose: string): Uint8Array<ArrayBuffer> =>
|
|
72
|
+
encoder.encode(
|
|
73
|
+
`ultimate.seal|${SEAL_VERSION}|alg=${SECRETS_ALG}|kid=${keyId}|purpose=${purpose}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
function requirePurpose(purpose: unknown): asserts purpose is string {
|
|
77
|
+
assert(
|
|
78
|
+
typeof purpose === 'string' && purpose.length > 0,
|
|
79
|
+
'seal() and open() were called without a purpose, and the purpose is what stops a value sealed for one column opening as another',
|
|
80
|
+
"pass purpose: '<what the value is for>' — seal(value, { purpose: 'entity:<table>.<column>' })",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The deterministic IV: HMAC-SHA-256 over the length-prefixed purpose and the plaintext, cut to
|
|
86
|
+
* GCM's 96 bits. Length-prefixed so (`a`, `bc`) and (`ab`, `c`) are different inputs. A repeated
|
|
87
|
+
* (key, IV) pair then means a repeated (purpose, plaintext) — the same ciphertext, which is the
|
|
88
|
+
* mode's stated leak and not a nonce reuse across two messages.
|
|
89
|
+
*/
|
|
90
|
+
async function derivedIv(
|
|
91
|
+
key: SealKey,
|
|
92
|
+
purpose: string,
|
|
93
|
+
plaintext: Uint8Array<ArrayBuffer>,
|
|
94
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
95
|
+
const label = encoder.encode(purpose);
|
|
96
|
+
const material = new Uint8Array(4 + label.length + plaintext.length);
|
|
97
|
+
new DataView(material.buffer).setUint32(0, label.length);
|
|
98
|
+
material.set(label, 4);
|
|
99
|
+
material.set(plaintext, 4 + label.length);
|
|
100
|
+
const mac = await crypto.subtle.sign('HMAC', key.mac, material);
|
|
101
|
+
return new Uint8Array(mac).slice(0, SECRETS_IV_BYTES);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function sealUnder(
|
|
105
|
+
key: SealKey,
|
|
106
|
+
plaintext: Uint8Array<ArrayBuffer>,
|
|
107
|
+
purpose: string,
|
|
108
|
+
deterministic: boolean,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const iv = deterministic
|
|
111
|
+
? await derivedIv(key, purpose, plaintext)
|
|
112
|
+
: crypto.getRandomValues(new Uint8Array(SECRETS_IV_BYTES));
|
|
113
|
+
const sealed = await crypto.subtle.encrypt(
|
|
114
|
+
{
|
|
115
|
+
name: 'AES-GCM',
|
|
116
|
+
iv,
|
|
117
|
+
additionalData: additionalData(key.id, purpose),
|
|
118
|
+
tagLength: SECRETS_TAG_BYTES * 8,
|
|
119
|
+
},
|
|
120
|
+
key.aes,
|
|
121
|
+
plaintext,
|
|
122
|
+
);
|
|
123
|
+
return `${SEAL_VERSION}.${key.id}.${toUrl(iv)}.${toUrl(new Uint8Array(sealed))}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Seal one value under the CURRENT master key — the one `x secrets` manages, found where
|
|
128
|
+
* `installSecrets()` finds it.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* const stored = await seal(password, { purpose: 'entity:connections.password' });
|
|
132
|
+
* const password = await openText(stored, { purpose: 'entity:connections.password' });
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* `X_SEAL_KEY_MISSING` when there is no key: the plaintext is never returned in its place.
|
|
136
|
+
*/
|
|
137
|
+
export async function seal(plaintext: string | Uint8Array, options: SealOptions): Promise<string> {
|
|
138
|
+
requirePurpose(options.purpose);
|
|
139
|
+
const ring = await ringFor(options);
|
|
140
|
+
return sealUnder(
|
|
141
|
+
ring.current,
|
|
142
|
+
bytesOf(plaintext),
|
|
143
|
+
options.purpose,
|
|
144
|
+
options.deterministic === true,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The DETERMINISTIC seal of one value under every declared key, current first. During a rotation
|
|
150
|
+
* a row written before it holds the old key's string and a row written after holds the new one's,
|
|
151
|
+
* so an equality lookup has to match either: `where column in (…sealAll(value))`. Outside a
|
|
152
|
+
* rotation this is one string. Uniqueness cannot be held across keys — one value has two forms.
|
|
153
|
+
*/
|
|
154
|
+
export async function sealAll(
|
|
155
|
+
plaintext: string | Uint8Array,
|
|
156
|
+
options: SealPurposeOptions,
|
|
157
|
+
): Promise<readonly string[]> {
|
|
158
|
+
requirePurpose(options.purpose);
|
|
159
|
+
const ring = await ringFor(options);
|
|
160
|
+
const bytes = bytesOf(plaintext);
|
|
161
|
+
return Promise.all(ring.keys.map((key) => sealUnder(key, bytes, options.purpose, true)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface SealedParts {
|
|
165
|
+
readonly keyId: string;
|
|
166
|
+
readonly iv: string;
|
|
167
|
+
readonly body: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function partsOf(value: unknown): SealedParts | undefined {
|
|
171
|
+
const match = typeof value === 'string' ? SEALED.exec(value) : null;
|
|
172
|
+
const [keyId, iv, body] = [match?.[1], match?.[2], match?.[3]];
|
|
173
|
+
if (keyId === undefined || iv === undefined || body === undefined) return undefined;
|
|
174
|
+
// No whole number of bytes encodes to 4n+1 base64 characters: a truncated write, not a value.
|
|
175
|
+
return body.length % 4 === 1 ? undefined : { keyId, iv, body };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const malformed = (sealed: unknown, purpose?: string): SealInvalidError =>
|
|
179
|
+
new SealInvalidError({
|
|
180
|
+
reason: 'malformed',
|
|
181
|
+
purpose,
|
|
182
|
+
length: typeof sealed === 'string' ? sealed.length : 0,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a value has the SHAPE of a sealed string. Not a claim that it opens: it exists so a
|
|
187
|
+
* caller migrating a plaintext column can tell an unsealed legacy row from a sealed one without
|
|
188
|
+
* attempting a decryption — `open()` itself never falls back to the raw string.
|
|
189
|
+
*/
|
|
190
|
+
export function isSealed(value: unknown): value is string {
|
|
191
|
+
return partsOf(value) !== undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The id of the key a sealed string names — what a re-seal `backfill()` compares to the current. */
|
|
195
|
+
export function sealedKeyId(sealed: string): string {
|
|
196
|
+
const parts = partsOf(sealed);
|
|
197
|
+
if (parts === undefined) throw malformed(sealed);
|
|
198
|
+
return parts.keyId;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Open a sealed string to its bytes. Three refusals, in the order the facts become knowable: the
|
|
203
|
+
* string is not a sealed value (`X_SEAL_INVALID`), it names a key this process does not declare
|
|
204
|
+
* (`X_SEAL_KEY_UNKNOWN` — read off the string before any decryption is attempted, so a rotation
|
|
205
|
+
* is never reported as tampering), or the tag rejected it under the purpose given
|
|
206
|
+
* (`X_SEAL_INVALID`).
|
|
207
|
+
*/
|
|
208
|
+
export async function open(
|
|
209
|
+
sealed: string,
|
|
210
|
+
options: SealPurposeOptions,
|
|
211
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
212
|
+
requirePurpose(options.purpose);
|
|
213
|
+
const parts = partsOf(sealed);
|
|
214
|
+
if (parts === undefined) throw malformed(sealed, options.purpose);
|
|
215
|
+
const { keyId, iv, body } = parts;
|
|
216
|
+
const ring = await ringFor(options);
|
|
217
|
+
// A lookup by the id the string names. An id is public — it is in every sealed value — and a
|
|
218
|
+
// `Map` says so: nothing here is compared byte by byte against a secret.
|
|
219
|
+
const key = ring.byId.get(keyId);
|
|
220
|
+
if (key === undefined) {
|
|
221
|
+
throw new SealKeyUnknownError({
|
|
222
|
+
keyId,
|
|
223
|
+
declared: ring.keys.map((one) => one.id),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
228
|
+
{
|
|
229
|
+
name: 'AES-GCM',
|
|
230
|
+
iv: fromUrl(iv),
|
|
231
|
+
additionalData: additionalData(keyId, options.purpose),
|
|
232
|
+
tagLength: SECRETS_TAG_BYTES * 8,
|
|
233
|
+
},
|
|
234
|
+
key.aes,
|
|
235
|
+
fromUrl(body),
|
|
236
|
+
);
|
|
237
|
+
return new Uint8Array(plaintext);
|
|
238
|
+
} catch {
|
|
239
|
+
// No `sourceError`, for `openSecrets`' reason: WebCrypto's OperationError says nothing more.
|
|
240
|
+
throw new SealInvalidError({ reason: 'unauthenticated', purpose: options.purpose, keyId });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `open()` for a value that was sealed from a string. Bytes that are not UTF-8 are refused rather
|
|
246
|
+
* than decoded with replacement characters: a caller would store the repaired text back.
|
|
247
|
+
*/
|
|
248
|
+
export async function openText(sealed: string, options: SealPurposeOptions): Promise<string> {
|
|
249
|
+
const bytes = await open(sealed, options);
|
|
250
|
+
try {
|
|
251
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
252
|
+
} catch {
|
|
253
|
+
throw new UltimateError({
|
|
254
|
+
code: 'X_INVARIANT',
|
|
255
|
+
cause: `the value opened to ${bytes.length} byte(s) that are not UTF-8 text, so it was sealed from bytes and openText() cannot return it`,
|
|
256
|
+
fix: 'call open() instead of openText() for a value sealed from a Uint8Array',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/secrets-errors.ts
CHANGED
|
@@ -75,10 +75,42 @@ export class SecretsKeyMissingError extends UltimateError {
|
|
|
75
75
|
/** Key material that is not 64 lowercase hex characters. A truncated paste is the usual cause. */
|
|
76
76
|
export class SecretsKeyInvalidError extends UltimateError {
|
|
77
77
|
constructor(input: { at: string; found: number; expected: number }) {
|
|
78
|
+
super({
|
|
79
|
+
code: 'X_SECRETS_KEY_INVALID',
|
|
80
|
+
// The lost-key sentence is CAUSE, not fix: a `fix:` is one command, and no command restores
|
|
81
|
+
// a key file — so the file branch says what cannot be done here and the fix measures it.
|
|
82
|
+
cause:
|
|
83
|
+
input.at === 'ULTIMATE_SECRETS_KEY'
|
|
84
|
+
? `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`
|
|
85
|
+
: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters — the key FILE is what is wrong, so re-exporting it changes nothing: restore it from wherever the team keeps the key, because a lost key cannot be recovered or regenerated`,
|
|
86
|
+
// Branches on WHERE the bad key was read. From the variable, re-reading the file repairs
|
|
87
|
+
// it. From the FILE, that same line reads the truncated file into the variable and is
|
|
88
|
+
// refused again, so the command is the measurement that says when the restore worked.
|
|
89
|
+
fix:
|
|
90
|
+
input.at === 'ULTIMATE_SECRETS_KEY'
|
|
91
|
+
? `export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)" # the key file holds the ${input.expected} characters on one line`
|
|
92
|
+
: `wc -c ${renderFixShellArg(input.at, '<the key file the cause names>')} # ${input.expected + 1} is a whole key and its newline; any other count is the truncated or padded file to restore`,
|
|
93
|
+
meta: { at: input.at },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The same condition under the same code, for a key that is NOT the current one: a malformed entry
|
|
100
|
+
* of a ring variable (`ULTIMATE_SECRETS_RETIRED_KEYS`). Its own class because its repair is an
|
|
101
|
+
* edit of that variable — the current key is fine, and re-exporting it changes nothing — and
|
|
102
|
+
* because a class has one literal `fix:`, which is what `x errors explain` prints without an
|
|
103
|
+
* instance. A variable name that is not one never reaches the line.
|
|
104
|
+
*/
|
|
105
|
+
export class SecretsRingKeyInvalidError extends UltimateError {
|
|
106
|
+
constructor(input: { at: string; found: number; expected: number; variable: string }) {
|
|
107
|
+
const variable = ENV_VAR_NAME.test(input.variable)
|
|
108
|
+
? input.variable
|
|
109
|
+
: 'the variable the cause names';
|
|
78
110
|
super({
|
|
79
111
|
code: 'X_SECRETS_KEY_INVALID',
|
|
80
112
|
cause: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`,
|
|
81
|
-
fix: `
|
|
113
|
+
fix: `x secrets edit # ${variable} holds ${input.expected}-character lowercase hex keys separated by commas: correct or remove the entry the cause names — the variable is a line of secrets.enc.json, and a platform that ALSO sets it wins, so correct it there too`,
|
|
82
114
|
meta: { at: input.at },
|
|
83
115
|
});
|
|
84
116
|
}
|
package/src/secrets.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SecretsKeyInvalidError,
|
|
9
9
|
SecretsKeyMismatchError,
|
|
10
10
|
SecretsPlaintextInvalidError,
|
|
11
|
+
SecretsRingKeyInvalidError,
|
|
11
12
|
SecretsTamperedError,
|
|
12
13
|
} from './secrets-errors';
|
|
13
14
|
|
|
@@ -65,14 +66,15 @@ function decodeHex(hex: string): Uint8Array<ArrayBuffer> {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// `btoa`/`atob` rather than node:buffer — both are standard globals, and a chunk loop avoids the
|
|
68
|
-
// stack blow-up `String.fromCharCode(...bytes)` hits on a spread of any size.
|
|
69
|
-
|
|
69
|
+
// stack blow-up `String.fromCharCode(...bytes)` hits on a spread of any size. Exported for
|
|
70
|
+
// `seal.ts`, which writes the same bytes in the URL-safe alphabet — one codec, never a second.
|
|
71
|
+
export function encodeBase64(bytes: Uint8Array<ArrayBuffer>): string {
|
|
70
72
|
let binary = '';
|
|
71
73
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
72
74
|
return btoa(binary);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
|
|
77
|
+
export function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
|
|
76
78
|
const binary = atob(text);
|
|
77
79
|
const bytes = new Uint8Array(binary.length);
|
|
78
80
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
@@ -84,15 +86,22 @@ export function generateMasterKey(): string {
|
|
|
84
86
|
return encodeHex(crypto.getRandomValues(new Uint8Array(SECRETS_KEY_BYTES)));
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
+
/**
|
|
90
|
+
* 64 lowercase hex characters, or `X_SECRETS_KEY_INVALID`. Whitespace is trimmed, never repaired.
|
|
91
|
+
* `variable` names the variable a key OTHER than the current one was read from, so the refusal's
|
|
92
|
+
* fix edits that variable instead of re-exporting a current key that is fine.
|
|
93
|
+
*/
|
|
94
|
+
export function parseMasterKey(
|
|
95
|
+
raw: string,
|
|
96
|
+
at: string,
|
|
97
|
+
variable?: string,
|
|
98
|
+
): Uint8Array<ArrayBuffer> {
|
|
89
99
|
const hex = raw.trim();
|
|
90
100
|
if (hex.length !== SECRETS_KEY_HEX_LENGTH || !HEX_KEY.test(hex)) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
101
|
+
const shape = { at, found: hex.length, expected: SECRETS_KEY_HEX_LENGTH };
|
|
102
|
+
throw variable === undefined
|
|
103
|
+
? new SecretsKeyInvalidError(shape)
|
|
104
|
+
: new SecretsRingKeyInvalidError({ ...shape, variable });
|
|
96
105
|
}
|
|
97
106
|
return decodeHex(hex);
|
|
98
107
|
}
|
|
@@ -119,7 +128,8 @@ export async function masterKeyId(key: Uint8Array<ArrayBuffer>): Promise<string>
|
|
|
119
128
|
const additionalData = (header: Omit<SecretsEnvelope, 'iv' | 'ct'>): Uint8Array<ArrayBuffer> =>
|
|
120
129
|
encoder.encode(`ultimate.secrets|v=${header.v}|alg=${header.alg}|kid=${header.kid}`);
|
|
121
130
|
|
|
122
|
-
|
|
131
|
+
/** The AES-256-GCM key object, non-extractable. Shared with `seal-keys.ts`: one import, one usage set. */
|
|
132
|
+
export const importKey = (key: Uint8Array<ArrayBuffer>): Promise<CryptoKey> =>
|
|
123
133
|
crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
124
134
|
|
|
125
135
|
/**
|
package/src/source-mask.ts
CHANGED
|
@@ -45,7 +45,11 @@ function endOfInterpolation(text: string, from: number): number {
|
|
|
45
45
|
let i = from;
|
|
46
46
|
while (i < text.length) {
|
|
47
47
|
const ch = text[i] as string;
|
|
48
|
-
|
|
48
|
+
// A regex before the comment test: in `${p.replace(/^\//, '')}` the `\//` is the regex's own
|
|
49
|
+
// escaped slash and its close, and read as a `//` comment it swallowed the `}` and the closing
|
|
50
|
+
// backtick — every declaration below masked as template text (`admin/src/errors.ts` hid one).
|
|
51
|
+
if (ch === '/' && opensRegex(text, i, from)) i = endOfRegex(text, i);
|
|
52
|
+
else if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
|
|
49
53
|
const line = text[i + 1] === '/';
|
|
50
54
|
const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
|
|
51
55
|
i = end === -1 ? text.length : line ? end : end + 2;
|
|
@@ -66,19 +70,21 @@ function endOfInterpolation(text: string, from: number): number {
|
|
|
66
70
|
* Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
|
|
67
71
|
* avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
|
|
68
72
|
* above, a number, `)`, `]`, a string's closing quote. Every other position is an operator's and
|
|
69
|
-
* opens one; `</` and `/>` are JSX delimiters. Read from the masked
|
|
73
|
+
* opens one; `</` and `/>` are JSX delimiters; `//` and `/*` open comments. Read from the masked
|
|
74
|
+
* prefix, so a comment is space — or, inside a `${}`, from the raw body from `floor` on, where the
|
|
75
|
+
* placeholder's own start is an operator position.
|
|
70
76
|
*/
|
|
71
|
-
function opensRegex(out:
|
|
72
|
-
if (out[at + 1] === '>') return false;
|
|
77
|
+
function opensRegex(out: ArrayLike<string>, at: number, floor = 0): boolean {
|
|
78
|
+
if (out[at + 1] === '>' || out[at + 1] === '/' || out[at + 1] === '*') return false;
|
|
73
79
|
let i = at - 1;
|
|
74
|
-
while (i >=
|
|
75
|
-
if (i <
|
|
80
|
+
while (i >= floor && /\s/.test(out[i] as string)) i -= 1;
|
|
81
|
+
if (i < floor) return true;
|
|
76
82
|
const ch = out[i] as string;
|
|
77
83
|
if (ch === '<' || ch === ')' || ch === ']' || QUOTES.has(ch)) return false;
|
|
78
84
|
if (!WORD.test(ch)) return true;
|
|
79
85
|
let start = i;
|
|
80
|
-
while (start >=
|
|
81
|
-
return REGEX_AFTER_WORDS.has(
|
|
86
|
+
while (start >= floor && WORD.test(out[start] as string)) start -= 1;
|
|
87
|
+
return REGEX_AFTER_WORDS.has(Array.prototype.slice.call(out, start + 1, i + 1).join(''));
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Which store backs a framework seam in this process — the in-memory one or the database — decided
|
|
2
|
+
// once. Three apps each wrote the ternary with a different predicate, and under `x dev` one seam
|
|
3
|
+
// sat in memory while every repository read the embedded Postgres.
|
|
4
|
+
|
|
5
|
+
import { resolveEnvironment } from './environment';
|
|
6
|
+
|
|
7
|
+
export const STORE_MODES = ['memory', 'database'] as const;
|
|
8
|
+
|
|
9
|
+
export type StoreMode = (typeof STORE_MODES)[number];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `memory` under `test`, `database` everywhere else. The environment and never `DATABASE_URL`:
|
|
13
|
+
* `x dev` installs the embedded PGlite as the process client and sets no URL, and a container
|
|
14
|
+
* gets its pool from the URL — both are `database`. `bun test` installs no client at all, so a
|
|
15
|
+
* statement there would have nothing to reach; that is the one carve-out. An unknown
|
|
16
|
+
* `ULTIMATE_ENV` throws `X_ENVIRONMENT_INVALID`, as `resolveEnvironment` does.
|
|
17
|
+
*
|
|
18
|
+
* `env` is required: the store a module picks at load must be testable without mutating the
|
|
19
|
+
* process environment, so the caller passes `Bun.env`.
|
|
20
|
+
*/
|
|
21
|
+
export function storeMode(env: Readonly<Record<string, string | undefined>>): StoreMode {
|
|
22
|
+
return resolveEnvironment({ env }) === 'test' ? 'memory' : 'database';
|
|
23
|
+
}
|