rastack 0.0.62 → 0.0.63

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.
@@ -63,12 +63,23 @@ const path = __importStar(require("path"));
63
63
  const config_1 = require("./deploy/config");
64
64
  const workflows_1 = require("./deploy/workflows");
65
65
  const io_1 = require("./deploy/io");
66
+ function parseEnvFlag(value) {
67
+ const v = value.trim().toLowerCase();
68
+ if (v === "all" || v === "")
69
+ return [...config_1.ENV_NAMES];
70
+ if (config_1.ENV_NAMES.includes(v))
71
+ return [v];
72
+ console.error(`Unknown --env: expected dev | prod | all.`);
73
+ process.exit(1);
74
+ }
66
75
  function parseFlags(argv) {
67
- const patch = { github: {} };
76
+ const patch = { github: {}, environments: {} };
77
+ let envs = [...config_1.ENV_NAMES];
68
78
  let force = false;
69
79
  let noWorkflows = false;
70
80
  let noDeploy = false;
71
81
  let print = false;
82
+ const devPatch = () => { var _a; return ((_a = patch.environments).dev ?? (_a.dev = {})); };
72
83
  for (let i = 0; i < argv.length; i++) {
73
84
  const a = argv[i];
74
85
  const next = () => argv[++i] ?? "";
@@ -85,6 +96,17 @@ function parseFlags(argv) {
85
96
  case "--branch":
86
97
  patch.github.branch = next();
87
98
  break;
99
+ case "--dev-branch":
100
+ devPatch().branch = next();
101
+ break;
102
+ case "--dev-proxy":
103
+ case "--dev-proxy-upstream":
104
+ // The dev external-API proxy upstream (a UAT/prod backend URL).
105
+ devPatch().proxyUpstream = next();
106
+ break;
107
+ case "--env":
108
+ envs = parseEnvFlag(next());
109
+ break;
88
110
  case "--region":
89
111
  patch.region = next();
90
112
  break;
@@ -114,7 +136,10 @@ function parseFlags(argv) {
114
136
  }
115
137
  }
116
138
  }
117
- return { patch, force, noWorkflows, noDeploy, print };
139
+ // Drop the scratch environments container when nothing was set on it.
140
+ if (patch.environments && !patch.environments.dev)
141
+ delete patch.environments;
142
+ return { patch, envs, force, noWorkflows, noDeploy, print };
118
143
  }
119
144
  /** Seed a config: existing file → flags → git remote → defaults. */
120
145
  function seedConfig(flags) {
@@ -151,6 +176,35 @@ async function promptMissing(cfg) {
151
176
  });
152
177
  });
153
178
  }
179
+ /**
180
+ * Ensure `.env` is git-ignored and (re)write the generated `.env` with the
181
+ * current config, preserving any secrets a developer already added. Returns the
182
+ * repo-relative paths touched, for the written/skipped summary.
183
+ */
184
+ function writeEnvAndGitignore(cfg) {
185
+ const touched = [];
186
+ // 1. git-ignore `.env` (append-merge; never duplicate or clobber).
187
+ const giAbs = path.join(process.cwd(), workflows_1.GITIGNORE_PATH);
188
+ const existingGi = fs.existsSync(giAbs)
189
+ ? fs.readFileSync(giAbs, "utf-8")
190
+ : "";
191
+ const nextGi = (0, workflows_1.gitignoreWith)(existingGi, workflows_1.ENV_PATH);
192
+ if (nextGi !== existingGi) {
193
+ fs.writeFileSync(giAbs, nextGi);
194
+ touched.push(workflows_1.GITIGNORE_PATH);
195
+ }
196
+ // 2. Upsert the generated RASTACK_* values into `.env` (secrets preserved).
197
+ const envAbs = path.join(process.cwd(), workflows_1.ENV_PATH);
198
+ const existingEnv = fs.existsSync(envAbs)
199
+ ? fs.readFileSync(envAbs, "utf-8")
200
+ : "";
201
+ const nextEnv = (0, workflows_1.applyEnvValues)(existingEnv, cfg);
202
+ if (nextEnv !== existingEnv) {
203
+ fs.writeFileSync(envAbs, nextEnv);
204
+ touched.push(workflows_1.ENV_PATH);
205
+ }
206
+ return touched;
207
+ }
154
208
  /** Yes/no confirmation with a default of "no" — gates the cloud-mutating steps. */
