@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.
@@ -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
  });