canopycms-cdk 0.0.67-int.89 → 0.0.67-int.90

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.
@@ -34,11 +34,18 @@ export interface CanopyCmsServiceProps {
34
34
  /** Lambda reserved concurrency cap (default: 10) */
35
35
  reservedConcurrency?: number;
36
36
  /**
37
- * Lambda architecture (default: `Architecture.X86_64`, Lambda's own
38
- * default). MUST match the platform the Docker image was built for - e.g.
39
- * an image built for `Platform.LINUX_ARM64` requires
40
- * `Architecture.ARM_64` here, or the function fails at invoke time with
41
- * an exec format error.
37
+ * Lambda architecture (default: `Architecture.ARM_64`, matching the EC2
38
+ * worker and AssetSupport's transform Lambda).
39
+ *
40
+ * This also decides the image's architecture for
41
+ * `DockerImageCode.fromImageAsset`: the construct always passes a resolved
42
+ * architecture to the function, and CDK derives the Docker build platform
43
+ * from it. So omit `platform` on `fromImageAsset`. An explicit `platform`
44
+ * overrides the derived one, and an image built for the other architecture
45
+ * cannot run on the function: an arm64 image on an x86_64 function fails at
46
+ * invoke with `Runtime.InvalidEntrypoint` (see "Where the image is built" in
47
+ * docs/deploying-to-aws.md). A prebuilt image (`DockerImageCode.fromEcr`) has
48
+ * no build for CDK to steer, so it must already be built for this architecture.
42
49
  */
43
50
  architecture?: lambda.Architecture;
44
51
  /** EC2 spot max price (default: on-demand rate for t4g.nano) */
@@ -70,8 +77,114 @@ export interface CanopyCmsServiceProps {
70
77
  githubRepo: string;
71
78
  /** Secrets Manager ARN for the GitHub bot token */
72
79
  githubTokenSecretArn?: string;
80
+ /**
81
+ * The key within a JSON secret document at `githubTokenSecretArn`; omit when
82
+ * the secret's whole value is the credential.
83
+ *
84
+ * Stamped into the worker's `CANOPYCMS_GITHUB_TOKEN_SECRET_JSON_FIELD`, which
85
+ * `getSecret` (packages/canopycms-cdk/worker/secrets.ts) reads to pull one
86
+ * field out of the document instead of using the whole string. Omitting it is
87
+ * the default and the common case — a secret holding a bare `ghp_…` needs
88
+ * nothing here.
89
+ *
90
+ * This is NOT the ECS/CloudFormation `arn:…:secret:name-AbCdEf:KEY::`
91
+ * convention. The worker calls the `GetSecretValue` API, which does not parse
92
+ * that suffix; a literal ARN carrying one is refused at synth (see
93
+ * `assertSecretArnHasNoFieldSuffix` below, and the known limit recorded on
94
+ * `SECRET_ARN_WITH_EMPTY_VERSION_TAIL` for the one token spelling that gets
95
+ * through) and the field belongs here instead.
96
+ */
97
+ githubTokenSecretJsonField?: string;
98
+ /**
99
+ * GitHub App ID, to authenticate the worker as a GitHub App installation
100
+ * instead of as a personal access token.
101
+ *
102
+ * **The token is the default and stays first-class.** Registering a GitHub
103
+ * App under an organisation takes an owner of that organisation (or a GitHub
104
+ * App manager for all its Apps), which many adopters are not, so this is the
105
+ * "if your organisation requires it" option, not a direction of travel.
106
+ * Nothing about
107
+ * `githubTokenSecretArn` is deprecated or warned about.
108
+ *
109
+ * All three App props (`githubAppId`, `githubAppInstallationId`,
110
+ * `githubAppPrivateKeySecretArn`) are set together or not at all, and App
111
+ * auth is mutually exclusive with `githubTokenSecretArn` — both are refused
112
+ * at synth. That mirrors `resolveWorkerGitHubAuth` in core
113
+ * (packages/canopycms/src/worker/github-auth.ts), which refuses the same two
114
+ * shapes at boot; checking here turns a 5-second systemd restart loop into a
115
+ * failed `cdk synth`.
116
+ *
117
+ * Stamped into the worker's `CANOPYCMS_GITHUB_APP_ID`.
118
+ */
119
+ githubAppId?: string;
120
+ /**
121
+ * The App's installation ID on your repository — NOT the App ID above.
122
+ *
123
+ * An App can be installed on several accounts, and a token is minted per
124
+ * installation, so both numbers are needed. It is the trailing number in the
125
+ * URL of the App's install page under your organisation's settings.
126
+ *
127
+ * Stamped into the worker's `CANOPYCMS_GITHUB_APP_INSTALLATION_ID`.
128
+ */
129
+ githubAppInstallationId?: string;
130
+ /**
131
+ * Secrets Manager ARN for the App's PEM private key.
132
+ *
133
+ * **ARN-only: there is deliberately no plaintext prop for this key**, unlike
134
+ * every other credential the construct knows about, and the reason is
135
+ * mechanical rather than a matter of taste. Every value the construct puts in
136
+ * the worker's environment is written into a `.env` file that systemd reads
137
+ * as `EnvironmentFile=`, where a newline starts a new variable — so
138
+ * `assertEnvSafe` refuses one, and a PEM is inherently multi-line. A
139
+ * plaintext key could not be delivered to the worker intact by this path at
140
+ * all. Passing the PEM itself here is caught by name at synth (see
141
+ * `assertNotInlinePrivateKey`, and the note there about the generated stack
142
+ * reaching CDK's own ARN complaint first) rather than surfacing as a puzzling
143
+ * "an ARN must not contain a newline".
144
+ *
145
+ * The ARN is unioned into the worker's IAM policy alongside the other secret
146
+ * ARN props; you do not need to repeat it in `secretsArns`.
147
+ */
148
+ githubAppPrivateKeySecretArn?: string;
149
+ /**
150
+ * The key within a JSON secret document at `githubAppPrivateKeySecretArn`;
151
+ * omit when the secret's whole value is the PEM.
152
+ *
153
+ * Stamped into the worker's
154
+ * `CANOPYCMS_GITHUB_APP_PRIVATE_KEY_SECRET_JSON_FIELD`. See
155
+ * `githubTokenSecretJsonField` above — same mechanism, same non-relationship
156
+ * to the ECS `:KEY::` ARN suffix. This is the case that motivated the JSON
157
+ * field support in the first place: an App private key is exactly the kind of
158
+ * material an organisation keeps inside one credential document per
159
+ * environment.
160
+ *
161
+ * A PEM stored as a JSON string value carries its newlines as `\n` escapes,
162
+ * which `JSON.parse` turns back into real newlines — and the worker
163
+ * additionally runs whatever it reads through
164
+ * `normalizeGitHubAppPrivateKey`, which unescapes and base64-unwraps, so a
165
+ * key mangled by a single-line config field still works.
166
+ */
167
+ githubAppPrivateKeySecretJsonField?: string;
73
168
  /** Secrets Manager ARN for the Clerk secret key */
74
169
  clerkSecretKeySecretArn?: string;
170
+ /**
171
+ * The key within a JSON secret document at `clerkSecretKeySecretArn`; omit
172
+ * when the secret's whole value is the credential.
173
+ *
174
+ * Stamped into the worker's `CLERK_SECRET_KEY_SECRET_JSON_FIELD`. See
175
+ * `githubTokenSecretJsonField` above — same mechanism, same non-relationship
176
+ * to the ECS `:KEY::` ARN suffix.
177
+ *
178
+ * Note that this covers `CLERK_SECRET_KEY` only. A Clerk JSON document
179
+ * typically also holds `CLERK_JWT_KEY` and the publishable key, and NEITHER
180
+ * can be sourced from Secrets Manager at all: `CLERK_JWT_KEY` reaches the
181
+ * Lambda as a plain value through `environment`, and the publishable key is a
182
+ * Docker build arg inlined into the client bundle. Both are public material
183
+ * (docs/deploying-to-aws.md, "Security Model"), so that is by design rather
184
+ * than an omission — but it means pointing this prop at your document does
185
+ * not relieve you of supplying those two separately.
186
+ */
187
+ clerkSecretKeySecretJsonField?: string;
75
188
  /**
76
189
  * The GitHub repository's default branch name (default: 'main').
77
190
  *
@@ -227,10 +340,10 @@ export interface CanopyCmsServiceProps {
227
340
  * - Lambda function (Docker image, EFS mount, private subnet, no internet)
228
341
  * - Lambda Function URL (for CloudFront origin)
229
342
  * - EC2 Worker (t4g.nano spot in ASG, public subnet, EFS mount, systemd) -
230
- * rolled on every deploy via the ASG's UpdatePolicy, so a changed worker
231
- * bundle actually reaches the instance instead of sitting unused in a
232
- * launch template until the next spot interruption (see the UpdatePolicy
233
- * below)
343
+ * rolled via the ASG's UpdatePolicy by every deploy that changes its launch
344
+ * template, so a changed worker bundle reaches the instance instead of
345
+ * sitting unused in a launch template until the next spot interruption (see
346
+ * the UpdatePolicy below)
234
347
  * - Dedicated CloudWatch log groups for the CMS Lambda and the worker's
235
348
  * stdout/stderr (the worker's is shipped via the amazon-cloudwatch-agent -
236
349
  * journald is not agent-readable), each with a custom name/retention/
@@ -1,7 +1,7 @@
1
1
  import * as path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { Construct } from 'constructs';
4
- import { Duration, RemovalPolicy, Stack, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3_assets as s3assets, aws_logs as logs, } from 'aws-cdk-lib';
4
+ import { Duration, RemovalPolicy, Stack, Token, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3_assets as s3assets, aws_logs as logs, } from 'aws-cdk-lib';
5
5
  import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
6
6
  // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
7
7
  // output is real ESM - `__dirname` is not a global there. Found while
@@ -97,6 +97,27 @@ function assertEnvSafe(name, value) {
97
97
  `(got ${JSON.stringify(value)}). It is written into the worker's .env file with a ` +
98
98
  `<< '${ENV_HEREDOC_DELIMITER}' heredoc, which that value would terminate early.`);
99
99
  }
100
+ // The same systemd `EnvironmentFile=` parser the leading-quote rule above is
101
+ // about also treats a backslash as an escape: `a\b` arrives as `ab`, and a
102
+ // value ENDING in a backslash continues onto the next line, swallowing the
103
+ // .env entry that follows it. That is the quote hazard again in a quieter
104
+ // form -- it corrupts a neighbouring variable rather than the one it appears
105
+ // in -- so it is refused here rather than debugged on an instance.
106
+ if (value.includes('\\')) {
107
+ throw new Error(`CanopyCmsService: ${name} must not contain a backslash (got ${JSON.stringify(value)}). ` +
108
+ `It is written into the worker's .env file, which systemd reads as EnvironmentFile -- ` +
109
+ `there a backslash escapes the next character, and a trailing one continues the value ` +
110
+ `onto the following line, consuming the next variable entirely.`);
111
+ }
112
+ // Leading/trailing whitespace is stripped by that same parser, so a value
113
+ // that is only whitespace reaches the worker as an empty string and every
114
+ // caller downstream treats it as unset -- the silent-discard case, arriving
115
+ // by a route no charset check upstream can see.
116
+ if (value !== value.trim()) {
117
+ throw new Error(`CanopyCmsService: ${name} must not start or end with whitespace ` +
118
+ `(got ${JSON.stringify(value)}). systemd strips it when reading the worker's .env, so ` +
119
+ `the value the worker sees would differ from the one configured here.`);
120
+ }
100
121
  return value;
101
122
  }
102
123
  /**
@@ -174,6 +195,298 @@ function assertValidGitBranchName(propName, value) {
174
195
  }
175
196
  return value;
176
197
  }
198
+ /**
199
+ * A Secrets Manager ARN carrying the ECS/CloudFormation JSON-field suffix,
200
+ * i.e. `arn:…:secret:name-AbCdEf:MY_KEY::` rather than `arn:…:secret:name-AbCdEf`.
201
+ *
202
+ * Keyed on "a colon anywhere after `:secret:`", because a secret NAME cannot
203
+ * contain one: Secrets Manager's documented name charset is ASCII letters,
204
+ * digits and `/_+=.@-`. So everything after `:secret:` in a secret ARN is the
205
+ * name -- plus the six random characters AWS appends, when the ARN is a
206
+ * complete one rather than the partial form this guard deliberately accepts --
207
+ * and a further colon can only begin the `:json-key:version-stage:version-id`
208
+ * tail.
209
+ *
210
+ * An earlier version of this anchored on the six-character suffix itself
211
+ * (`-[A-Za-z0-9]{6}:`) and therefore missed the suffix form built on a
212
+ * name-only ARN -- `arn:…:secret:gh:MY_KEY::`, which is the shape ECS's own
213
+ * documentation shows. That ARN was accepted, stamped, and written into the IAM
214
+ * policy, producing exactly the AccessDenied restart-loop this guard exists to
215
+ * prevent.
216
+ */
217
+ const SECRET_ARN_WITH_FIELD_SUFFIX = /:secret:[^:]*:/;
218
+ /**
219
+ * The last two characters of the `…:json-key::` spelling -- the ECS suffix with
220
+ * `version-stage` and `version-id` left empty, which is how the convention is
221
+ * almost always written and how AWS's own examples show it.
222
+ *
223
+ * Checked in ADDITION to the regex above, for one case the regex cannot see: an
224
+ * unresolved CDK token, `${Token[TOKEN.42]}:MY_KEY::`, carries no `:secret:` to
225
+ * anchor on because the ARN has not been rendered yet. No well-formed secret
226
+ * ARN, token or literal, ends in two colons, so this is safe to refuse.
227
+ *
228
+ * KNOWN LIMIT, stated rather than fixed: a token ARN with NON-empty version
229
+ * parts (`${Token[…]}:MY_KEY:AWSCURRENT:v1`) is caught by neither check and is
230
+ * stamped verbatim. Recognising it needs `Token.isUnresolved` plus a guess at
231
+ * where the token ends; the spelling adopters actually copy has the empty
232
+ * parts, and every LITERAL ARN is caught by the regex whatever its version
233
+ * parts say.
234
+ */
235
+ const SECRET_ARN_WITH_EMPTY_VERSION_TAIL = '::';
236
+ /**
237
+ * Guards one (secret ARN, JSON field) prop pair at synth.
238
+ *
239
+ * Both checks exist because the failure they replace is SILENT, and both
240
+ * failures land on the worker at boot -- where systemd's `Restart=always` turns
241
+ * a misconfiguration into an indefinite 5-second restart loop rather than
242
+ * anything `cdk deploy` reports.
243
+ *
244
+ * 1. A JSON-field prop with no ARN prop. The field env var is stamped, the ARN
245
+ * is not, and the credential is then read from nowhere: for Clerk that
246
+ * leaves `refreshAuthCache` undefined, which disables auth-cache refresh
247
+ * with NO log line at all; for GitHub the worker reports "CANOPYCMS_GITHUB_TOKEN
248
+ * or CANOPYCMS_GITHUB_TOKEN_SECRET_ARN is required" while the adopter is
249
+ * looking at a stack that plainly configures a GitHub secret.
250
+ *
251
+ * 2. An ARN carrying the ECS `:KEY::` suffix. That form is a
252
+ * CloudFormation-dynamic-reference and ECS `secrets.valueFrom` convention;
253
+ * the `GetSecretValue` API this worker calls does not parse it. Through the
254
+ * scaffolded stack the adopter gets CDK's own cryptic complaint
255
+ * ("does not appear to be complete; missing 6-character suffix" --
256
+ * `Secret.fromSecretCompleteArn` gates on `/-[a-z0-9]{6}$/i`); hand-rolling
257
+ * the stack, the string lands verbatim in the worker's IAM `Resource`, where
258
+ * it can never match the real secret, and the worker gets AccessDenied.
259
+ * Naming the JSON-field prop in the message is the whole point of the check:
260
+ * the adopter's intent is supported, just spelled differently here.
261
+ *
262
+ * An empty JSON field is rejected for the same reason `settingsBranch: ''` is:
263
+ * the worker reads a blank env var as "not configured" (`|| undefined` at both
264
+ * call sites in worker/index.ts), so stamping it would discard an explicitly
265
+ * set prop without a word.
266
+ */
267
+ function assertSecretPropPair(arnPropName, arn, jsonFieldPropName, jsonField) {
268
+ if (jsonField !== undefined) {
269
+ // `.trim()`, not `=== ''`: systemd's EnvironmentFile parser strips leading
270
+ // and trailing whitespace from a value, so `" "` reaches the worker as `""`
271
+ // and takes the same silently-ignored path an empty string would. The
272
+ // untrimmed check let exactly the case it was written for through.
273
+ if (jsonField.trim() === '') {
274
+ throw new Error(`CanopyCmsService: ${jsonFieldPropName} must name a key, but it is ` +
275
+ `${JSON.stringify(jsonField)}. The worker reads a blank value as "no field configured" ` +
276
+ `and falls back to using the secret's whole value, silently ignoring this prop -- name ` +
277
+ `the key you want, or omit the prop entirely.`);
278
+ }
279
+ if (!arn) {
280
+ throw new Error(`CanopyCmsService: ${jsonFieldPropName} is set but ${arnPropName} is not. ` +
281
+ `The JSON field names a key INSIDE a secret, so it does nothing without the secret's ` +
282
+ `ARN -- the worker would be told which key to read and never told where to read it ` +
283
+ `from. Set ${arnPropName}, or drop ${jsonFieldPropName}.`);
284
+ }
285
+ }
286
+ if (arn !== undefined) {
287
+ assertSecretArnHasNoFieldSuffix(arnPropName, arn, jsonFieldPropName);
288
+ }
289
+ }
290
+ /**
291
+ * Rejects the ECS `:KEY::` ARN suffix on any prop that carries a secret ARN.
292
+ *
293
+ * Separate from `assertSecretPropPair` because `secretsArns` has no JSON-field
294
+ * prop of its own and still needs the check: its values go verbatim into the
295
+ * worker's IAM policy, where a suffixed ARN is an unmatchable `Resource` and
296
+ * produces exactly the AccessDenied restart-loop described above.
297
+ */
298
+ function assertSecretArnHasNoFieldSuffix(propName, arn, jsonFieldPropName) {
299
+ // `unknown`, and narrowed here, because the types are not the whole story:
300
+ // `secretsArns: [process.env.EXTRA_SECRET_ARN!]` is the idiom a CDK app that
301
+ // reads its config from the environment reaches for -- the scaffolded
302
+ // `bin/app.ts` does exactly that everywhere else -- and `!` turns an unset
303
+ // variable into `undefined` with the compiler none the wiser.
304
+ //
305
+ // Throwing is a deliberate change from what that used to do. The entry was
306
+ // silently dropped by the `typeof arn === 'string'` filter on the IAM union
307
+ // below, so the worker was told to read a secret it had no grant for and got
308
+ // AccessDenied at boot -- the failure that filter's own comment is about.
309
+ if (typeof arn !== 'string' || arn.length === 0) {
310
+ throw new Error(`CanopyCmsService: ${propName} must be a non-empty secret ARN string, but it is ` +
311
+ `${JSON.stringify(arn) ?? String(arn)}. An unset environment variable asserted with '!' ` +
312
+ `arrives here as undefined; it would otherwise be dropped from the worker's IAM policy ` +
313
+ `in silence, leaving a worker that knows which secret to read and cannot read it.`);
314
+ }
315
+ if (!SECRET_ARN_WITH_FIELD_SUFFIX.test(arn) && !arn.endsWith(SECRET_ARN_WITH_EMPTY_VERSION_TAIL))
316
+ return;
317
+ const alternative = jsonFieldPropName
318
+ ? `Pass the plain secret ARN (everything up to and including the six-character suffix) and ` +
319
+ `name the key with ${jsonFieldPropName} instead.`
320
+ : `Pass the plain secret ARN, ending at the six-character suffix.`;
321
+ throw new Error(`CanopyCmsService: ${propName} ${JSON.stringify(arn)} carries a ':KEY::' JSON-field suffix. ` +
322
+ `That is the ECS / CloudFormation dynamic-reference convention; the worker reads secrets ` +
323
+ `with the GetSecretValue API, which does not parse it -- the suffixed string would be ` +
324
+ `written into the worker's IAM policy, where it can never match the real secret, and the ` +
325
+ `worker would fail with AccessDenied at boot. ${alternative}`);
326
+ }
327
+ /** The three props that together configure GitHub App authentication. */
328
+ const GITHUB_APP_PROP_NAMES = [
329
+ 'githubAppId',
330
+ 'githubAppInstallationId',
331
+ 'githubAppPrivateKeySecretArn',
332
+ ];
333
+ /**
334
+ * The props that mean "this deployment authenticates with a personal access
335
+ * token". The JSON field belongs here as well as the ARN: on its own it cannot
336
+ * authenticate anything, but its PRESENCE still says which credential the
337
+ * adopter thinks they are configuring, which is what the exclusivity rule needs
338
+ * to know.
339
+ */
340
+ const GITHUB_TOKEN_PROP_NAMES = ['githubTokenSecretArn', 'githubTokenSecretJsonField'];
341
+ /**
342
+ * Rejects a PEM private key passed where a prop expects an identifier or an ARN.
343
+ *
344
+ * There is no plaintext private-key prop, and there cannot be one: the value
345
+ * would be written into the worker's `.env`, which systemd reads as
346
+ * `EnvironmentFile=` where a newline begins a new variable. So the realistic
347
+ * mistake is to paste the key into `githubAppPrivateKeySecretArn` -- the prop
348
+ * whose name contains "PrivateKey" -- instead of the ARN of a secret holding
349
+ * it.
350
+ *
351
+ * Without this, that lands on `assertEnvSafe`'s generic rule and reports "must
352
+ * not contain a newline" about an ARN, which explains the mechanism and not the
353
+ * mistake. Checked on each of `GITHUB_APP_PROP_NAMES` because the same
354
+ * misunderstanding puts the key in any of them, and each would otherwise
355
+ * produce a differently confusing message.
356
+ * `githubAppPrivateKeySecretJsonField` is deliberately not checked: a JSON key
357
+ * NAME is not somewhere anyone mistakes a PEM for, and it keeps this aligned
358
+ * with the one list that defines what "the App props" are.
359
+ *
360
+ * **Reached only by a hand-written stack.** The generated
361
+ * `infrastructure/lib/cms-stack.ts` resolves the ARN with
362
+ * `Secret.fromSecretCompleteArn` BEFORE it constructs `CanopyCmsService`, so a
363
+ * scaffolded adopter gets CDK's own complaint ("does not appear to be complete;
364
+ * missing 6-character suffix") first. That asymmetry is the same one
365
+ * `assertSecretPropPair` documents for the token ARN, and is why this guard is
366
+ * worth having rather than redundant: the hand-written path has nothing else.
367
+ *
368
+ * A one-line key (a base64-wrapped PEM, say) is NOT caught here, and cannot be:
369
+ * it is indistinguishable from a malformed ARN at synth. It fails at boot in
370
+ * `normalizeGitHubAppPrivateKey`, or -- for the ARN prop -- as a Secrets
371
+ * Manager error naming the string it tried to fetch.
372
+ */
373
+ function assertNotInlinePrivateKey(propName, value) {
374
+ if (value === undefined || !value.includes('-----BEGIN'))
375
+ return;
376
+ throw new Error(`CanopyCmsService: ${propName} looks like a PEM private key, not ${propName === 'githubAppPrivateKeySecretArn' ? 'a secret ARN' : 'an identifier'}. ` +
377
+ `The GitHub App private key can ONLY be supplied as a Secrets Manager ARN -- it is ` +
378
+ `multi-line, and every value this construct configures goes into the worker's .env file, ` +
379
+ `which systemd reads as EnvironmentFile where a newline starts a new variable. Store the ` +
380
+ `PEM in Secrets Manager and pass that secret's full ARN as githubAppPrivateKeySecretArn ` +
381
+ `(optionally with githubAppPrivateKeySecretJsonField if it lives inside a JSON document).`);
382
+ }
383
+ /**
384
+ * Rejects a GitHub App identifier that is not a whole number.
385
+ *
386
+ * Both identifiers are numeric, and the two wrong values an adopter reaches for
387
+ * are the App's *slug* and its `Iv1.…` OAuth client id — both are on the same
388
+ * settings page as the number, and neither works.
389
+ *
390
+ * The two fail differently at boot, and both are worse than failing here.
391
+ * `createAppAuth` refuses a non-numeric `appId` at construction (measured
392
+ * against `@octokit/auth-app@6.1.4`: `Number.isFinite(+options.appId)`), so the
393
+ * app id at least produces a named error. The installation id is checked only
394
+ * for falsiness there, so a non-numeric one is interpolated into
395
+ * `/app/installations/NaN/access_tokens` and comes back as a 404 that reads as
396
+ * "the app is not installed" — sending the operator to re-install a perfectly
397
+ * good App.
398
+ *
399
+ * Stricter than `createAppAuth`'s own `+value` coercion, deliberately: that
400
+ * accepts `' 12 '`, `12.5` and `0x1f`. None of them is an id.
401
+ *
402
+ * Two values pass through untouched, and both would otherwise be reported as the
403
+ * wrong problem:
404
+ *
405
+ * - **Empty**, which is what `process.env.GITHUB_APP_ID ?? ''` and an Actions
406
+ * `vars.` reference to a variable nobody created both produce. That is not a
407
+ * malformed id, it is an ABSENT one, and `assertGitHubAuthProps` says so by
408
+ * name. Reporting `must be the numeric id (got "")` instead would send the
409
+ * adopter to correct a value they never set.
410
+ * - **An unresolved CDK token**, e.g.
411
+ * `ssm.StringParameter.valueForStringParameter(…)` or `Fn.importValue(…)`,
412
+ * whose value does not exist until deploy. Refusing it would make a legitimate
413
+ * configuration unrepresentable; it is unverifiable here either way, so it
414
+ * goes through and fails at the worker if it is wrong. `githubAppPrivateKeySecretArn`
415
+ * already accepts a token (one trips neither the `:secret:X:` regex nor
416
+ * `assertEnvSafe`), so this keeps the App props consistent with each other.
417
+ */
418
+ function assertNumericId(propName, value) {
419
+ if (value === undefined || value === '' || /^\d+$/.test(value))
420
+ return;
421
+ if (Token.isUnresolved(value))
422
+ return;
423
+ throw new Error(`CanopyCmsService: ${propName} must be the numeric id GitHub shows for the app ` +
424
+ `(got ${JSON.stringify(value)}). The app's slug and its 'Iv1.…' client id both appear on ` +
425
+ `the same settings page and neither works here — githubAppId is the number labelled ` +
426
+ `"App ID", and githubAppInstallationId is the trailing number in the URL of the app's ` +
427
+ `install page under your organisation's settings.`);
428
+ }
429
+ /**
430
+ * Guards the GitHub credential props at synth: exactly one shape, fully given.
431
+ *
432
+ * Only the SECOND rule restates `resolveWorkerGitHubAuth`
433
+ * (packages/canopycms/src/worker/github-auth.ts:237-248), which refuses both
434
+ * credentials and refuses neither. The first has no counterpart there and could
435
+ * not: core takes one already-built `githubAppAuth` object, so a partial set of
436
+ * three props is not representable by the time it sees anything. Restating the
437
+ * second rather than leaving it to core is the point: a worker that throws at
438
+ * boot is restarted by systemd every 5 seconds indefinitely while `cdk deploy`
439
+ * reports success, so a rule that only exists at boot is a rule the adopter
440
+ * discovers from CloudWatch. The core check stays because core is reachable
441
+ * without this construct.
442
+ *
443
+ * 1. **All three App props or none.** Two of the three is not a partial
444
+ * configuration that could still work: `createAppAuth` needs the App ID, the
445
+ * installation ID and the key, and the message names the missing ones rather
446
+ * than saying the set is incomplete.
447
+ * 2. **Not both an App and a token.** Rejected rather than resolved by
448
+ * precedence, because it would otherwise be undefined which identity a push
449
+ * or a pull request acts as -- and a PR opened by the wrong identity is not
450
+ * something an adopter notices quickly.
451
+ *
452
+ * Configuring NEITHER is deliberately not an error here. The worker also reads
453
+ * `CANOPYCMS_GITHUB_TOKEN` directly from its environment, which an adopter can
454
+ * supply outside this construct, and core refuses the genuinely empty case at
455
+ * boot with a message naming both options.
456
+ */
457
+ function assertGitHubAuthProps(props) {
458
+ for (const name of GITHUB_APP_PROP_NAMES) {
459
+ assertNotInlinePrivateKey(name, props[name]);
460
+ }
461
+ assertNumericId('githubAppId', props.githubAppId);
462
+ assertNumericId('githubAppInstallationId', props.githubAppInstallationId);
463
+ const missing = GITHUB_APP_PROP_NAMES.filter((name) => !props[name]);
464
+ const provided = GITHUB_APP_PROP_NAMES.filter((name) => props[name]);
465
+ if (provided.length > 0 && missing.length > 0) {
466
+ throw new Error(`CanopyCmsService: GitHub App authentication needs all of ` +
467
+ `${GITHUB_APP_PROP_NAMES.join(', ')}, but ${missing.join(' and ')} ` +
468
+ `${missing.length === 1 ? 'is' : 'are'} not set (${provided.join(' and ')} ` +
469
+ `${provided.length === 1 ? 'is' : 'are'}). An App's installation token is minted from ` +
470
+ `all three together, so a partial set cannot authenticate at all -- supply the rest, or ` +
471
+ `drop them and use githubTokenSecretArn.`);
472
+ }
473
+ // The JSON field counts as "a token is configured", not just the ARN. An
474
+ // adopter following docs/adopter-migration.md removes `githubTokenSecretArn`
475
+ // and overlooks `githubTokenSecretJsonField`; left out of this check, that
476
+ // lands on `assertSecretPropPair` instead, which answers "Set
477
+ // githubTokenSecretArn, or drop githubTokenSecretJsonField" -- pointing them
478
+ // back at the credential they were just told to delete, and at a
479
+ // configuration this rule would then refuse anyway.
480
+ const tokenPropsSet = GITHUB_TOKEN_PROP_NAMES.filter((name) => props[name]);
481
+ if (provided.length > 0 && tokenPropsSet.length > 0) {
482
+ throw new Error(`CanopyCmsService: configure either the githubToken* props or the githubApp* props, not ` +
483
+ `both (${tokenPropsSet.join(' and ')} ${tokenPropsSet.length === 1 ? 'is' : 'are'} set ` +
484
+ `alongside ${provided.join(' and ')}). Two credentials would leave it undefined which ` +
485
+ `identity the worker's pushes and pull requests act as. A personal access token is the ` +
486
+ `default and needs no App props; GitHub App auth replaces it, so drop ` +
487
+ `${tokenPropsSet.join(' and ')} when you adopt it.`);
488
+ }
489
+ }
177
490
  /**
178
491
  * Default CMS Lambda timeout.
179
492
  *
@@ -204,10 +517,10 @@ export const MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT = Duration.seconds(60);
204
517
  * - Lambda function (Docker image, EFS mount, private subnet, no internet)
205
518
  * - Lambda Function URL (for CloudFront origin)
206
519
  * - EC2 Worker (t4g.nano spot in ASG, public subnet, EFS mount, systemd) -
207
- * rolled on every deploy via the ASG's UpdatePolicy, so a changed worker
208
- * bundle actually reaches the instance instead of sitting unused in a
209
- * launch template until the next spot interruption (see the UpdatePolicy
210
- * below)
520
+ * rolled via the ASG's UpdatePolicy by every deploy that changes its launch
521
+ * template, so a changed worker bundle reaches the instance instead of
522
+ * sitting unused in a launch template until the next spot interruption (see
523
+ * the UpdatePolicy below)
211
524
  * - Dedicated CloudWatch log groups for the CMS Lambda and the worker's
212
525
  * stdout/stderr (the worker's is shipped via the amazon-cloudwatch-agent -
213
526
  * journald is not agent-readable), each with a custom name/retention/
@@ -273,14 +586,45 @@ export class CanopyCmsService extends Construct {
273
586
  ? assertValidGitBranchName('settingsBranch', props.settingsBranch)
274
587
  : undefined;
275
588
  // ------------------------------------------------------------------
589
+ // Secret ARNs and their JSON fields
590
+ // ------------------------------------------------------------------
591
+ //
592
+ // Checked here, at the top, so a misconfigured pair fails `cdk synth`
593
+ // rather than `cdk deploy`-then-restart-loop. See `assertSecretPropPair`
594
+ // for what each of the two checks costs when it is absent.
595
+ //
596
+ // WHICH CREDENTIAL first, then whether each is well formed. The order is
597
+ // load-bearing and the reverse produces self-contradictory advice: an
598
+ // adopter following docs/adopter-migration.md removes `githubTokenSecretArn`
599
+ // and overlooks `githubTokenSecretJsonField`, and the pair check answers
600
+ // "Set githubTokenSecretArn, or drop githubTokenSecretJsonField" -- pointing
601
+ // them straight back at the credential they were just told to delete, and at
602
+ // a configuration the exclusivity rule below would then refuse anyway.
603
+ // `assertGitHubAuthProps` is silent when no App prop is set, so this costs
604
+ // the token-only path nothing.
605
+ assertGitHubAuthProps(props);
606
+ assertSecretPropPair('githubTokenSecretArn', props.githubTokenSecretArn, 'githubTokenSecretJsonField', props.githubTokenSecretJsonField);
607
+ assertSecretPropPair('clerkSecretKeySecretArn', props.clerkSecretKeySecretArn, 'clerkSecretKeySecretJsonField', props.clerkSecretKeySecretJsonField);
608
+ assertSecretPropPair('githubAppPrivateKeySecretArn', props.githubAppPrivateKeySecretArn, 'githubAppPrivateKeySecretJsonField', props.githubAppPrivateKeySecretJsonField);
609
+ // `secretsArns` gets the suffix half of the same guard: it has no
610
+ // JSON-field prop, but its entries are written verbatim into the worker's
611
+ // IAM policy below, so a suffixed ARN fails there in precisely the way the
612
+ // policy's own comment describes.
613
+ for (const [index, arn] of (props.secretsArns ?? []).entries()) {
614
+ assertSecretArnHasNoFieldSuffix(`secretsArns[${index}]`, arn);
615
+ }
616
+ // ------------------------------------------------------------------
276
617
  // Operating mode
277
618
  // ------------------------------------------------------------------
278
619
  //
279
620
  // The adopter's `canopycms.config.ts` is shared by local dev, the image
280
- // build and this deployment, and it must say `dev` for the first two (a
281
- // prod-mode `next build` would try to open an EFS branch workspace that
282
- // cannot exist in an image builder). So the deployed mode is supplied
283
- // here, at run time: `resolveOperatingMode`
621
+ // build and this deployment, and it says `dev`. `next dev` needs that, and
622
+ // the image build should stay in dev mode too: build-time reads come from
623
+ // the working tree in either mode (`readsFromCheckout` in canopycms's
624
+ // build-mode.ts), so nothing in a build needs prod, while prod would hold
625
+ // the image builder to checks it has no reason to meet (gitBotAuthorName/
626
+ // gitBotAuthorEmail, a credential-verifying auth plugin). So the deployed
627
+ // mode is supplied here, at run time: `resolveOperatingMode`
284
628
  // (packages/canopycms/src/operating-mode/mode-env.ts) reads CANOPY_MODE
285
629
  // and it wins over the config literal. Without it the Lambda runs dev
286
630
  // mode, resolves its workspace to `<cwd>/.canopy-dev`, and fails EROFS on
@@ -413,6 +757,13 @@ export class CanopyCmsService extends Construct {
413
757
  if (props.lambdaRole) {
414
758
  attachLambdaExecutionPolicies(props.lambdaRole, { vpc: true });
415
759
  }
760
+ // Always resolved, never passed through as `undefined`. DockerImageFunction
761
+ // hands it to the image code's `_bind`, and for `fromImageAsset` that is
762
+ // what sets the Docker build platform. Unset, CDK sets no platform at all:
763
+ // Docker builds for whatever machine runs `cdk deploy` (arm64 on Apple
764
+ // Silicon, amd64 on an x86 CI runner) while the function stays x86_64, and
765
+ // the mismatch only shows at invoke. See `architecture`'s doc comment.
766
+ const architecture = props.architecture ?? lambda.Architecture.ARM_64;
416
767
  this.lambdaFunction = new lambda.DockerImageFunction(this, 'CmsFunction', {
417
768
  code: props.cmsDockerImage,
418
769
  // Default (unset) leaves CDK to create the execution role, with its own
@@ -421,7 +772,7 @@ export class CanopyCmsService extends Construct {
421
772
  memorySize: props.memorySize ?? 2048,
422
773
  timeout: this.timeout,
423
774
  reservedConcurrentExecutions: props.reservedConcurrency ?? 10,
424
- architecture: props.architecture,
775
+ architecture,
425
776
  vpc: this.vpc,
426
777
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
427
778
  securityGroups: [lambdaSg],
@@ -540,12 +891,23 @@ export class CanopyCmsService extends Construct {
540
891
  // got AccessDenied from GetSecretValue, exited, and systemd restart-looped
541
892
  // it every 5s forever. Nothing flagged it at synth.
542
893
  //
543
- // Deduped so the emitted policy does not list the same ARN twice when an
544
- // adopter correctly passes both.
894
+ // Deduped for the benefit of anyone reading this list, NOT of the emitted
895
+ // template: measured against aws-cdk-lib 2.265, `PolicyStatement` already
896
+ // collapses a repeated `resources` entry, so passing the same ARN twice
897
+ // renders one `Resource` either way. Removing this `new Set` changes no
898
+ // synthesized output and breaks no test -- which is worth saying out loud,
899
+ // because the comment here previously claimed the opposite and a reader
900
+ // could reasonably have trusted it while refactoring.
545
901
  const secretsArns = [
546
902
  ...new Set([
547
903
  ...(props.secretsArns ?? []),
548
904
  props.githubTokenSecretArn,
905
+ // The App private key is read by the same `getSecret` call path as
906
+ // the token it replaces, from the same instance profile, so omitting
907
+ // it here would reproduce that AccessDenied restart-loop exactly --
908
+ // on the credential path an adopter reaches for precisely because
909
+ // the token path was not good enough.
910
+ props.githubAppPrivateKeySecretArn,
549
911
  props.clerkSecretKeySecretArn,
550
912
  ].filter((arn) => typeof arn === 'string' && arn.length > 0)),
551
913
  ];
@@ -600,9 +962,50 @@ export class CanopyCmsService extends Construct {
600
962
  if (props.githubTokenSecretArn) {
601
963
  envEntries.push(['CANOPYCMS_GITHUB_TOKEN_SECRET_ARN', props.githubTokenSecretArn]);
602
964
  }
965
+ // The JSON-field vars need NO IAM change, and that is not an oversight: a
966
+ // field is a key inside a secret's value, not a separately grantable
967
+ // resource. `secretsmanager:GetSecretValue` on the secret -- already
968
+ // granted above from the same ARN prop -- returns the whole document, and
969
+ // the worker picks the field out of it in `getSecret`. Said explicitly
970
+ // because the deduped union above exists precisely because someone
971
+ // previously assumed the two prop families were connected when they were
972
+ // not, and shipped a worker that knew which secret to read and had no
973
+ // permission to read it.
974
+ if (props.githubTokenSecretJsonField) {
975
+ envEntries.push([
976
+ 'CANOPYCMS_GITHUB_TOKEN_SECRET_JSON_FIELD',
977
+ props.githubTokenSecretJsonField,
978
+ ]);
979
+ }
980
+ // GitHub App credentials. Stamped individually rather than as a group even
981
+ // though `assertGitHubAuthProps` has already established they are all set
982
+ // or all unset: each `if` is then the same shape as every other entry here,
983
+ // and a future prop added to the App set cannot be silently dropped by a
984
+ // condition that names only its siblings.
985
+ if (props.githubAppId) {
986
+ envEntries.push(['CANOPYCMS_GITHUB_APP_ID', props.githubAppId]);
987
+ }
988
+ if (props.githubAppInstallationId) {
989
+ envEntries.push(['CANOPYCMS_GITHUB_APP_INSTALLATION_ID', props.githubAppInstallationId]);
990
+ }
991
+ if (props.githubAppPrivateKeySecretArn) {
992
+ envEntries.push([
993
+ 'CANOPYCMS_GITHUB_APP_PRIVATE_KEY_SECRET_ARN',
994
+ props.githubAppPrivateKeySecretArn,
995
+ ]);
996
+ }
997
+ if (props.githubAppPrivateKeySecretJsonField) {
998
+ envEntries.push([
999
+ 'CANOPYCMS_GITHUB_APP_PRIVATE_KEY_SECRET_JSON_FIELD',
1000
+ props.githubAppPrivateKeySecretJsonField,
1001
+ ]);
1002
+ }
603
1003
  if (props.clerkSecretKeySecretArn) {
604
1004
  envEntries.push(['CLERK_SECRET_KEY_SECRET_ARN', props.clerkSecretKeySecretArn]);
605
1005
  }
1006
+ if (props.clerkSecretKeySecretJsonField) {
1007
+ envEntries.push(['CLERK_SECRET_KEY_SECRET_JSON_FIELD', props.clerkSecretKeySecretJsonField]);
1008
+ }
606
1009
  if (settingsBranch !== undefined) {
607
1010
  // Only when explicitly set - an absent prop must keep today's behavior
608
1011
  // (the worker falls through to the computed `canopycms-settings-<name>`),