@geekmidas/envkit 1.0.7 → 1.1.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/CHANGELOG.md +39 -0
- package/dist/sst.cjs +280 -2
- package/dist/sst.cjs.map +1 -1
- package/dist/sst.d.cts +166 -2
- package/dist/sst.d.cts.map +1 -1
- package/dist/sst.d.mts +166 -2
- package/dist/sst.d.mts.map +1 -1
- package/dist/sst.mjs +273 -3
- package/dist/sst.mjs.map +1 -1
- package/package.json +2 -2
- package/src/SstEnvValidator.ts +369 -0
- package/src/SstEnvironmentBuilder.ts +26 -0
- package/src/__tests__/SstEnvValidator.spec.ts +236 -0
- package/src/__tests__/SstEnvironmentBuilder.spec.ts +4 -0
- package/src/__tests__/sst.spec.ts +36 -0
- package/src/sst.ts +16 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { EnvironmentBuilder, type InputValue } from './EnvironmentBuilder';
|
|
2
|
+
import { type SstResource, sstResolvers } from './SstEnvironmentBuilder';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Variables AWS Lambda injects into the runtime. Always valid on AWS, but never
|
|
6
|
+
* assumed — opt in via `platform: 'aws'` or by spreading {@link AWS_RUNTIME_ENV_VARS}
|
|
7
|
+
* into `whitelist`.
|
|
8
|
+
*/
|
|
9
|
+
export const AWS_RUNTIME_ENV_VARS = [
|
|
10
|
+
'AWS_REGION',
|
|
11
|
+
'AWS_DEFAULT_REGION',
|
|
12
|
+
'AWS_ENDPOINT_URL',
|
|
13
|
+
'AWS_ENDPOINT',
|
|
14
|
+
'AWS_ACCESS_KEY_ID',
|
|
15
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
16
|
+
'AWS_SESSION_TOKEN',
|
|
17
|
+
'AWS_LAMBDA_FUNCTION_NAME',
|
|
18
|
+
'AWS_LAMBDA_FUNCTION_VERSION',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Variables GCP injects into Cloud Functions / Cloud Run.
|
|
23
|
+
*/
|
|
24
|
+
export const GCP_RUNTIME_ENV_VARS = [
|
|
25
|
+
'GOOGLE_CLOUD_PROJECT',
|
|
26
|
+
'GCP_PROJECT',
|
|
27
|
+
'FUNCTION_TARGET',
|
|
28
|
+
'FUNCTION_SIGNATURE_TYPE',
|
|
29
|
+
'K_SERVICE',
|
|
30
|
+
'K_REVISION',
|
|
31
|
+
'K_CONFIGURATION',
|
|
32
|
+
'PORT',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Variables Cloudflare exposes to Workers. Workers inject almost nothing into
|
|
37
|
+
* the global env, so this is intentionally minimal.
|
|
38
|
+
*/
|
|
39
|
+
export const CLOUDFLARE_RUNTIME_ENV_VARS = [
|
|
40
|
+
'CF_PAGES',
|
|
41
|
+
'CF_PAGES_URL',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Registry of platform → always-valid runtime variables. Export, don't assume:
|
|
46
|
+
* a caller states the platform it deploys to and the matching set is trusted.
|
|
47
|
+
*/
|
|
48
|
+
export const PLATFORM_ENV_VARS = {
|
|
49
|
+
aws: AWS_RUNTIME_ENV_VARS,
|
|
50
|
+
gcp: GCP_RUNTIME_ENV_VARS,
|
|
51
|
+
cloudflare: CLOUDFLARE_RUNTIME_ENV_VARS,
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export type Platform = keyof typeof PLATFORM_ENV_VARS;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The always-valid runtime variables for a platform. The "whitelist construct":
|
|
58
|
+
* resolve a platform to its set, to spread into `whitelist` or compare against.
|
|
59
|
+
*/
|
|
60
|
+
export function platformEnvVars(platform: Platform): readonly string[] {
|
|
61
|
+
return PLATFORM_ENV_VARS[platform];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How many link-provided vars to list in an error before truncating. */
|
|
65
|
+
const MAX_LISTED_VARS = 8;
|
|
66
|
+
|
|
67
|
+
/** Minimum similarity for a variable to be offered as a "did you mean". */
|
|
68
|
+
const SUGGESTION_THRESHOLD = 0.3;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Input shape for the validator: the same record `SstEnvironmentBuilder`
|
|
72
|
+
* accepts — keyed by resource name, valued by an SST resource (or a plain
|
|
73
|
+
* string for pass-through env vars).
|
|
74
|
+
*/
|
|
75
|
+
export type LinkRecord = Record<string, SstResource | InputValue | string>;
|
|
76
|
+
|
|
77
|
+
export interface ValidationResult {
|
|
78
|
+
valid: boolean;
|
|
79
|
+
/** Requested variables that are not available (excluding optional `?` vars). */
|
|
80
|
+
invalidVars: string[];
|
|
81
|
+
/** Every variable the links + whitelist make available. */
|
|
82
|
+
availableVars: string[];
|
|
83
|
+
/** Nearest available matches for each invalid variable (best first). */
|
|
84
|
+
suggestions: Record<string, string[]>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EnvValidatorOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Platform whose runtime-injected variables are always valid. Omit to assume
|
|
90
|
+
* none — the validator never bakes in a platform's variables.
|
|
91
|
+
*/
|
|
92
|
+
platform?: Platform;
|
|
93
|
+
/**
|
|
94
|
+
* Extra always-valid variables in addition to the platform set — typically
|
|
95
|
+
* the keys of a function's explicit `environment`.
|
|
96
|
+
*/
|
|
97
|
+
whitelist?: readonly string[];
|
|
98
|
+
/**
|
|
99
|
+
* Label for the deployable unit (e.g. a function name), used in error
|
|
100
|
+
* messages so a failure points at exactly one unit.
|
|
101
|
+
*/
|
|
102
|
+
context?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Levenshtein edit distance between two strings.
|
|
107
|
+
*/
|
|
108
|
+
function levenshtein(a: string, b: string): number {
|
|
109
|
+
const m = a.length;
|
|
110
|
+
const n = b.length;
|
|
111
|
+
if (m === 0) return n;
|
|
112
|
+
if (n === 0) return m;
|
|
113
|
+
|
|
114
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
115
|
+
|
|
116
|
+
for (let i = 1; i <= m; i++) {
|
|
117
|
+
const curr: number[] = [i];
|
|
118
|
+
for (let j = 1; j <= n; j++) {
|
|
119
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
120
|
+
const del = (prev[j] ?? 0) + 1;
|
|
121
|
+
const ins = (curr[j - 1] ?? 0) + 1;
|
|
122
|
+
const sub = (prev[j - 1] ?? 0) + cost;
|
|
123
|
+
curr[j] = Math.min(del, ins, sub);
|
|
124
|
+
}
|
|
125
|
+
prev = curr;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return prev[n] ?? 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Similarity in `[0, 1]` combining edit distance with `_`-token overlap, so both
|
|
133
|
+
* near-typos (a missing underscore) and renames (`DATABASE_URL` ~ `DB_URL`,
|
|
134
|
+
* which share the `URL` token) are caught.
|
|
135
|
+
*/
|
|
136
|
+
function similarity(a: string, b: string): number {
|
|
137
|
+
const editSim = 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1);
|
|
138
|
+
|
|
139
|
+
const ta = new Set(a.split('_').filter(Boolean));
|
|
140
|
+
const tb = new Set(b.split('_').filter(Boolean));
|
|
141
|
+
const shared = [...ta].filter((t) => tb.has(t)).length;
|
|
142
|
+
const union = new Set([...ta, ...tb]).size || 1;
|
|
143
|
+
const tokenSim = shared / union;
|
|
144
|
+
|
|
145
|
+
// Nudge token-overlap matches ahead of coincidental edit-distance ones, so a
|
|
146
|
+
// rename like DATABASE_URL prefers DB_URL (shares `URL`) over DB_PASSWORD.
|
|
147
|
+
return Math.max(editSim, tokenSim * 1.15);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns the nearest available variables to `missing`, best first, limited to
|
|
152
|
+
* those above {@link SUGGESTION_THRESHOLD}.
|
|
153
|
+
*/
|
|
154
|
+
function suggestionsFor(
|
|
155
|
+
missing: string,
|
|
156
|
+
available: readonly string[],
|
|
157
|
+
max = 1,
|
|
158
|
+
): string[] {
|
|
159
|
+
return available
|
|
160
|
+
.map((candidate) => ({ candidate, score: similarity(missing, candidate) }))
|
|
161
|
+
.filter((s) => s.score >= SUGGESTION_THRESHOLD)
|
|
162
|
+
.sort((a, b) => b.score - a.score)
|
|
163
|
+
.slice(0, max)
|
|
164
|
+
.map((s) => s.candidate);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Thrown when a deployable unit requires environment variables its links do not
|
|
169
|
+
* provide. Carries the structured `missing`/`available`/`suggestions` so callers
|
|
170
|
+
* can inspect the failure programmatically, not just read the message.
|
|
171
|
+
*/
|
|
172
|
+
export class EnvValidationError extends Error {
|
|
173
|
+
readonly isEnvValidationError = true;
|
|
174
|
+
readonly missing: string[];
|
|
175
|
+
readonly available: string[];
|
|
176
|
+
readonly suggestions: Record<string, string[]>;
|
|
177
|
+
readonly context?: string;
|
|
178
|
+
|
|
179
|
+
constructor(args: {
|
|
180
|
+
missing: string[];
|
|
181
|
+
available: string[];
|
|
182
|
+
linkVars: string[];
|
|
183
|
+
suggestions: Record<string, string[]>;
|
|
184
|
+
context?: string;
|
|
185
|
+
}) {
|
|
186
|
+
super(EnvValidationError.format(args));
|
|
187
|
+
this.name = 'EnvValidationError';
|
|
188
|
+
this.missing = args.missing;
|
|
189
|
+
this.available = args.available;
|
|
190
|
+
this.suggestions = args.suggestions;
|
|
191
|
+
this.context = args.context;
|
|
192
|
+
// V8-only; keeps the constructor out of the stack trace.
|
|
193
|
+
(
|
|
194
|
+
Error as unknown as {
|
|
195
|
+
captureStackTrace?(target: object, ctor?: unknown): void;
|
|
196
|
+
}
|
|
197
|
+
).captureStackTrace?.(this, EnvValidationError);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private static format(args: {
|
|
201
|
+
missing: string[];
|
|
202
|
+
linkVars: string[];
|
|
203
|
+
suggestions: Record<string, string[]>;
|
|
204
|
+
context?: string;
|
|
205
|
+
}): string {
|
|
206
|
+
const { missing, linkVars, suggestions, context } = args;
|
|
207
|
+
const who = context ? `'${context}'` : 'a deployable unit';
|
|
208
|
+
|
|
209
|
+
const width = Math.max(...missing.map((v) => v.length));
|
|
210
|
+
const lines = missing.map((v) => {
|
|
211
|
+
const hints = suggestions[v] ?? [];
|
|
212
|
+
const hint = hints.length ? `(did you mean ${hints.join(' or ')}?)` : '';
|
|
213
|
+
return ` - ${v.padEnd(width)} ${hint}`.trimEnd();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
let provided: string;
|
|
217
|
+
if (linkVars.length === 0) {
|
|
218
|
+
provided = '(no links provide environment variables)';
|
|
219
|
+
} else {
|
|
220
|
+
const shown = linkVars.slice(0, MAX_LISTED_VARS).join(', ');
|
|
221
|
+
const extra =
|
|
222
|
+
linkVars.length > MAX_LISTED_VARS
|
|
223
|
+
? ` ...(+${linkVars.length - MAX_LISTED_VARS})`
|
|
224
|
+
: '';
|
|
225
|
+
provided = `Provided by links: ${shown}${extra}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return `${who} is missing required env vars:\n${lines.join('\n')}\n\n${provided}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Derives the set of environment-variable **keys** a group of linked resources
|
|
234
|
+
* will produce at runtime — without resolving any value.
|
|
235
|
+
*
|
|
236
|
+
* This reuses the runtime resolvers (`sstResolvers`) via {@link EnvironmentBuilder},
|
|
237
|
+
* so the keys computed here are exactly the keys a deployed function receives:
|
|
238
|
+
* the validator and the runtime resolution share a single source of truth and
|
|
239
|
+
* cannot drift. Because every resolver's output keys depend only on the record
|
|
240
|
+
* key and the resource `type` (never the value), each resource is reduced to
|
|
241
|
+
* `{ type }` before building, so no SST `Output` is ever read.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* resolveEnvKeys({ db: { type: ResourceType.Postgres } });
|
|
245
|
+
* // ['DB_NAME', 'DB_HOST', 'DB_PASSWORD', 'DB_PORT', 'DB_USERNAME', 'DB_URL']
|
|
246
|
+
*/
|
|
247
|
+
export function resolveEnvKeys(record: LinkRecord): string[] {
|
|
248
|
+
const typeOnly: Record<string, InputValue> = {};
|
|
249
|
+
for (const [key, value] of Object.entries(record)) {
|
|
250
|
+
typeOnly[key] = typeof value === 'string' ? '' : { type: value.type };
|
|
251
|
+
}
|
|
252
|
+
return Object.keys(new EnvironmentBuilder(typeOnly, sstResolvers).build());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Validates that the environment variables a deployable unit requires are
|
|
257
|
+
* actually provided by its linked resources — at infra time, before deploy.
|
|
258
|
+
*
|
|
259
|
+
* Available variables are the union of the keys derived from `links`
|
|
260
|
+
* ({@link resolveEnvKeys}), the chosen platform's runtime variables (only when
|
|
261
|
+
* `options.platform` is set — nothing is assumed), and any `options.whitelist`
|
|
262
|
+
* (e.g. the keys of a function's explicit `environment`). A requested variable
|
|
263
|
+
* suffixed with `?` is treated as optional and never causes a failure.
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* const validator = new EnvValidator(
|
|
267
|
+
* { db: { type: ResourceType.Postgres } },
|
|
268
|
+
* { platform: 'aws', whitelist: ['APP_NAME'], context: 'orders-fn' },
|
|
269
|
+
* );
|
|
270
|
+
* validator.assert(['DB_HOST', 'AWS_REGION', 'APP_NAME', 'SENTRY_DSN?']); // ok
|
|
271
|
+
* validator.assert(['DATABASE_URL']); // throws EnvValidationError (did you mean DB_URL?)
|
|
272
|
+
*/
|
|
273
|
+
export class EnvValidator {
|
|
274
|
+
/** Every variable available — link-derived, platform, and extra whitelist. */
|
|
275
|
+
readonly availableVars: string[];
|
|
276
|
+
/** Just the variables the links provide (used for error context). */
|
|
277
|
+
readonly linkVars: string[];
|
|
278
|
+
readonly context?: string;
|
|
279
|
+
private readonly availableSet: Set<string>;
|
|
280
|
+
/** Per-link (record key → its env-var keys), for least-privilege filtering. */
|
|
281
|
+
private readonly linkVarsByName: Map<string, string[]>;
|
|
282
|
+
|
|
283
|
+
constructor(links: LinkRecord, options: EnvValidatorOptions = {}) {
|
|
284
|
+
const { platform, whitelist = [], context } = options;
|
|
285
|
+
this.context = context;
|
|
286
|
+
this.linkVarsByName = new Map(
|
|
287
|
+
Object.entries(links).map(([name, value]) => [
|
|
288
|
+
name,
|
|
289
|
+
resolveEnvKeys({ [name]: value }),
|
|
290
|
+
]),
|
|
291
|
+
);
|
|
292
|
+
this.linkVars = [...this.linkVarsByName.values()].flat();
|
|
293
|
+
this.availableVars = [
|
|
294
|
+
...this.linkVars,
|
|
295
|
+
...(platform ? PLATFORM_ENV_VARS[platform] : []),
|
|
296
|
+
...whitelist,
|
|
297
|
+
];
|
|
298
|
+
this.availableSet = new Set(this.availableVars);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Returns the names of the links that provide at least one of the requested
|
|
303
|
+
* variables — so a construct can attach only the links a unit actually needs
|
|
304
|
+
* (least privilege) rather than the whole pool. Trailing `?` is ignored.
|
|
305
|
+
*/
|
|
306
|
+
getProvidersForEnvVars(requestedEnvVars: readonly string[]): string[] {
|
|
307
|
+
const requested = new Set(
|
|
308
|
+
requestedEnvVars.map((v) => (v.endsWith('?') ? v.slice(0, -1) : v)),
|
|
309
|
+
);
|
|
310
|
+
const providers: string[] = [];
|
|
311
|
+
for (const [name, vars] of this.linkVarsByName) {
|
|
312
|
+
if (vars.some((v) => requested.has(v))) providers.push(name);
|
|
313
|
+
}
|
|
314
|
+
return providers;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Checks every requested variable against the available set. Variables
|
|
319
|
+
* ending in `?` are optional and never reported as invalid. Invalid
|
|
320
|
+
* variables come back with their nearest available matches.
|
|
321
|
+
*/
|
|
322
|
+
validate(requestedEnvVars: readonly string[]): ValidationResult {
|
|
323
|
+
const invalidVars = requestedEnvVars.filter((envVar) => {
|
|
324
|
+
if (envVar.endsWith('?')) return false;
|
|
325
|
+
return !this.availableSet.has(envVar);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const suggestions: Record<string, string[]> = {};
|
|
329
|
+
for (const envVar of invalidVars) {
|
|
330
|
+
const matches = suggestionsFor(envVar, this.availableVars);
|
|
331
|
+
if (matches.length) suggestions[envVar] = matches;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
valid: invalidVars.length === 0,
|
|
336
|
+
invalidVars,
|
|
337
|
+
availableVars: this.availableVars,
|
|
338
|
+
suggestions,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Asserts that every requested variable is available, throwing an
|
|
344
|
+
* {@link EnvValidationError} otherwise. Intended to run in a construct's
|
|
345
|
+
* constructor so a misconfigured `sst.config.ts` fails at synth time, before
|
|
346
|
+
* any AWS call.
|
|
347
|
+
*/
|
|
348
|
+
assert(requestedEnvVars: readonly string[]): void {
|
|
349
|
+
const { valid, invalidVars, suggestions } = this.validate(requestedEnvVars);
|
|
350
|
+
|
|
351
|
+
if (!valid) {
|
|
352
|
+
throw new EnvValidationError({
|
|
353
|
+
missing: invalidVars,
|
|
354
|
+
available: this.availableVars,
|
|
355
|
+
linkVars: this.linkVars,
|
|
356
|
+
suggestions,
|
|
357
|
+
context: this.context,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Whether a single variable is available. Does not treat a trailing `?` as
|
|
364
|
+
* optional — pass the bare name.
|
|
365
|
+
*/
|
|
366
|
+
has(envVar: string): boolean {
|
|
367
|
+
return this.availableSet.has(envVar);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -20,6 +20,7 @@ export enum ResourceType {
|
|
|
20
20
|
Vpc = 'sst.aws.Vpc',
|
|
21
21
|
Secret = 'sst.sst.Secret',
|
|
22
22
|
Dynamo = 'sst.aws.Dynamo',
|
|
23
|
+
Queue = 'sst.aws.Queue',
|
|
23
24
|
|
|
24
25
|
// Modern format (colon notation)
|
|
25
26
|
SSTSecret = 'sst:sst:Secret',
|
|
@@ -29,6 +30,7 @@ export enum ResourceType {
|
|
|
29
30
|
SSTBucket = 'sst:aws:Bucket',
|
|
30
31
|
SnsTopic = 'sst:aws:SnsTopic',
|
|
31
32
|
SSTDynamo = 'sst:aws:Dynamo',
|
|
33
|
+
SSTQueue = 'sst:aws:Queue',
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -93,6 +95,15 @@ export type SnsTopic = {
|
|
|
93
95
|
arn: string;
|
|
94
96
|
};
|
|
95
97
|
|
|
98
|
+
/**
|
|
99
|
+
* AWS SQS Queue resource type.
|
|
100
|
+
*/
|
|
101
|
+
export type Queue = {
|
|
102
|
+
type: ResourceType.Queue | ResourceType.SSTQueue;
|
|
103
|
+
url: string;
|
|
104
|
+
arn: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
96
107
|
/**
|
|
97
108
|
* AWS DynamoDB Table resource type.
|
|
98
109
|
*/
|
|
@@ -112,6 +123,7 @@ export type SstResource =
|
|
|
112
123
|
| Vpc
|
|
113
124
|
| Secret
|
|
114
125
|
| SnsTopic
|
|
126
|
+
| Queue
|
|
115
127
|
| Dynamo;
|
|
116
128
|
|
|
117
129
|
// Value types without the `type` key (for resolver parameters)
|
|
@@ -119,6 +131,7 @@ type SecretValue = Omit<Secret, 'type'>;
|
|
|
119
131
|
type PostgresValue = Omit<Postgres, 'type'>;
|
|
120
132
|
type BucketValue = Omit<Bucket, 'type'>;
|
|
121
133
|
type SnsTopicValue = Omit<SnsTopic, 'type'>;
|
|
134
|
+
type QueueValue = Omit<Queue, 'type'>;
|
|
122
135
|
type DynamoValue = Omit<Dynamo, 'type'>;
|
|
123
136
|
|
|
124
137
|
/**
|
|
@@ -152,6 +165,17 @@ const bucketResolver = (name: string, value: BucketValue) => ({
|
|
|
152
165
|
|
|
153
166
|
const topicResolver = (name: string, value: SnsTopicValue) => ({
|
|
154
167
|
[`${name}Arn`]: value.arn,
|
|
168
|
+
// A publisher connection string for `@geekmidas/events` (SNS); region is
|
|
169
|
+
// resolved from the ARN / AWS_REGION at runtime.
|
|
170
|
+
[`${name}PublisherConnectionString`]: `sns://?topicArn=${encodeURIComponent(value.arn)}`,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const queueResolver = (name: string, value: QueueValue) => ({
|
|
174
|
+
[`${name}Url`]: value.url,
|
|
175
|
+
[`${name}Arn`]: value.arn,
|
|
176
|
+
// A publisher connection string for `@geekmidas/events` (SQS); region is
|
|
177
|
+
// resolved from the URL / AWS_REGION at runtime.
|
|
178
|
+
[`${name}PublisherConnectionString`]: `sqs://?queueUrl=${encodeURIComponent(value.url)}`,
|
|
155
179
|
});
|
|
156
180
|
|
|
157
181
|
const dynamoResolver = (name: string, value: DynamoValue) => ({
|
|
@@ -172,6 +196,7 @@ export const sstResolvers: Resolvers = {
|
|
|
172
196
|
[ResourceType.Postgres]: postgresResolver,
|
|
173
197
|
[ResourceType.Bucket]: bucketResolver,
|
|
174
198
|
[ResourceType.Dynamo]: dynamoResolver,
|
|
199
|
+
[ResourceType.Queue]: queueResolver,
|
|
175
200
|
|
|
176
201
|
// Modern format
|
|
177
202
|
[ResourceType.SSTSecret]: secretResolver,
|
|
@@ -181,6 +206,7 @@ export const sstResolvers: Resolvers = {
|
|
|
181
206
|
[ResourceType.SSTApiGatewayV2]: noopResolver,
|
|
182
207
|
[ResourceType.SnsTopic]: topicResolver,
|
|
183
208
|
[ResourceType.SSTDynamo]: dynamoResolver,
|
|
209
|
+
[ResourceType.SSTQueue]: queueResolver,
|
|
184
210
|
};
|
|
185
211
|
|
|
186
212
|
/**
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ResourceType } from '../SstEnvironmentBuilder';
|
|
3
|
+
import {
|
|
4
|
+
EnvValidationError,
|
|
5
|
+
EnvValidator,
|
|
6
|
+
platformEnvVars,
|
|
7
|
+
resolveEnvKeys,
|
|
8
|
+
} from '../sst';
|
|
9
|
+
|
|
10
|
+
describe('SstEnvValidator', () => {
|
|
11
|
+
describe('resolveEnvKeys', () => {
|
|
12
|
+
it('derives all keys a Postgres link produces', () => {
|
|
13
|
+
expect(resolveEnvKeys({ db: { type: ResourceType.Postgres } })).toEqual([
|
|
14
|
+
'DB_NAME',
|
|
15
|
+
'DB_HOST',
|
|
16
|
+
'DB_PASSWORD',
|
|
17
|
+
'DB_PORT',
|
|
18
|
+
'DB_USERNAME',
|
|
19
|
+
'DB_URL',
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('derives the bare key a Secret link produces', () => {
|
|
24
|
+
expect(resolveEnvKeys({ apiKey: { type: ResourceType.Secret } })).toEqual(
|
|
25
|
+
['API_KEY'],
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('derives the suffixed key a Bucket link produces', () => {
|
|
30
|
+
expect(
|
|
31
|
+
resolveEnvKeys({ uploads: { type: ResourceType.Bucket } }),
|
|
32
|
+
).toEqual(['UPLOADS_NAME']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('derives queue keys incl. the publisher connection string', () => {
|
|
36
|
+
expect(resolveEnvKeys({ orders: { type: ResourceType.Queue } })).toEqual([
|
|
37
|
+
'ORDERS_URL',
|
|
38
|
+
'ORDERS_ARN',
|
|
39
|
+
'ORDERS_PUBLISHER_CONNECTION_STRING',
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('derives topic keys incl. the publisher connection string', () => {
|
|
44
|
+
expect(
|
|
45
|
+
resolveEnvKeys({ events: { type: ResourceType.SnsTopic } }),
|
|
46
|
+
).toEqual(['EVENTS_ARN', 'EVENTS_PUBLISHER_CONNECTION_STRING']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('derives no keys for noop resource types', () => {
|
|
50
|
+
expect(
|
|
51
|
+
resolveEnvKeys({
|
|
52
|
+
fn: { type: ResourceType.Function },
|
|
53
|
+
api: { type: ResourceType.ApiGatewayV2 },
|
|
54
|
+
net: { type: ResourceType.Vpc },
|
|
55
|
+
}),
|
|
56
|
+
).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('passes plain string entries through as a single env-cased key', () => {
|
|
60
|
+
expect(resolveEnvKeys({ logLevel: 'debug' })).toEqual(['LOG_LEVEL']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not read resource values (keys only)', () => {
|
|
64
|
+
const withValues = resolveEnvKeys({
|
|
65
|
+
db: {
|
|
66
|
+
type: ResourceType.Postgres,
|
|
67
|
+
database: 'app',
|
|
68
|
+
host: 'localhost',
|
|
69
|
+
password: 'secret',
|
|
70
|
+
port: 5432,
|
|
71
|
+
username: 'user',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
expect(withValues).toEqual(
|
|
75
|
+
resolveEnvKeys({ db: { type: ResourceType.Postgres } }),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('merges keys across multiple links', () => {
|
|
80
|
+
const keys = resolveEnvKeys({
|
|
81
|
+
db: { type: ResourceType.Postgres },
|
|
82
|
+
cache: { type: ResourceType.Bucket },
|
|
83
|
+
});
|
|
84
|
+
expect(keys).toContain('DB_HOST');
|
|
85
|
+
expect(keys).toContain('CACHE_NAME');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('platform whitelists', () => {
|
|
90
|
+
it('exposes per-platform runtime vars that differ', () => {
|
|
91
|
+
expect(platformEnvVars('aws')).toContain('AWS_REGION');
|
|
92
|
+
expect(platformEnvVars('gcp')).toContain('GOOGLE_CLOUD_PROJECT');
|
|
93
|
+
expect(platformEnvVars('gcp')).not.toContain('AWS_REGION');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('assumes no platform whitelist by default', () => {
|
|
97
|
+
const validator = new EnvValidator({
|
|
98
|
+
db: { type: ResourceType.Postgres },
|
|
99
|
+
});
|
|
100
|
+
expect(validator.has('AWS_REGION')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('trusts the AWS runtime vars only when platform: aws is set', () => {
|
|
104
|
+
const validator = new EnvValidator(
|
|
105
|
+
{ db: { type: ResourceType.Postgres } },
|
|
106
|
+
{ platform: 'aws' },
|
|
107
|
+
);
|
|
108
|
+
expect(validator.has('AWS_REGION')).toBe(true);
|
|
109
|
+
expect(validator.validate(['DB_HOST', 'AWS_REGION']).valid).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not trust AWS vars when targeting gcp', () => {
|
|
113
|
+
const validator = new EnvValidator(
|
|
114
|
+
{ db: { type: ResourceType.Postgres } },
|
|
115
|
+
{ platform: 'gcp' },
|
|
116
|
+
);
|
|
117
|
+
expect(validator.has('AWS_REGION')).toBe(false);
|
|
118
|
+
expect(validator.has('GOOGLE_CLOUD_PROJECT')).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('EnvValidator', () => {
|
|
123
|
+
const links = {
|
|
124
|
+
db: { type: ResourceType.Postgres },
|
|
125
|
+
apiKey: { type: ResourceType.Secret },
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
it('exposes available vars from links', () => {
|
|
129
|
+
const validator = new EnvValidator(links);
|
|
130
|
+
expect(validator.has('DB_HOST')).toBe(true);
|
|
131
|
+
expect(validator.has('API_KEY')).toBe(true);
|
|
132
|
+
expect(validator.has('NOT_THERE')).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('validate() reports missing variables', () => {
|
|
136
|
+
const validator = new EnvValidator(links);
|
|
137
|
+
const result = validator.validate(['DB_HOST', 'MISSING_ONE']);
|
|
138
|
+
expect(result.valid).toBe(false);
|
|
139
|
+
expect(result.invalidVars).toEqual(['MISSING_ONE']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('validate() passes when every required var is available', () => {
|
|
143
|
+
const validator = new EnvValidator(links);
|
|
144
|
+
expect(validator.validate(['DB_HOST', 'DB_URL', 'API_KEY']).valid).toBe(
|
|
145
|
+
true,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('treats `?`-suffixed variables as optional', () => {
|
|
150
|
+
const validator = new EnvValidator(links);
|
|
151
|
+
expect(validator.validate(['DB_HOST', 'SENTRY_DSN?']).valid).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('honours an additional whitelist (e.g. explicit environment keys)', () => {
|
|
155
|
+
const validator = new EnvValidator(links, { whitelist: ['APP_NAME'] });
|
|
156
|
+
expect(validator.validate(['DB_HOST', 'APP_NAME']).valid).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('getProvidersForEnvVars (least-privilege filtering)', () => {
|
|
161
|
+
const validator = new EnvValidator({
|
|
162
|
+
db: { type: ResourceType.Postgres },
|
|
163
|
+
uploads: { type: ResourceType.Bucket },
|
|
164
|
+
apiKey: { type: ResourceType.Secret },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns only the links that provide a requested var', () => {
|
|
168
|
+
expect(validator.getProvidersForEnvVars(['DB_HOST'])).toEqual(['db']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns multiple providers when several match', () => {
|
|
172
|
+
const providers = validator.getProvidersForEnvVars([
|
|
173
|
+
'DB_URL',
|
|
174
|
+
'UPLOADS_NAME',
|
|
175
|
+
]);
|
|
176
|
+
expect(providers).toEqual(['db', 'uploads']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('ignores optional `?` markers', () => {
|
|
180
|
+
expect(validator.getProvidersForEnvVars(['API_KEY?'])).toEqual([
|
|
181
|
+
'apiKey',
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns nothing when no link provides the var', () => {
|
|
186
|
+
expect(validator.getProvidersForEnvVars(['AWS_REGION'])).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('did-you-mean suggestions', () => {
|
|
191
|
+
const validator = new EnvValidator({
|
|
192
|
+
db: { type: ResourceType.Postgres },
|
|
193
|
+
redis: { type: ResourceType.Secret },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('suggests a renamed match (DATABASE_URL -> DB_URL)', () => {
|
|
197
|
+
const { suggestions } = validator.validate(['DATABASE_URL']);
|
|
198
|
+
expect(suggestions.DATABASE_URL).toContain('DB_URL');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('suggests a near-typo match (DB_HSOT -> DB_HOST)', () => {
|
|
202
|
+
const { suggestions } = validator.validate(['DB_HSOT']);
|
|
203
|
+
expect(suggestions.DB_HSOT).toContain('DB_HOST');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('offers no suggestion for an unrelated variable', () => {
|
|
207
|
+
const { suggestions } = validator.validate(['STRIPE_SECRET_KEY']);
|
|
208
|
+
expect(suggestions.STRIPE_SECRET_KEY).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('EnvValidationError', () => {
|
|
213
|
+
const links = { db: { type: ResourceType.Postgres } } as const;
|
|
214
|
+
|
|
215
|
+
it('assert() throws an EnvValidationError with structured fields', () => {
|
|
216
|
+
const validator = new EnvValidator(links, { context: 'orders-fn' });
|
|
217
|
+
try {
|
|
218
|
+
validator.assert(['DATABASE_URL', 'DB_HOST']);
|
|
219
|
+
expect.unreachable('assert should have thrown');
|
|
220
|
+
} catch (error) {
|
|
221
|
+
expect(error).toBeInstanceOf(EnvValidationError);
|
|
222
|
+
const e = error as EnvValidationError;
|
|
223
|
+
expect(e.missing).toEqual(['DATABASE_URL']);
|
|
224
|
+
expect(e.context).toBe('orders-fn');
|
|
225
|
+
expect(e.suggestions.DATABASE_URL).toContain('DB_URL');
|
|
226
|
+
expect(e.message).toContain("'orders-fn'");
|
|
227
|
+
expect(e.message).toContain('did you mean DB_URL?');
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('assert() does not throw when all required vars are available', () => {
|
|
232
|
+
const validator = new EnvValidator(links);
|
|
233
|
+
expect(() => validator.assert(['DB_HOST', 'DB_URL'])).not.toThrow();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -159,6 +159,8 @@ describe('SstEnvironmentBuilder', () => {
|
|
|
159
159
|
|
|
160
160
|
expect(env).toEqual({
|
|
161
161
|
EVENTS_TOPIC_ARN: 'arn:aws:sns:us-east-1:123456789:my-topic',
|
|
162
|
+
EVENTS_TOPIC_PUBLISHER_CONNECTION_STRING:
|
|
163
|
+
'sns://?topicArn=arn%3Aaws%3Asns%3Aus-east-1%3A123456789%3Amy-topic',
|
|
162
164
|
});
|
|
163
165
|
});
|
|
164
166
|
});
|
|
@@ -279,6 +281,8 @@ describe('SstEnvironmentBuilder', () => {
|
|
|
279
281
|
JWT_SECRET: 'jwt-secret',
|
|
280
282
|
UPLOADS_NAME: 'uploads-bucket',
|
|
281
283
|
EVENTS_ARN: 'arn:aws:sns:us-east-1:123456789:events',
|
|
284
|
+
EVENTS_PUBLISHER_CONNECTION_STRING:
|
|
285
|
+
'sns://?topicArn=arn%3Aaws%3Asns%3Aus-east-1%3A123456789%3Aevents',
|
|
282
286
|
API_VERSION: 'v2',
|
|
283
287
|
});
|
|
284
288
|
});
|