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.
@@ -15,7 +15,7 @@
15
15
  * or emitted as a live `$(aws cloudformation describe-stacks …)` substitution so
16
16
  * the same plan works before and after they are wired into the config.
17
17
  */
18
- import { DeployConfig } from "./config";
18
+ import { DeployConfig, EnvName } from "./config";
19
19
  export type CiPhase = "check" | "build" | "deploy";
20
20
  export interface CiStep {
21
21
  /** Human label, echoed before the step runs. */
@@ -37,10 +37,17 @@ export interface CiPlanOptions {
37
37
  bin?: string;
38
38
  /** Skip the build steps in `deploy` (code already built this job). */
39
39
  skipBuild?: boolean;
40
- /** Override the function name (else from cfg.outputs, else resolved live). */
40
+ /** Override the function name (else from the env's outputs, else resolved live). */
41
41
  functionName?: string;
42
- /** Override the JWKS URL (else from cfg.outputs, else resolved live). */
42
+ /** Override the JWKS URL (else from the env's outputs, else resolved live). */
43
43
  jwksUrl?: string;
44
+ /**
45
+ * Which environment (dev/prod) to build+ship. When set, the stack name,
46
+ * outputs, and app label come from that environment — so `rastack ci deploy
47
+ * --env dev` targets the dev stack (dev's Cognito JWKS, dev's function). When
48
+ * unset the legacy single-stack fields (`cfg.stackName` / `cfg.outputs`) apply.
49
+ */
50
+ env?: EnvName;
44
51
  }
45
52
  /** `check` — validate the resource graph and compile the manifest. A CI gate. */
46
53
  export declare function checkSteps(cfg: DeployConfig, bin: string): CiStep[];
package/dist/deploy/ci.js CHANGED
@@ -21,6 +21,7 @@ exports.checkSteps = checkSteps;
21
21
  exports.buildSteps = buildSteps;
22
22
  exports.deploySteps = deploySteps;
23
23
  exports.ciPlan = ciPlan;
24
+ const config_1 = require("./config");
24
25
  /**
25
26
  * The in-process compile step shared by `check` and `build`: it produces
26
27
  * `schema.rastack.json` + `openapi.json` in `cfg.outDir` without a subprocess,
@@ -34,17 +35,25 @@ function compileStep(cfg) {
34
35
  action: "compile",
35
36
  };
36
37
  }
38
+ /** The stack name + outputs + app label the plan targets, honouring `opts.env`. */
39
+ function ciTarget(cfg, opts) {
40
+ if (opts.env) {
41
+ const e = (0, config_1.resolveEnvironment)(cfg, opts.env);
42
+ return { stackName: e.stackName, outputs: e.outputs, app: e.appName };
43
+ }
44
+ return { stackName: cfg.stackName, outputs: cfg.outputs, app: cfg.app };
45
+ }
37
46
  /**
38
- * A stack Output value: the literal from `cfg.outputs` if wired, else a shell
39
- * command substitution that resolves it live from CloudFormation. Keeping the
40
- * fallback inline (rather than a separate step) means the plan is a flat list of
41
- * self-contained commands that read correctly in any order.
47
+ * A stack Output value: the literal if wired, else a shell command substitution
48
+ * that resolves it live from CloudFormation. Keeping the fallback inline (rather
49
+ * than a separate step) means the plan is a flat list of self-contained commands
50
+ * that read correctly in any order.
42
51
  */
43
- function stackOutput(cfg, outputKey, literal) {
52
+ function stackOutput(stackName, region, outputKey, literal) {
44
53
  if (literal)
45
54
  return literal;
46
- return (`$(aws cloudformation describe-stacks --stack-name ${cfg.stackName} ` +
47
- `--region ${cfg.region} ` +
55
+ return (`$(aws cloudformation describe-stacks --stack-name ${stackName} ` +
56
+ `--region ${region} ` +
48
57
  `--query "Stacks[0].Outputs[?OutputKey=='${outputKey}'].OutputValue" ` +
49
58
  `--output text)`);
50
59
  }
@@ -69,8 +78,9 @@ function checkSteps(cfg, bin) {
69
78
  */
70
79
  function buildSteps(cfg, opts = {}) {
71
80
  const stage = stageDir(cfg);
81
+ const target = ciTarget(cfg, opts);
72
82
  const archFlag = cfg.lambda.arch === "arm64" ? "--arm64" : "--x86-64";
73
- const jwks = stackOutput(cfg, "JwksUrl", opts.jwksUrl ?? cfg.outputs?.jwksUrl);
83
+ const jwks = stackOutput(target.stackName, cfg.region, "JwksUrl", opts.jwksUrl ?? target.outputs?.jwksUrl);
74
84
  return [
75
85
  compileStep(cfg),
76
86
  {
@@ -100,8 +110,9 @@ function buildSteps(cfg, opts = {}) {
100
110
  function deploySteps(cfg, opts = {}) {
101
111
  const stage = stageDir(cfg);
102
112
  const zip = `${stage}/bootstrap.zip`;
103
- const fn = stackOutput(cfg, "FunctionName", opts.functionName ?? cfg.outputs?.functionName);
104
- const apiUrl = stackOutput(cfg, "ApiUrl", cfg.outputs?.apiUrl);
113
+ const target = ciTarget(cfg, opts);
114
+ const fn = stackOutput(target.stackName, cfg.region, "FunctionName", opts.functionName ?? target.outputs?.functionName);
115
+ const apiUrl = stackOutput(target.stackName, cfg.region, "ApiUrl", target.outputs?.apiUrl);
105
116
  const steps = opts.skipBuild ? [] : buildSteps(cfg, opts);
106
117
  steps.push({
107
118
  name: "Ship the code (update-function-code)",
@@ -113,7 +124,7 @@ function deploySteps(cfg, opts = {}) {
113
124
  run: `aws lambda wait function-updated --function-name ${fn} --region ${cfg.region}`,
114
125
  }, {
115
126
  name: "Report the API URL",
116
- run: `echo "Deployed ${cfg.app} → ${apiUrl}"`,
127
+ run: `echo "Deployed ${target.app} → ${apiUrl}"`,
117
128
  });
118
129
  return steps;
119
130
  }
@@ -32,6 +32,12 @@ export interface DeployOutputs {
32
32
  jwksUrl?: string;
33
33
  /** S3 Iceberg warehouse bucket — the system of record. */
34
34
  warehouseBucket?: string;
35
+ /** Cognito Hosted UI domain (scheme-less), for browser sign-in. */
36
+ userPoolDomain?: string;
37
+ /** Full Cognito Hosted UI base URL (`https://…`). */
38
+ hostedUiUrl?: string;
39
+ /** Base URL of the external-API proxy route, when the env provisions one. */
40
+ proxyUrl?: string;
35
41
  }
36
42
  export interface LambdaBuild {
37
43
  /** Cargo bin built for the custom runtime (`provided.al2023`). */
@@ -41,6 +47,35 @@ export interface LambdaBuild {
41
47
  /** Cargo features the Lambda build enables. */
42
48
  features: string;
43
49
  }
50
+ /**
51
+ * Per-environment overrides. The **prod** environment defaults its values from
52
+ * the top-level config (its branch is `github.branch`, its stack is `stackName`,
53
+ * its outputs are the top-level `outputs`); the **dev** environment defaults to
54
+ * the `development` branch and a `-dev` stack. Anything set here wins over those
55
+ * defaults. Only the fields that actually differ per environment live here —
56
+ * `app`/`namePrefix`/`region`/`github.owner`/`github.repo` are shared.
57
+ */
58
+ export interface EnvOverride {
59
+ /** GitHub branch whose push deploys this environment. */
60
+ branch?: string;
61
+ /** GitHub Actions **Environment** name (scoped Variables + protection rules). */
62
+ ghEnvironment?: string;
63
+ /** CloudFormation stack name for this environment. */
64
+ stackName?: string;
65
+ /** Iceberg namespace this environment's warehouse writes under. */
66
+ namespace?: string;
67
+ /**
68
+ * Base URL of the upstream this environment's **external-API proxy** forwards
69
+ * to (typically a UAT or prod backend). When set, the stack provisions a
70
+ * Cognito-authorized `ANY /proxy/{proxy+}` route on the HTTP API that forwards
71
+ * to `${proxyUpstream}/{proxy}` — so a locally-running app (signed in against
72
+ * this environment's Cognito pool) reaches external APIs *only* through the
73
+ * proxy, never directly, and credentials stay server-side. Empty = no proxy.
74
+ */
75
+ proxyUpstream?: string;
76
+ /** Values resolved from this environment's stack after it is created. */
77
+ outputs?: DeployOutputs;
78
+ }
44
79
  export interface DeployConfig {
45
80
  /** App slug — namespaces buckets/roles/SSM. Cognito-safe: `^[a-z0-9]{1,20}$`. */
46
81
  app: string;
@@ -48,9 +83,16 @@ export interface DeployConfig {
48
83
  namePrefix: string;
49
84
  /** AWS region the stack (and CI) target. */
50
85
  region: string;
51
- /** CloudFormation stack name — defaults to `${namePrefix}-${app}`. */
86
+ /**
87
+ * CloudFormation stack name of the **prod** environment — defaults to
88
+ * `${namePrefix}-${app}`. Dev's stack defaults to this + `-dev`.
89
+ */
52
90
  stackName: string;
53
- /** The GitHub repo whose CI is trusted to ship the Lambda code. */
91
+ /**
92
+ * The GitHub repo whose CI is trusted to ship the Lambda code. `branch` is the
93
+ * **prod** deploy branch (default `master`); the dev branch defaults to
94
+ * `development` and is overridable under `environments.dev.branch`.
95
+ */
54
96
  github: {
55
97
  owner: string;
56
98
  repo: string;
@@ -70,9 +112,80 @@ export interface DeployConfig {
70
112
  artifactsBucket?: string;
71
113
  /** Whether the function verifies the `access` or `id` token. */
72
114
  tokenUse: "access" | "id";
73
- /** Values resolved from the stack after it is created (optional). */
115
+ /** Values resolved from the (prod) stack after it is created (optional). */
116
+ outputs?: DeployOutputs;
117
+ /**
118
+ * Per-environment overrides. A `dev` + `prod` split is the default even with
119
+ * this omitted (see `resolveEnvironment`); this only records what a user
120
+ * changed (a custom dev branch, dev stack outputs, …).
121
+ */
122
+ environments?: Partial<Record<EnvName, EnvOverride>>;
123
+ }
124
+ /** The two deployment environments — dev (development branch) and prod (master). */
125
+ export type EnvName = "prod" | "dev";
126
+ export declare const ENV_NAMES: readonly EnvName[];
127
+ /** The fully-resolved shape of one environment (top-level + defaults + overrides). */
128
+ export interface EnvDef {
129
+ /** Which environment this is. */
130
+ name: EnvName;
131
+ /** Branch whose push deploys it (`master` for prod, `development` for dev). */
132
+ branch: string;
133
+ /** GitHub Actions Environment name (`production` / `development`). */
134
+ ghEnvironment: string;
135
+ /**
136
+ * The app.yaml `AppName` parameter — the infra namespace. Prod uses the app
137
+ * slug; dev uses `${app}dev` so its buckets/roles/pools/SSM never collide with
138
+ * prod's (AppName is Cognito-safe `^[a-z0-9]{1,20}$`, so the suffix is glued,
139
+ * not hyphenated).
140
+ */
141
+ appName: string;
142
+ /** CloudFormation stack name for this environment. */
143
+ stackName: string;
144
+ /** Iceberg namespace this environment writes under. */
145
+ namespace: string;
146
+ /** Upstream the external-API proxy forwards to (empty when no proxy). */
147
+ proxyUpstream: string;
148
+ /** Values resolved from this environment's stack, when known. */
74
149
  outputs?: DeployOutputs;
75
150
  }
151
+ /** Defaults for the dev environment when `environments.dev` is unset. */
152
+ export declare const DEV_DEFAULTS: {
153
+ readonly branch: "development";
154
+ readonly ghEnvironment: "development";
155
+ readonly namespace: "development";
156
+ };
157
+ /** The GitHub Actions Environment name the prod deploy uses. */
158
+ export declare const PROD_GH_ENVIRONMENT = "production";
159
+ /**
160
+ * The app.yaml `AppName` for an environment — the infra namespace. Dev glues a
161
+ * `dev` suffix on so its stack's buckets/roles/pools/SSM are physically distinct
162
+ * from prod's without a hyphen (Cognito `AppName`/`IdentityPoolName` forbid one).
163
+ */
164
+ export declare function envAppName(cfg: DeployConfig, env: EnvName): string;
165
+ /**
166
+ * Resolve one environment to its full definition: the prod environment inherits
167
+ * the top-level branch/stack/namespace/outputs, the dev environment defaults to
168
+ * the `development` branch and a `-dev` stack, and anything under
169
+ * `cfg.environments[env]` overrides those.
170
+ */
171
+ export declare function resolveEnvironment(cfg: DeployConfig, env: EnvName): EnvDef;
172
+ /** Both environments, prod first, fully resolved. */
173
+ export declare function resolveEnvironments(cfg: DeployConfig): EnvDef[];
174
+ /**
175
+ * The browser-facing Cognito config for one environment — what `rastack dev`
176
+ * serves at `/__rastack/cognito.json` so a locally-running app can sign in
177
+ * against the real (dev) Cognito pool via the Hosted UI. Shaped to feed
178
+ * `cognitoRuntimeToConfig` in `rastack/auth`. Returns null until the
179
+ * environment's stack Outputs (client id + Hosted UI url) are known.
180
+ */
181
+ export interface CognitoRuntimeConfig {
182
+ userPoolId?: string;
183
+ clientId: string;
184
+ /** Hosted UI base URL (`https://…amazoncognito.com`). */
185
+ hostedUiUrl: string;
186
+ region: string;
187
+ }
188
+ export declare function cognitoRuntimeConfig(cfg: DeployConfig, env: EnvName): CognitoRuntimeConfig | null;
76
189
  export declare const DEFAULT_LAMBDA: LambdaBuild;
77
190
  /** The out-of-the-box config — everything a fresh repo can default sensibly. */
78
191
  export declare const DEFAULTS: Omit<DeployConfig, "app" | "github">;
@@ -136,6 +249,16 @@ export declare function cognitoAdvancedSecuritySupported(region: string): boolea
136
249
  * the template's ENFORCED default stands).
137
250
  */
138
251
  export declare function stackParameters(cfg: DeployConfig): StackParameter[];
252
+ /**
253
+ * The app.yaml `--parameter-overrides` for a **specific environment** (dev or
254
+ * prod). Same as `stackParameters` but the AppName/branch/namespace come from the
255
+ * resolved environment, and it also passes `GitHubEnvironment` so the stack's
256
+ * deploy-role trust is scoped to the GitHub Actions Environment (the OIDC subject
257
+ * for an environment-gated job is `…:environment:<env>`, not `…:ref:…`). Prod's
258
+ * AppName is the bare slug, so prod's resource names are byte-for-byte what the
259
+ * pre-environment template produced.
260
+ */
261
+ export declare function stackParametersForEnv(cfg: DeployConfig, env: EnvName): StackParameter[];
139
262
  /**
140
263
  * The canonical repo-relative CloudFormation template paths. These are the paths
141
264
  * in the *framework* repo (theserverkid/reactapistack) and the default the argv
@@ -158,6 +281,10 @@ export declare function deployStackArgs(cfg: DeployConfig, templateFile?: string
158
281
  * for you; it is also printed by `--print` so it can be run by hand.
159
282
  */
160
283
  export declare function deployStackCommand(cfg: DeployConfig, templateFile?: string): string;
284
+ /** The `aws cloudformation deploy` argv for one environment's stack (dev/prod). */
285
+ export declare function deployStackArgsForEnv(cfg: DeployConfig, env: EnvName, templateFile?: string): string[];
286
+ /** The single-line `aws cloudformation deploy` command for one environment. */
287
+ export declare function deployStackCommandForEnv(cfg: DeployConfig, env: EnvName, templateFile?: string): string;
161
288
  /**
162
289
  * The tier-0 bootstrap stack (bootstrap.yaml) — the S3 buckets that hold the
163
290
  * Lambda placeholder + hosted templates, and the GitHub OIDC provider. One per
@@ -193,14 +320,28 @@ export interface RepoVariable {
193
320
  name: string;
194
321
  value: string;
195
322
  from: string;
323
+ /**
324
+ * The GitHub Actions **Environment** this Variable is scoped to (`development`
325
+ * / `production`). When set, `init` wires it under that environment so the
326
+ * dev and prod deploys each read their own role/region.
327
+ */
328
+ ghEnvironment?: string;
196
329
  }
197
330
  /**
198
331
  * The GitHub repo **Variables** CI reads — `AWS_DEPLOY_ROLE_ARN` and
199
332
  * `AWS_REGION` (see docs/serverless-deploy.md §3). Values come from the stack
200
333
  * Outputs when already resolved, else an empty string to fill in from the
201
- * Console.
334
+ * Console. This is the legacy single-environment view (the prod stack); the
335
+ * dev/prod split wires them per GitHub Environment via `repoVariablesForEnv`.
202
336
  */
203
337
  export declare function repoVariables(cfg: DeployConfig): RepoVariable[];
338
+ /**
339
+ * The same two Variables, scoped to one environment's GitHub Actions
340
+ * Environment. The dev deploy reads `development`'s copies, the prod deploy
341
+ * `production`'s — so the two never cross-assume each other's role. Values come
342
+ * from the environment's own stack Outputs.
343
+ */
344
+ export declare function repoVariablesForEnv(cfg: DeployConfig, env: EnvName): RepoVariable[];
204
345
  /**
205
346
  * The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
206
347
  * repo. `init` runs this for each resolved `repoVariables` entry so the
@@ -210,7 +351,16 @@ export declare function repoVariables(cfg: DeployConfig): RepoVariable[];
210
351
  * user's GitHub) rather than an API token; a pure argv so it is inspectable and
211
352
  * shell-free (`execFileSync`).
212
353
  */
213
- export declare function ghVariableSetArgs(cfg: DeployConfig, name: string, value: string): string[];
354
+ export declare function ghVariableSetArgs(cfg: DeployConfig, name: string, value: string, ghEnvironment?: string): string[];
355
+ /**
356
+ * The argv (after `gh`) that creates (idempotently) a GitHub Actions
357
+ * **Environment** on the config's repo — `gh api --method PUT
358
+ * repos/{owner}/{repo}/environments/{env}`. `init` runs this for `development`
359
+ * and `production` before wiring their scoped Variables, since a Variable can
360
+ * only be set on an environment that already exists. PUT is idempotent, so
361
+ * re-running `init` is safe.
362
+ */
363
+ export declare function ghEnvironmentCreateArgs(cfg: DeployConfig, ghEnvironment: string): string[];
214
364
  /**
215
365
  * The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
216
366
  * The IaC sets these on the function; surfaced here for `rastack deploy` to
@@ -18,7 +18,11 @@
18
18
  * thin IO wrappers that read/write the file and shell out.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.APP_STACK_OUTPUTS = exports.BOOTSTRAP_TEMPLATES_OUTPUT = exports.BOOTSTRAP_ARTIFACTS_OUTPUT = exports.BOOTSTRAP_TEMPLATE_FILE = exports.APP_TEMPLATE_FILE = exports.REGIONS_WITHOUT_COGNITO_ASF = exports.DEFAULTS = exports.DEFAULT_LAMBDA = void 0;
21
+ exports.APP_STACK_OUTPUTS = exports.BOOTSTRAP_TEMPLATES_OUTPUT = exports.BOOTSTRAP_ARTIFACTS_OUTPUT = exports.BOOTSTRAP_TEMPLATE_FILE = exports.APP_TEMPLATE_FILE = exports.REGIONS_WITHOUT_COGNITO_ASF = exports.DEFAULTS = exports.DEFAULT_LAMBDA = exports.PROD_GH_ENVIRONMENT = exports.DEV_DEFAULTS = exports.ENV_NAMES = void 0;
22
+ exports.envAppName = envAppName;
23
+ exports.resolveEnvironment = resolveEnvironment;
24
+ exports.resolveEnvironments = resolveEnvironments;
25
+ exports.cognitoRuntimeConfig = cognitoRuntimeConfig;
22
26
  exports.normalizeConfig = normalizeConfig;
23
27
  exports.validateConfig = validateConfig;
24
28
  exports.validateBootstrapConfig = validateBootstrapConfig;
@@ -26,15 +30,83 @@ exports.mergeConfig = mergeConfig;
26
30
  exports.parseGitHubRemote = parseGitHubRemote;
27
31
  exports.cognitoAdvancedSecuritySupported = cognitoAdvancedSecuritySupported;
28
32
  exports.stackParameters = stackParameters;
33
+ exports.stackParametersForEnv = stackParametersForEnv;
29
34
  exports.deployStackArgs = deployStackArgs;
30
35
  exports.deployStackCommand = deployStackCommand;
36
+ exports.deployStackArgsForEnv = deployStackArgsForEnv;
37
+ exports.deployStackCommandForEnv = deployStackCommandForEnv;
31
38
  exports.bootstrapStackName = bootstrapStackName;
32
39
  exports.bootstrapStackParameters = bootstrapStackParameters;
33
40
  exports.bootstrapStackArgs = bootstrapStackArgs;
34
41
  exports.bootstrapStackCommand = bootstrapStackCommand;
35
42
  exports.repoVariables = repoVariables;
43
+ exports.repoVariablesForEnv = repoVariablesForEnv;
36
44
  exports.ghVariableSetArgs = ghVariableSetArgs;
45
+ exports.ghEnvironmentCreateArgs = ghEnvironmentCreateArgs;
37
46
  exports.lambdaEnv = lambdaEnv;
47
+ exports.ENV_NAMES = ["prod", "dev"];
48
+ /** Defaults for the dev environment when `environments.dev` is unset. */
49
+ exports.DEV_DEFAULTS = {
50
+ branch: "development",
51
+ ghEnvironment: "development",
52
+ namespace: "development",
53
+ };
54
+ /** The GitHub Actions Environment name the prod deploy uses. */
55
+ exports.PROD_GH_ENVIRONMENT = "production";
56
+ /**
57
+ * The app.yaml `AppName` for an environment — the infra namespace. Dev glues a
58
+ * `dev` suffix on so its stack's buckets/roles/pools/SSM are physically distinct
59
+ * from prod's without a hyphen (Cognito `AppName`/`IdentityPoolName` forbid one).
60
+ */
61
+ function envAppName(cfg, env) {
62
+ return env === "dev" ? `${cfg.app}dev` : cfg.app;
63
+ }
64
+ /**
65
+ * Resolve one environment to its full definition: the prod environment inherits
66
+ * the top-level branch/stack/namespace/outputs, the dev environment defaults to
67
+ * the `development` branch and a `-dev` stack, and anything under
68
+ * `cfg.environments[env]` overrides those.
69
+ */
70
+ function resolveEnvironment(cfg, env) {
71
+ const o = cfg.environments?.[env] ?? {};
72
+ if (env === "prod") {
73
+ return {
74
+ name: "prod",
75
+ branch: o.branch ?? cfg.github.branch,
76
+ ghEnvironment: o.ghEnvironment ?? exports.PROD_GH_ENVIRONMENT,
77
+ appName: cfg.app,
78
+ stackName: o.stackName ?? cfg.stackName,
79
+ namespace: o.namespace ?? cfg.namespace,
80
+ proxyUpstream: o.proxyUpstream ?? "",
81
+ outputs: o.outputs ?? cfg.outputs,
82
+ };
83
+ }
84
+ return {
85
+ name: "dev",
86
+ branch: o.branch ?? exports.DEV_DEFAULTS.branch,
87
+ ghEnvironment: o.ghEnvironment ?? exports.DEV_DEFAULTS.ghEnvironment,
88
+ appName: envAppName(cfg, "dev"),
89
+ stackName: o.stackName ?? (cfg.stackName ? `${cfg.stackName}-dev` : ""),
90
+ namespace: o.namespace ?? exports.DEV_DEFAULTS.namespace,
91
+ proxyUpstream: o.proxyUpstream ?? "",
92
+ outputs: o.outputs,
93
+ };
94
+ }
95
+ /** Both environments, prod first, fully resolved. */
96
+ function resolveEnvironments(cfg) {
97
+ return exports.ENV_NAMES.map((e) => resolveEnvironment(cfg, e));
98
+ }
99
+ function cognitoRuntimeConfig(cfg, env) {
100
+ const o = resolveEnvironment(cfg, env).outputs;
101
+ if (!o?.userPoolClientId || !o?.hostedUiUrl)
102
+ return null;
103
+ return {
104
+ userPoolId: o.userPoolId,
105
+ clientId: o.userPoolClientId,
106
+ hostedUiUrl: o.hostedUiUrl,
107
+ region: cfg.region,
108
+ };
109
+ }
38
110
  exports.DEFAULT_LAMBDA = {
39
111
  bin: "bootstrap",
40
112
  arch: "arm64",
@@ -82,8 +154,36 @@ function normalizeConfig(partial = {}) {
82
154
  artifactsBucket: partial.artifactsBucket?.trim() || undefined,
83
155
  tokenUse: partial.tokenUse ?? exports.DEFAULTS.tokenUse,
84
156
  outputs: partial.outputs ? { ...partial.outputs } : undefined,
157
+ environments: normalizeEnvironments(partial.environments),
85
158
  };
86
159
  }
160
+ /** Copy through per-environment overrides, dropping an empty map to undefined. */
161
+ function normalizeEnvironments(envs) {
162
+ if (!envs)
163
+ return undefined;
164
+ const out = {};
165
+ for (const name of exports.ENV_NAMES) {
166
+ const o = envs[name];
167
+ if (!o)
168
+ continue;
169
+ const entry = {};
170
+ if (o.branch?.trim())
171
+ entry.branch = o.branch.trim();
172
+ if (o.ghEnvironment?.trim())
173
+ entry.ghEnvironment = o.ghEnvironment.trim();
174
+ if (o.stackName?.trim())
175
+ entry.stackName = o.stackName.trim();
176
+ if (o.namespace?.trim())
177
+ entry.namespace = o.namespace.trim();
178
+ if (o.proxyUpstream?.trim())
179
+ entry.proxyUpstream = o.proxyUpstream.trim();
180
+ if (o.outputs)
181
+ entry.outputs = { ...o.outputs };
182
+ if (Object.keys(entry).length)
183
+ out[name] = entry;
184
+ }
185
+ return Object.keys(out).length ? out : undefined;
186
+ }
87
187
  const SLUG = /^[a-z0-9]{1,20}$/;
88
188
  const REGION = /^[a-z]{2}-[a-z]+-\d+$/;
89
189
  const GH_NAME = /^[A-Za-z0-9_.-]+$/;
@@ -99,6 +199,12 @@ function validateConfig(cfg) {
99
199
  errors.push("app is required (a short slug, e.g. `airline`).");
100
200
  else if (!SLUG.test(cfg.app))
101
201
  errors.push(`app "${cfg.app}" must match ${SLUG} (lowercase letters/digits, 1–20).`);
202
+ // The dev environment appends "dev" to the app slug for its infra namespace
203
+ // (buckets/roles/pools/SSM). That derived name must still be a valid slug, so
204
+ // the app slug is effectively capped at 17 chars once a dev env is in play.
205
+ else if (!SLUG.test(envAppName(cfg, "dev")))
206
+ errors.push(`app "${cfg.app}" is too long: the dev environment uses "${envAppName(cfg, "dev")}" ` +
207
+ `for its infra names, which must still match ${SLUG} (so app ≤ 17 chars).`);
102
208
  if (!SLUG.test(cfg.namePrefix))
103
209
  errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
104
210
  if (!cfg.region)
@@ -158,8 +264,27 @@ function mergeConfig(base, patch) {
158
264
  github: { ...base.github, ...patch.github },
159
265
  lambda: { ...base.lambda, ...patch.lambda },
160
266
  outputs: { ...base.outputs, ...patch.outputs },
267
+ environments: mergeEnvironments(base.environments, patch.environments),
161
268
  });
162
269
  }
270
+ /** Deep-merge per-environment overrides (per env, and per env's outputs). */
271
+ function mergeEnvironments(base, patch) {
272
+ if (!base && !patch)
273
+ return undefined;
274
+ const out = {};
275
+ for (const name of exports.ENV_NAMES) {
276
+ const b = base?.[name];
277
+ const p = patch?.[name];
278
+ if (!b && !p)
279
+ continue;
280
+ out[name] = {
281
+ ...b,
282
+ ...p,
283
+ outputs: b?.outputs || p?.outputs ? { ...b?.outputs, ...p?.outputs } : undefined,
284
+ };
285
+ }
286
+ return Object.keys(out).length ? out : undefined;
287
+ }
163
288
  /**
164
289
  * Recover `{ owner, repo }` from a GitHub remote URL — both the SSH
165
290
  * (`git@github.com:owner/repo.git`) and HTTPS
@@ -232,6 +357,36 @@ function stackParameters(cfg) {
232
357
  params.push({ key: "AdvancedSecurity", value: "OFF" });
233
358
  return params;
234
359
  }
360
+ /**
361
+ * The app.yaml `--parameter-overrides` for a **specific environment** (dev or
362
+ * prod). Same as `stackParameters` but the AppName/branch/namespace come from the
363
+ * resolved environment, and it also passes `GitHubEnvironment` so the stack's
364
+ * deploy-role trust is scoped to the GitHub Actions Environment (the OIDC subject
365
+ * for an environment-gated job is `…:environment:<env>`, not `…:ref:…`). Prod's
366
+ * AppName is the bare slug, so prod's resource names are byte-for-byte what the
367
+ * pre-environment template produced.
368
+ */
369
+ function stackParametersForEnv(cfg, env) {
370
+ const e = resolveEnvironment(cfg, env);
371
+ const params = [
372
+ { key: "AppName", value: e.appName },
373
+ { key: "NamePrefix", value: cfg.namePrefix },
374
+ { key: "GitHubOwner", value: cfg.github.owner },
375
+ { key: "GitHubRepo", value: cfg.github.repo },
376
+ { key: "GitHubBranch", value: e.branch },
377
+ { key: "GitHubEnvironment", value: e.ghEnvironment },
378
+ { key: "IcebergNamespace", value: e.namespace },
379
+ ];
380
+ if (cfg.artifactsBucket)
381
+ params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
382
+ // The external-API proxy is provisioned only when an upstream is configured
383
+ // (typically dev → a UAT/prod backend). Empty leaves the route/authorizer out.
384
+ if (e.proxyUpstream)
385
+ params.push({ key: "ExternalApiUpstream", value: e.proxyUpstream });
386
+ if (!cognitoAdvancedSecuritySupported(cfg.region))
387
+ params.push({ key: "AdvancedSecurity", value: "OFF" });
388
+ return params;
389
+ }
235
390
  /**
236
391
  * The canonical repo-relative CloudFormation template paths. These are the paths
237
392
  * in the *framework* repo (theserverkid/reactapistack) and the default the argv
@@ -296,6 +451,14 @@ function deployStackArgs(cfg, templateFile = exports.APP_TEMPLATE_FILE) {
296
451
  function deployStackCommand(cfg, templateFile = exports.APP_TEMPLATE_FILE) {
297
452
  return awsCommand(deployStackArgs(cfg, templateFile));
298
453
  }
454
+ /** The `aws cloudformation deploy` argv for one environment's stack (dev/prod). */
455
+ function deployStackArgsForEnv(cfg, env, templateFile = exports.APP_TEMPLATE_FILE) {
456
+ return cfnDeployArgs(templateFile, resolveEnvironment(cfg, env).stackName, cfg.region, stackParametersForEnv(cfg, env));
457
+ }
458
+ /** The single-line `aws cloudformation deploy` command for one environment. */
459
+ function deployStackCommandForEnv(cfg, env, templateFile = exports.APP_TEMPLATE_FILE) {
460
+ return awsCommand(deployStackArgsForEnv(cfg, env, templateFile));
461
+ }
299
462
  /**
300
463
  * The tier-0 bootstrap stack (bootstrap.yaml) — the S3 buckets that hold the
301
464
  * Lambda placeholder + hosted templates, and the GitHub OIDC provider. One per
@@ -346,12 +509,16 @@ exports.APP_STACK_OUTPUTS = [
346
509
  { key: "UserPoolClientId", field: "userPoolClientId" },
347
510
  { key: "JwksUrl", field: "jwksUrl" },
348
511
  { key: "WarehouseBucketName", field: "warehouseBucket" },
512
+ { key: "UserPoolDomain", field: "userPoolDomain" },
513
+ { key: "HostedUiUrl", field: "hostedUiUrl" },
514
+ { key: "ProxyUrl", field: "proxyUrl" },
349
515
  ];
350
516
  /**
351
517
  * The GitHub repo **Variables** CI reads — `AWS_DEPLOY_ROLE_ARN` and
352
518
  * `AWS_REGION` (see docs/serverless-deploy.md §3). Values come from the stack
353
519
  * Outputs when already resolved, else an empty string to fill in from the
354
- * Console.
520
+ * Console. This is the legacy single-environment view (the prod stack); the
521
+ * dev/prod split wires them per GitHub Environment via `repoVariablesForEnv`.
355
522
  */
356
523
  function repoVariables(cfg) {
357
524
  return [
@@ -363,6 +530,29 @@ function repoVariables(cfg) {
363
530
  { name: "AWS_REGION", value: cfg.region, from: "Region stack output" },
364
531
  ];
365
532
  }
533
+ /**
534
+ * The same two Variables, scoped to one environment's GitHub Actions
535
+ * Environment. The dev deploy reads `development`'s copies, the prod deploy
536
+ * `production`'s — so the two never cross-assume each other's role. Values come
537
+ * from the environment's own stack Outputs.
538
+ */
539
+ function repoVariablesForEnv(cfg, env) {
540
+ const e = resolveEnvironment(cfg, env);
541
+ return [
542
+ {
543
+ name: "AWS_DEPLOY_ROLE_ARN",
544
+ value: e.outputs?.deployRoleArn ?? "",
545
+ from: "DeployRoleArn stack output",
546
+ ghEnvironment: e.ghEnvironment,
547
+ },
548
+ {
549
+ name: "AWS_REGION",
550
+ value: cfg.region,
551
+ from: "Region stack output",
552
+ ghEnvironment: e.ghEnvironment,
553
+ },
554
+ ];
555
+ }
366
556
  /**
367
557
  * The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
368
558
  * repo. `init` runs this for each resolved `repoVariables` entry so the
@@ -372,15 +562,35 @@ function repoVariables(cfg) {
372
562
  * user's GitHub) rather than an API token; a pure argv so it is inspectable and
373
563
  * shell-free (`execFileSync`).
374
564
  */
375
- function ghVariableSetArgs(cfg, name, value) {
376
- return [
565
+ function ghVariableSetArgs(cfg, name, value, ghEnvironment) {
566
+ const args = [
377
567
  "variable",
378
568
  "set",
379
569
  name,
380
570
  "--repo",
381
571
  `${cfg.github.owner}/${cfg.github.repo}`,
382
- "--body",
383
- value,
572
+ ];
573
+ // Scope the Variable to a GitHub Actions Environment when one is given, so the
574
+ // dev and prod deploys each read their own role/region.
575
+ if (ghEnvironment)
576
+ args.push("--env", ghEnvironment);
577
+ args.push("--body", value);
578
+ return args;
579
+ }
580
+ /**
581
+ * The argv (after `gh`) that creates (idempotently) a GitHub Actions
582
+ * **Environment** on the config's repo — `gh api --method PUT
583
+ * repos/{owner}/{repo}/environments/{env}`. `init` runs this for `development`
584
+ * and `production` before wiring their scoped Variables, since a Variable can
585
+ * only be set on an environment that already exists. PUT is idempotent, so
586
+ * re-running `init` is safe.
587
+ */
588
+ function ghEnvironmentCreateArgs(cfg, ghEnvironment) {
589
+ return [
590
+ "api",
591
+ "--method",
592
+ "PUT",
593
+ `repos/${cfg.github.owner}/${cfg.github.repo}/environments/${ghEnvironment}`,
384
594
  ];
385
595
  }
386
596
  /**
@@ -75,6 +75,13 @@ export declare function ghAuthLogin(cwd?: string): boolean;
75
75
  * into a command line.
76
76
  */
77
77
  export declare function setRepoVariable(args: string[], cwd?: string): boolean;
78
+ /**
79
+ * Create (idempotently, via `gh api --method PUT`) a GitHub Actions Environment
80
+ * so its scoped Variables can be set. Returns true on success. Best-effort — a
81
+ * missing/unauthenticated `gh` or an API error degrades to the printed
82
+ * instructions, exactly like `setRepoVariable`.
83
+ */
84
+ export declare function ghEnvironmentCreate(args: string[], cwd?: string): boolean;
78
85
  /**
79
86
  * Ask a single question with a default, echoed as `label [default]: `. Returns
80
87
  * the trimmed answer, or the default on an empty line.