155
209
  async function confirm(question) {
156
210
  return (0, io_1.withPrompt)(async (rl) => {
@@ -164,24 +218,36 @@ function table(rows) {
164
218
  return rows.map((r) => ` ${r.key.padEnd(width)} ${r.value}`).join("\n");
165
219
  }
166
220
  /**
167
- * Read the app.yaml stack Outputs into a `DeployOutputs`, skipping any that are
168
- * not resolvable yet (best-effort — a freshly created stack has them all).
221
+ * Read one environment's app.yaml stack Outputs into a `DeployOutputs`, skipping
222
+ * any not resolvable yet (best-effort — a freshly created stack has them all).
169
223
  */
170
- function readAppOutputs(cfg) {
224
+ function readEnvOutputs(cfg, env) {
225
+ const stackName = (0, config_1.resolveEnvironment)(cfg, env).stackName;
171
226
  const outputs = {};
172
227
  for (const { key, field } of config_1.APP_STACK_OUTPUTS) {
173
- const value = (0, io_1.stackOutput)(cfg.stackName, key, cfg.region);
228
+ const value = (0, io_1.stackOutput)(stackName, key, cfg.region);
174
229
  if (value)
175
230
  outputs[field] = value;
176
231
  }
177
232
  return outputs;
178
233
  }
234
+ /** Merge an environment's resolved outputs back into the config in the right slot. */
235
+ function withEnvOutputs(cfg, env, outputs) {
236
+ return env === "prod"
237
+ ? (0, config_1.mergeConfig)(cfg, { outputs })
238
+ : (0, config_1.mergeConfig)(cfg, { environments: { dev: { outputs } } });
239
+ }
240
+ /** Human label for an environment ("dev" / "prod") plus its branch. */
241
+ function envLabel(cfg, env) {
242
+ const e = (0, config_1.resolveEnvironment)(cfg, env);
243
+ return `${env} (${e.branch} → ${e.ghEnvironment})`;
244
+ }
179
245
  /**
180
246
  * Print the exact commands `init` would run, without touching the cloud
181
247
  * (`--print`). The artifacts bucket is read from the bootstrap stack when the
182
248
  * AWS CLI is available, else shown as a placeholder.
183
249
  */
184
- function printPlan(cfg) {
250
+ function printPlan(cfg, envs) {
185
251
  const bucket = (0, io_1.awsAvailable)()
186
252
  ? (0, io_1.stackOutput)((0, config_1.bootstrapStackName)(cfg), config_1.BOOTSTRAP_ARTIFACTS_OUTPUT, cfg.region)
187
253
  : null;
@@ -189,23 +255,32 @@ function printPlan(cfg) {
189
255
  artifactsBucket: bucket ?? "<ArtifactsBucketName from bootstrap stack>",
190
256
  });
191
257
  const appTemplate = (0, io_1.resolveDeployAsset)("app.yaml") ?? undefined;
192
- console.log(`\n Deployment plan (${cfg.stackName} @ ${cfg.region})\n`);
258
+ console.log(`\n Deployment plan (${envs.join(" + ")} @ ${cfg.region})\n`);
193
259
  console.log(` ── Prerequisite: the tier-0 bootstrap stack (${(0, config_1.bootstrapStackName)(cfg)}) must exist ──`);
194
260
  console.log(` Create it once per account+region (init does not): rastack bootstrap --region ${cfg.region}\n`);
195
- console.log(" ── 1. Per-app backend (app.yaml) — what init deploys ──");
196
- console.log("\n " + (0, config_1.deployStackCommand)(withBucket, appTemplate) + "\n");
197
- console.log(" ── 2. Wire these GitHub repo Variables from the stack Outputs ──");
198
- console.log(table((0, config_1.repoVariables)(cfg).map((v) => ({
199
- key: v.name,
200
- value: v.value || `<${v.from}>`,
201
- }))) + "\n");
261
+ console.log(" ── 1. Per-app backend (app.yaml) — one stack per environment ──");
262
+ for (const env of envs) {
263
+ const e = (0, config_1.resolveEnvironment)(withBucket, env);
264
+ console.log(`\n # ${envLabel(withBucket, env)} — stack ${e.stackName}`);
265
+ console.log(" " + (0, config_1.deployStackCommandForEnv)(withBucket, env, appTemplate));
266
+ }
267
+ console.log("\n ── 2. Wire each GitHub Environment's Variables from its stack Outputs ──");
268
+ for (const env of envs) {
269
+ const e = (0, config_1.resolveEnvironment)(cfg, env);
270
+ console.log(`\n # GitHub Environment: ${e.ghEnvironment}`);
271
+ console.log(table((0, config_1.repoVariablesForEnv)(cfg, env).map((v) => ({
272
+ key: v.name,
273
+ value: v.value || `<${v.from}>`,
274
+ }))));
275
+ }
276
+ console.log("");
202
277
  }
203
278
  /**
204
279
  * Stand the infrastructure up: ensure the bootstrap stack + artifacts bucket,
205
280
  * upload the Lambda placeholder, deploy app.yaml, and read the Outputs back into
206
281
  * the config. Returns the config enriched with the resolved buckets + outputs.
207
282
  */
208
- async function standUp(cfg, interactive) {
283
+ async function standUp(cfg, envs, interactive) {
209
284
  if (!(0, io_1.awsAvailable)()) {
210
285
  console.error(" ✗ the AWS CLI is not on PATH. Install it and configure credentials,\n" +
211
286
  " or re-run with --print to see the commands, or --no-deploy to skip.");
@@ -229,13 +304,12 @@ async function standUp(cfg, interactive) {
229
304
  process.exit(1);
230
305
  }
231
306
  console.log(` ✓ artifacts bucket: ${artifactsBucket}`);
232
- // 2. Upload the placeholder the function is created from.
307
+ // 2. Upload the placeholder the function is created from (shared by all envs).
233
308
  console.log("\n▸ Uploading the Lambda placeholder bundle");
234
309
  if (!(0, io_1.uploadPlaceholder)(artifactsBucket, cfg.region)) {
235
310
  console.error(" ✗ Lambda placeholder bundle not found in the rastack package — cannot create the function.");
236
311
  process.exit(1);
237
312
  }
238
- // 3. Deploy the per-app backend with the resolved bucket wired in.
239
313
  cfg = (0, config_1.mergeConfig)(cfg, {
240
314
  artifactsBucket,
241
315
  templatesBucket: templatesBucket ?? undefined,
@@ -246,22 +320,25 @@ async function standUp(cfg, interactive) {
246
320
  " (`npm i -g rastack`) or run from the framework repo.");
247
321
  process.exit(1);
248
322
  }
249
- console.log(`\n▸ Deploying ${cfg.stackName}`);
250
- try {
251
- (0, io_1.awsRun)((0, config_1.deployStackArgs)(cfg, appTemplate));
252
- }
253
- catch (err) {
254
- console.error(`\n ✗ aws cloudformation deploy failed for ${cfg.stackName}.\n ${err.message}`);
255
- process.exit(1);
323
+ // 3. Deploy one stack per environment — each with its own Cognito pool,
324
+ // warehouse, Lambda and API — reading the Outputs back into the config.
325
+ for (const env of envs) {
326
+ const stackName = (0, config_1.resolveEnvironment)(cfg, env).stackName;
327
+ console.log(`\n▸ Deploying ${envLabel(cfg, env)} — ${stackName}`);
328
+ try {
329
+ (0, io_1.awsRun)((0, config_1.deployStackArgsForEnv)(cfg, env, appTemplate));
330
+ }
331
+ catch (err) {
332
+ console.error(`\n ✗ aws cloudformation deploy failed for ${stackName}.\n ${err.message}`);
333
+ process.exit(1);
334
+ }
335
+ cfg = withEnvOutputs(cfg, env, readEnvOutputs(cfg, env));
336
+ (0, io_1.saveConfig)(cfg);
256
337
  }
257
- // 4. Read the Outputs back into the config so CI has them without a live lookup.
258
- const outputs = readAppOutputs(cfg);
259
- cfg = (0, config_1.mergeConfig)(cfg, { outputs });
260
- (0, io_1.saveConfig)(cfg);
261
- // 5. Connect the GitHub CLI and wire the repo Variables CI reads, so the FIRST
262
- // push actually ships. Without this the deploy-backend workflow's `if:
263
- // vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
264
- wireRepoVariables(cfg, interactive);
338
+ // 4. Connect the GitHub CLI and wire each environment's scoped Variables, so
339
+ // the FIRST push actually ships. Without this the deploy-backend workflow's
340
+ // `if: vars.AWS_DEPLOY_ROLE_ARN != ''` guard silently skips every run.
341
+ wireEnvironments(cfg, envs, interactive);
265
342
  return cfg;
266
343
  }
267
344
  /**
@@ -275,58 +352,97 @@ async function standUp(cfg, interactive) {
275
352
  * (and isn't) done.
276
353
  */
277
354
  const wiredVariables = new Set();
278
- function wireRepoVariables(cfg, interactive) {
279
- const vars = (0, config_1.repoVariables)(cfg).filter((v) => v.value);
280
- if (!vars.length)
355
+ /** Key a wired Variable by its GitHub Environment so dev/prod don't collide. */
356
+ function wiredKey(ghEnvironment, name) {
357
+ return `${ghEnvironment ?? ""}:${name}`;
358
+ }
359
+ /**
360
+ * Create each environment's GitHub Actions Environment and wire its scoped
361
+ * `AWS_DEPLOY_ROLE_ARN` / `AWS_REGION` Variables via `gh`, so the GitHub →
362
+ * Lambda pipeline is live for both dev and prod the moment `init` finishes.
363
+ */
364
+ function wireEnvironments(cfg, envs, interactive) {
365
+ const anyValue = envs.some((env) => (0, config_1.repoVariablesForEnv)(cfg, env).some((v) => v.value));
366
+ if (!anyValue)
281
367
  return;
282
368
  if (!(0, io_1.ghAvailable)()) {
283
- console.log("\n▸ GitHub CLI (`gh`) not found — skipping repo Variable wiring.\n" +
369
+ console.log("\n▸ GitHub CLI (`gh`) not found — skipping environment + Variable wiring.\n" +
284
370
  " Install `gh` (https://cli.github.com) and re-run, or wire the\n" +
285
371
  " Variables below by hand so pushes deploy.");
286
372
  return;
287
373
  }
288
- // Connect `gh` to GitHub if it isn't already, so it can set the Variables.
374
+ // Connect `gh` to GitHub if it isn't already, so it can create environments.
289
375
  if (!(0, io_1.ghAuthenticated)()) {
290
376
  if (interactive) {
291
377
  console.log("\n▸ Connecting the GitHub CLI to your account (`gh auth login`)");
292
378
  (0, io_1.ghAuthLogin)();
293
379
  }
294
380
  if (!(0, io_1.ghAuthenticated)()) {
295
- console.log("\n▸ GitHub CLI is not connected — skipping repo Variable wiring.\n" +
381
+ console.log("\n▸ GitHub CLI is not connected — skipping environment wiring.\n" +
296
382
  " Run `gh auth login`, then wire the Variables below by hand.");
297
383
  return;
298
384
  }
299
385
  }
300
- console.log("\n▸ Wiring the GitHub repo Variables CI reads (via `gh`)");
301
- for (const v of vars) {
302
- const ok = (0, io_1.setRepoVariable)((0, config_1.ghVariableSetArgs)(cfg, v.name, v.value));
303
- if (ok) {
304
- wiredVariables.add(v.name);
305
- console.log(` ✓ set ${v.name}`);
386
+ console.log("\n▸ Creating GitHub Environments + wiring their Variables (via `gh`)");
387
+ for (const env of envs) {
388
+ const e = (0, config_1.resolveEnvironment)(cfg, env);
389
+ // Create the Environment first — a Variable can only be scoped to one that
390
+ // already exists. Idempotent (PUT), so re-running init is safe.
391
+ if (!(0, io_1.ghEnvironmentCreate)((0, config_1.ghEnvironmentCreateArgs)(cfg, e.ghEnvironment))) {
392
+ console.log(` • could not create GitHub Environment ${e.ghEnvironment} — create it by hand`);
393
+ continue;
306
394
  }
307
- else {
308
- console.log(` • could not set ${v.name} — wire it by hand (below)`);
395
+ for (const v of (0, config_1.repoVariablesForEnv)(cfg, env).filter((x) => x.value)) {
396
+ const ok = (0, io_1.setRepoVariable)((0, config_1.ghVariableSetArgs)(cfg, v.name, v.value, v.ghEnvironment));
397
+ if (ok) {
398
+ wiredVariables.add(wiredKey(v.ghEnvironment, v.name));
399
+ console.log(` ✓ set ${e.ghEnvironment}/${v.name}`);
400
+ }
401
+ else {
402
+ console.log(` • could not set ${e.ghEnvironment}/${v.name} — wire it by hand (below)`);
403
+ }
309
404
  }
310
405
  }
311
406
  }
312
- function printSummary(cfg) {
313
- console.log(`\n ✓ ${cfg.app} is deployed (${cfg.stackName} @ ${cfg.region})`);
314
- if (cfg.outputs?.apiUrl)
315
- console.log(` API: ${cfg.outputs.apiUrl}`);
407
+ function printSummary(cfg, envs) {
408
+ console.log(`\n ✓ ${cfg.app} is deployed @ ${cfg.region}`);
409
+ for (const env of envs) {
410
+ const e = (0, config_1.resolveEnvironment)(cfg, env);
411
+ console.log(`\n ${env} — ${e.stackName}`);
412
+ if (e.outputs?.apiUrl)
413
+ console.log(` API: ${e.outputs.apiUrl}`);
414
+ if (e.outputs?.hostedUiUrl)
415
+ console.log(` Hosted UI: ${e.outputs.hostedUiUrl}`);
416
+ if (e.outputs?.userPoolId)
417
+ console.log(` User Pool: ${e.outputs.userPoolId}`);
418
+ if (e.outputs?.proxyUrl)
419
+ console.log(` Proxy: ${e.outputs.proxyUrl}`);
420
+ }
316
421
  // Only the Variables `init` could NOT wire itself remain a manual step.
317
- const pending = (0, config_1.repoVariables)(cfg).filter((v) => !wiredVariables.has(v.name));
422
+ const pending = [];
423
+ for (const env of envs) {
424
+ const e = (0, config_1.resolveEnvironment)(cfg, env);
425
+ for (const v of (0, config_1.repoVariablesForEnv)(cfg, env)) {
426
+ if (!wiredVariables.has(wiredKey(v.ghEnvironment, v.name)))
427
+ pending.push({
428
+ key: `${e.ghEnvironment}/${v.name}`,
429
+ value: v.value || `<${v.from}>`,
430
+ });
431
+ }
432
+ }
318
433
  if (pending.length) {
319
- console.log("\n Wire these GitHub repo Variables so CI can ship code (Settings → Variables):");
320
- console.log(table(pending.map((v) => ({
321
- key: v.name,
322
- value: v.value || `<${v.from}>`,
323
- }))));
434
+ console.log("\n Wire these per-environment GitHub Variables so CI can ship code\n" +
435
+ " (Settings → Environments → <env> → Variables):");
436
+ console.log(table(pending));
324
437
  }
325
438
  else {
326
- console.log("\n ✓ wired the GitHub repo Variables CI reads (AWS_DEPLOY_ROLE_ARN, AWS_REGION).");
439
+ console.log("\n ✓ wired every GitHub Environment's Variables (AWS_DEPLOY_ROLE_ARN, AWS_REGION).");
327
440
  }
328
- console.log(`\n Every push to ${cfg.github.branch} now ships code via \`rastack ci deploy\`` +
329
- ` (also runnable locally as \`rastack deploy\`).\n`);
441
+ const dev = (0, config_1.resolveEnvironment)(cfg, "dev");
442
+ const prod = (0, config_1.resolveEnvironment)(cfg, "prod");
443
+ console.log(`\n Pushes now ship code via \`rastack ci deploy\`:\n` +
444
+ ` ${prod.branch} → prod (${prod.ghEnvironment}) ${dev.branch} → dev (${dev.ghEnvironment})\n` +
445
+ ` Run one locally with \`rastack deploy --env dev\` / \`--env prod\`.\n`);
330
446
  }
331
447
  async function main(argv) {
332
448
  const flags = parseFlags(argv);
@@ -362,6 +478,10 @@ async function main(argv) {
362
478
  fs.writeFileSync(abs, f.content);
363
479
  written.push(f.path);
364
480
  }
481
+ // The `.env` (local app environment) + its `.gitignore` entry are standard —
482
+ // generated on every init, secrets preserved, refreshed with Outputs post-deploy.
483
+ for (const p of writeEnvAndGitignore(cfg))
484
+ written.push(p);
365
485
  console.log("");
366
486
  for (const w of written)
367
487
  console.log(` ✓ wrote ${w}`);
@@ -369,7 +489,7 @@ async function main(argv) {
369
489
  console.log(` • skipped ${s} (exists — pass --force to overwrite)`);
370
490
  // 2. Deploy (unless opted out).
371
491
  if (flags.print) {
372
- printPlan(cfg);
492
+ printPlan(cfg, flags.envs);
373
493
  return;
374
494
  }
375
495
  if (flags.noDeploy) {
@@ -378,14 +498,20 @@ async function main(argv) {
378
498
  return;
379
499
  }
380
500
  if ((0, io_1.canPrompt)()) {
381
- const ok = await confirm(`Deploy ${cfg.stackName} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`);
501
+ const stacks = flags.envs
502
+ .map((env) => (0, config_1.resolveEnvironment)(cfg, env).stackName)
503
+ .join(" + ");
504
+ const ok = await confirm(`Deploy ${stacks} to AWS ${cfg.region} now? (requires the bootstrap stack to exist) [y/N]`);
382
505
  if (!ok) {
383
506
  console.log("\n Skipped the deploy. Re-run to deploy, or `rastack init --print` for the commands.\n");
384
507
  return;
385
508
  }
386
509
  }
387
- cfg = await standUp(cfg, (0, io_1.canPrompt)());
388
- printSummary(cfg);
510
+ cfg = await standUp(cfg, flags.envs, (0, io_1.canPrompt)());
511
+ // Refresh `.env` with the resolved stack Outputs (API URLs, pool/client ids).
512
+ for (const p of writeEnvAndGitignore(cfg))
513
+ console.log(` ✓ updated ${p}`);
514
+ printSummary(cfg, flags.envs);
389
515
  }
390
516
  if (require.main === module) {
391
517
  main(process.argv.slice(2)).catch((err) => {
package/dist/rastack.js CHANGED
@@ -130,6 +130,7 @@ switch (command) {
130
130
  case "deploy":
131
131
  // Ship code to the Lambda — an alias for the CI code deploy. Infrastructure
132
132
  // is stood up once by `rastack init`; `deploy` only ever ships code.
133
+ // `--env dev|prod` picks which environment's function to update.
133
134
  run("rastack-ci.js", ["deploy", ...rest]);
134
135
  break;
135
136
  case "ci":
@@ -34,7 +34,44 @@ Parameters:
34
34
  GitHubBranch:
35
35
  Type: String
36
36
  Default: master
37
- Description: Branch of GitHubRepo allowed to deploy.
37
+ Description: Branch of GitHubRepo allowed to deploy (used when GitHubEnvironment is blank).
38
+
39
+ GitHubEnvironment:
40
+ Type: String
41
+ Default: ""
42
+ Description: >
43
+ GitHub Actions **Environment** (e.g. development / production) this stack's
44
+ deploy role trusts. When set, the OIDC trust is scoped to
45
+ `…:environment:<name>` — the subject a job that declares `environment:` gets
46
+ — instead of a branch ref, so the dev and prod deploys can share a branch
47
+ set yet only assume their own environment's role. Blank keeps the legacy
48
+ branch-ref trust (`…:ref:refs/heads/<GitHubBranch>`). `rastack init` sets it
49
+ to development / production for the dev / prod stacks.
50
+
51
+ CallbackUrls:
52
+ Type: CommaDelimitedList
53
+ Default: "http://localhost:4321"
54
+ Description: >
55
+ Allowed OAuth redirect (callback) URLs for the Hosted UI app client. The
56
+ local `rastack dev` origin is the default so a developer can sign in against
57
+ this environment's real Cognito pool while developing; add your deployed
58
+ frontend origin(s) too.
59
+
60
+ LogoutUrls:
61
+ Type: CommaDelimitedList
62
+ Default: "http://localhost:4321"
63
+ Description: Allowed sign-out redirect URLs for the Hosted UI app client.
64
+
65
+ ExternalApiUpstream:
66
+ Type: String
67
+ Default: ""
68
+ Description: >
69
+ Base URL (no trailing slash) the **external-API proxy** forwards to —
70
+ typically a UAT or prod backend. When set, the stack adds a
71
+ Cognito-authorized `ANY /proxy/{proxy+}` route to the HTTP API that forwards
72
+ to `<upstream>/{proxy}`, so a locally-running app (signed in against this
73
+ pool) reaches external APIs only through the proxy, never directly. Blank
74
+ omits the proxy route + authorizer entirely.
38
75
 
39
76
  ArtifactsBucket:
40
77
  Type: String
@@ -87,6 +124,10 @@ Conditions:
87
124
  DeriveOIDCArn: !Equals [!Ref OIDCProviderArn, ""]
88
125
  ShouldTrailDataEvents: !Equals [!Ref EnableDataEventTrail, "true"]
89
126
  UseAdvancedSecurity: !Not [!Equals [!Ref AdvancedSecurity, "OFF"]]
127
+ # Scope the deploy-role trust to a GitHub Actions Environment when one is given.
128
+ TrustEnvironment: !Not [!Equals [!Ref GitHubEnvironment, ""]]
129
+ # Provision the external-API proxy route only when an upstream is configured.
130
+ HasProxy: !Not [!Equals [!Ref ExternalApiUpstream, ""]]
90
131
 
91
132
  Resources:
92
133
 
@@ -290,6 +331,20 @@ Resources:
290
331
  - ALLOW_USER_SRP_AUTH
291
332
  - ALLOW_REFRESH_TOKEN_AUTH
292
333
  PreventUserExistenceErrors: ENABLED
334
+ # Hosted UI (OAuth 2.0 Authorization Code + PKCE) — the flow rastack/auth's
335
+ # <CognitoAuthProvider> drives. Enabling it here lets a locally-running app
336
+ # (`rastack dev`) sign in against this environment's real Cognito pool.
337
+ SupportedIdentityProviders:
338
+ - COGNITO
339
+ AllowedOAuthFlowsUserPoolClient: true
340
+ AllowedOAuthFlows:
341
+ - code
342
+ AllowedOAuthScopes:
343
+ - openid
344
+ - email
345
+ - profile
346
+ CallbackURLs: !Ref CallbackUrls
347
+ LogoutURLs: !Ref LogoutUrls
293
348
  TokenValidityUnits:
294
349
  AccessToken: minutes
295
350
  IdToken: minutes
@@ -299,6 +354,15 @@ Resources:
299
354
  RefreshTokenValidity: 30
300
355
  EnableTokenRevocation: true
301
356
 
357
+ # Hosted UI domain — the browser sign-in surface. Prefix is globally unique via
358
+ # the account id. Both the deployed frontend and local `rastack dev` redirect
359
+ # here to authenticate against this environment's pool.
360
+ UserPoolDomain:
361
+ Type: AWS::Cognito::UserPoolDomain
362
+ Properties:
363
+ Domain: !Sub "${NamePrefix}-${AppName}-${AWS::AccountId}"
364
+ UserPoolId: !Ref UserPool
365
+
302
366
  IdentityPool:
303
367
  Type: AWS::Cognito::IdentityPool
304
368
  Properties:
@@ -494,6 +558,49 @@ Resources:
494
558
  Principal: apigateway.amazonaws.com
495
559
  SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*"
496
560
 
561
+ # -- External-API proxy (optional) --------------------------------------------
562
+ # When ExternalApiUpstream is set, a Cognito-authorized `ANY /proxy/{proxy+}`
563
+ # route forwards to `<upstream>/{proxy}`. HTTP API prefers the more specific
564
+ # route, so `/proxy/*` goes here and everything else still hits the Rust Lambda
565
+ # via `$default`. The JWT authorizer means only callers holding a valid token
566
+ # from THIS environment's pool can use the proxy — so a local dev app signs in
567
+ # against the dev pool, then reaches UAT/prod externals only through here.
568
+ ProxyJwtAuthorizer:
569
+ Type: AWS::ApiGatewayV2::Authorizer
570
+ Condition: HasProxy
571
+ Properties:
572
+ ApiId: !Ref HttpApi
573
+ AuthorizerType: JWT
574
+ Name: !Sub "${NamePrefix}-${AppName}-cognito"
575
+ IdentitySource:
576
+ - "$request.header.Authorization"
577
+ JwtConfiguration:
578
+ Audience:
579
+ - !Ref UserPoolClient
580
+ Issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
581
+
582
+ ProxyIntegration:
583
+ Type: AWS::ApiGatewayV2::Integration
584
+ Condition: HasProxy
585
+ Properties:
586
+ ApiId: !Ref HttpApi
587
+ IntegrationType: HTTP_PROXY
588
+ IntegrationMethod: ANY
589
+ # `{proxy}` is the greedy path variable captured by the route; the caller's
590
+ # Authorization header is forwarded so the upstream can authorize too.
591
+ IntegrationUri: !Sub "${ExternalApiUpstream}/{proxy}"
592
+ PayloadFormatVersion: "1.0"
593
+
594
+ ProxyRoute:
595
+ Type: AWS::ApiGatewayV2::Route
596
+ Condition: HasProxy
597
+ Properties:
598
+ ApiId: !Ref HttpApi
599
+ RouteKey: "ANY /proxy/{proxy+}"
600
+ Target: !Sub "integrations/${ProxyIntegration}"
601
+ AuthorizationType: JWT
602
+ AuthorizerId: !Ref ProxyJwtAuthorizer
603
+
497
604
  # -- Deploy role (GitHub OIDC) — code only ------------------------------------
498
605
  # CI assumes this to ship compiled code. It can UpdateFunctionCode on THIS one
499
606
  # function and read this app's SSM params — nothing else — with an explicit
@@ -518,7 +625,13 @@ Resources:
518
625
  StringEquals:
519
626
  token.actions.githubusercontent.com:aud: sts.amazonaws.com
520
627
  StringLike:
521
- token.actions.githubusercontent.com:sub: !Sub "repo:${GitHubOwner}/${GitHubRepo}:ref:refs/heads/${GitHubBranch}"
628
+ # An environment-gated GitHub job's OIDC subject is
629
+ # `repo:O/R:environment:<env>`, not `…:ref:refs/heads/<branch>`,
630
+ # so the trust must match the environment when one is set.
631
+ token.actions.githubusercontent.com:sub: !If
632
+ - TrustEnvironment
633
+ - !Sub "repo:${GitHubOwner}/${GitHubRepo}:environment:${GitHubEnvironment}"
634
+ - !Sub "repo:${GitHubOwner}/${GitHubRepo}:ref:refs/heads/${GitHubBranch}"
522
635
  Policies:
523
636
  - PolicyName: update-function-code-only
524
637
  PolicyDocument:
@@ -602,6 +715,14 @@ Resources:
602
715
  Type: String
603
716
  Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}/.well-known/jwks.json"
604
717
 
718
+ ParamHostedUiUrl:
719
+ Type: AWS::SSM::Parameter
720
+ DependsOn: UserPoolDomain
721
+ Properties:
722
+ Name: !Sub "/rastack/${AppName}/hosted-ui-url"
723
+ Type: String
724
+ Value: !Sub "https://${NamePrefix}-${AppName}-${AWS::AccountId}.auth.${AWS::Region}.amazoncognito.com"
725
+
605
726
  Outputs:
606
727
  ApiUrl:
607
728
  Description: Base URL of the deployed API (set as the frontend's remote base).
@@ -627,9 +748,19 @@ Outputs:
627
748
  JwksUrl:
628
749
  Description: JWKS to bake into the Lambda bundle at build time.
629
750
  Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}/.well-known/jwks.json"
751
+ UserPoolDomain:
752
+ Description: Cognito Hosted UI domain prefix (scheme-less).
753
+ Value: !Ref UserPoolDomain
754
+ HostedUiUrl:
755
+ Description: Cognito Hosted UI base URL — browser sign-in / `rastack dev` auth.
756
+ Value: !Sub "https://${NamePrefix}-${AppName}-${AWS::AccountId}.auth.${AWS::Region}.amazoncognito.com"
630
757
  WarehouseBucketName:
631
758
  Description: S3 Iceberg warehouse — the system of record.
632
759
  Value: !Ref WarehouseBucket
633
760
  AuditLogBucketName:
634
761
  Description: S3 access logs + CloudTrail data events for the warehouse.
635
762
  Value: !Ref AuditLogBucket
763
+ ProxyUrl:
764
+ Condition: HasProxy
765
+ Description: Base URL of the Cognito-authorized external-API proxy (when enabled).
766
+ Value: !Sub "https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/proxy"
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.62",
3
+ "version": "0.0.63",
4
4
  "description": "",
5
5
  "main": "runtime.ts",
6
6
  "types": "runtime.ts",