@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.41

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.
Files changed (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +228 -50
  75. package/infra/amplify-app-shell.ts +256 -53
  76. package/infra/build-specs.js +15 -5
  77. package/infra/build-specs.ts +18 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +236 -11
  82. package/infra/site-config.ts +276 -15
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,14 +1,27 @@
1
1
  import { Construct } from "constructs";
2
- import { ArnFormat, CfnOutput, Stack, StackProps } from "aws-cdk-lib";
2
+ import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-lib";
3
3
  import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
+ import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
5
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
6
- import { AmplifyAppShellSiteConfig, resolveStagingDomainName } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, ReaderCustomHeaderRule, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
7
8
 
8
9
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
9
10
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
10
11
  }
11
12
 
13
+ function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
14
+ if (!rules) return undefined;
15
+ const lines = ["customHeaders:"];
16
+ for (const rule of rules) {
17
+ lines.push(` - pattern: ${JSON.stringify(rule.pattern)}`, " headers:");
18
+ for (const [key, value] of Object.entries(rule.headers)) {
19
+ lines.push(` - key: ${JSON.stringify(key)}`, ` value: ${JSON.stringify(value)}`);
20
+ }
21
+ }
22
+ return lines.join("\n") + "\n";
23
+ }
24
+
12
25
  /**
13
26
  * Provisions the Amplify *app shell* of a Papyrus publication from its
14
27
  * site.json:
@@ -16,7 +29,19 @@ function environmentVariables(variables: Record<string, string>): amplify.CfnBra
16
29
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
17
30
  * and `staging` (frontend only, reads the production backend)
18
31
  * - for `markus-static` sites, a reader app (WEB) with branch `main`
19
- * - one domain association per branch/app, IAM service and compute roles
32
+ * - a domain association per branch/app when a domain is configured (without
33
+ * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
34
+ * URL is used), IAM service and compute roles
35
+ * - `cms.staging: false` omits the staging branch and its domain
36
+ *
37
+ * Google sign-in is always on: the branches never set PAPYRUS_DISABLE_GOOGLE_OAUTH,
38
+ and `cms.cognitoDomainPrefix` becomes the hosted-UI domain
39
+ (PAPYRUS_COGNITO_DOMAIN_PREFIX) only when `cms.applyCognitoDomainPrefix` is true
40
+ (PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX); otherwise Amplify's generated domain is kept. The secrets GOOGLE_CLIENT_ID and
41
+ GOOGLE_CLIENT_SECRET are Amplify backend secrets set in the console, never here.
42
+ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
43
+ * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
44
+ * principal in the account that may assume it); no token is stored anywhere.
20
45
  *
21
46
  * No repository and no access token are configured: the apps are manual-deploy
22
47
  * until Amplify's GitHub App is connected once in the console
@@ -31,7 +56,9 @@ export class AmplifyAppShellStack extends Stack {
31
56
  super(scope, id, props);
32
57
 
33
58
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
59
+ const stagingEnabled = isStagingEnabled(config);
34
60
  const stagingDomainName = resolveStagingDomainName(config);
61
+ const cmsHostName = resolveCmsHostName(config);
35
62
 
36
63
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
37
64
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
@@ -48,6 +75,19 @@ export class AmplifyAppShellStack extends Stack {
48
75
  ],
49
76
  });
50
77
 
78
+ const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
79
+ if (revalidateSecretParameterName) {
80
+ computeRole.addToPolicy(
81
+ new iam.PolicyStatement({
82
+ sid: "ReadRevalidateSecret",
83
+ actions: ["ssm:GetParameter"],
84
+ resources: [
85
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
86
+ ],
87
+ }),
88
+ );
89
+ }
90
+
51
91
  const cmsApp = new amplify.CfnApp(this, "App", {
52
92
  name: cmsAppName,
53
93
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
@@ -56,13 +96,35 @@ export class AmplifyAppShellStack extends Stack {
56
96
  computeRoleArn: computeRole.roleArn,
57
97
  buildSpec: cmsProductionBuildSpec(config),
58
98
  jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
59
- customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
99
+ customRules: [
100
+ ...(config.cms.redirects ?? []).map((redirect) => ({
101
+ source: `https://${redirect.source}`,
102
+ target: `https://${resolveCmsHostName(config)}`,
103
+ status: String(redirect.status),
104
+ })),
105
+ { source: "/<*>", target: "/index.html", status: "404-200" },
106
+ ],
60
107
  });
61
108
 
62
109
  const stagingMode: Record<string, string> = config.frontend === "markus-static"
63
110
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
64
111
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
65
112
 
113
+ const productionOrigin = cmsHostName
114
+ ? `https://${cmsHostName}/`
115
+ : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
116
+ const stagingOrigin = stagingDomainName
117
+ ? `https://${stagingDomainName}/`
118
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
119
+ const sharedRootDomain = config.cms.domainPrefix !== undefined;
120
+ const defaultOriginsToAllow = [
121
+ ...(config.cms.domainName ? [] : [productionOrigin]),
122
+ ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
123
+ ];
124
+ const oauthRedirectUrls = defaultOriginsToAllow.length === 0
125
+ ? config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS
126
+ : Fn.join(",", [config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS, ...defaultOriginsToAllow]);
127
+
66
128
  const productionBranch = new amplify.CfnBranch(this, "Branch", {
67
129
  appId: cmsApp.attrAppId,
68
130
  branchName: "main",
@@ -71,74 +133,204 @@ export class AmplifyAppShellStack extends Stack {
71
133
  enablePerformanceMode: false,
72
134
  environmentVariables: environmentVariables({
73
135
  ...config.cms.environment,
136
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
137
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
138
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
74
139
  PAPYRUS_SITE_BRAND: config.brand,
75
140
  SITE_ENV: "production",
76
141
  PAPYRUS_CONTENT_SOURCE: "published",
142
+ ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
77
143
  }),
78
144
  });
79
145
 
80
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
81
- appId: cmsApp.attrAppId,
82
- branchName: "staging",
83
- stage: "BETA",
84
- enableAutoBuild: true,
85
- enablePerformanceMode: false,
86
- buildSpec: cmsStagingBuildSpec(config),
87
- environmentVariables: environmentVariables({
88
- ...config.cms.environment,
89
- PAPYRUS_SITE_BRAND: config.brand,
90
- SITE_ENV: "staging",
91
- ...stagingMode,
92
- NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
93
- }),
94
- });
146
+ let stagingBranch: amplify.CfnBranch | undefined;
147
+ if (stagingEnabled) {
148
+ stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
149
+ appId: cmsApp.attrAppId,
150
+ branchName: "staging",
151
+ stage: "BETA",
152
+ enableAutoBuild: true,
153
+ enablePerformanceMode: false,
154
+ buildSpec: cmsStagingBuildSpec(config),
155
+ environmentVariables: environmentVariables({
156
+ ...config.cms.environment,
157
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
158
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
159
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
160
+ PAPYRUS_SITE_BRAND: config.brand,
161
+ SITE_ENV: "staging",
162
+ ...stagingMode,
163
+ NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
164
+ ? `https://${stagingDomainName}`
165
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
166
+ }),
167
+ });
95
168
 
96
- const productionDomain = new amplify.CfnDomain(this, "Domain", {
97
- appId: cmsApp.attrAppId,
98
- domainName: config.cms.domainName,
99
- subDomainSettings: [{ branchName: "main", prefix: "" }],
100
- });
101
- productionDomain.addResourceDependency(productionBranch);
169
+ if (stagingDomainName && !sharedRootDomain) {
170
+ const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
171
+ appId: cmsApp.attrAppId,
172
+ domainName: stagingDomainName,
173
+ subDomainSettings: [{ branchName: "staging", prefix: "" }],
174
+ });
175
+ stagingDomain.addResourceDependency(stagingBranch);
176
+ }
177
+ }
102
178
 
103
- const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
104
- appId: cmsApp.attrAppId,
105
- domainName: stagingDomainName,
106
- subDomainSettings: [{ branchName: "staging", prefix: "" }],
107
- });
108
- stagingDomain.addResourceDependency(stagingBranch);
179
+ if (config.cms.domainName) {
180
+ const subDomainSettings = sharedRootDomain
181
+ ? [
182
+ { branchName: "main", prefix: config.cms.domainPrefix as string },
183
+ ...(config.cms.redirects ?? []).map((redirect) => ({
184
+ branchName: "main",
185
+ prefix: resolveRedirectSubDomainPrefix(config.cms.domainName as string, redirect.source),
186
+ })),
187
+ ...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix as string }] : []),
188
+ ]
189
+ : [{ branchName: "main", prefix: "" }];
190
+ const productionDomain = new amplify.CfnDomain(this, "Domain", {
191
+ appId: cmsApp.attrAppId,
192
+ domainName: config.cms.domainName,
193
+ subDomainSettings,
194
+ });
195
+ productionDomain.addResourceDependency(productionBranch);
196
+ if (sharedRootDomain && stagingBranch) {
197
+ productionDomain.addResourceDependency(stagingBranch);
198
+ }
199
+ }
109
200
 
110
201
  this.cmsAppId = cmsApp.attrAppId;
111
202
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
112
- new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
113
- new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
203
+ new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
204
+ if (stagingEnabled) {
205
+ new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
206
+ }
114
207
 
115
208
  if (config.reader) {
116
- this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
209
+ this.readerAppId = this.addReaderApp(config, config.reader);
210
+ }
211
+
212
+ const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
213
+ this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
214
+
215
+ if (config.github) {
216
+ this.addGithubCiRole(config, config.github, amplifyAppIds);
217
+ }
218
+ }
219
+
220
+ private authoringStatements(config: AmplifyAppShellSiteConfig, cmsAppId: string, amplifyAppIds: string[]): iam.PolicyStatement[] {
221
+ const mediaBucketArn = `arn:${this.partition}:s3:::amplify-${cmsAppId}-*`;
222
+ return [
223
+ new iam.PolicyStatement({
224
+ sid: "SignAppSyncAuthoringRequests",
225
+ actions: ["appsync:GraphQL"],
226
+ resources: ["Query", "Mutation"].map((typeName) =>
227
+ this.formatArn({ service: "appsync", resource: "apis", resourceName: `*/types/${typeName}/fields/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
228
+ ),
229
+ }),
230
+ new iam.PolicyStatement({
231
+ sid: "ReadWriteMediaAndPreview",
232
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
233
+ resources: [`${mediaBucketArn}/media/*`, `${mediaBucketArn}/${resolveStoragePreviewPrefix(config)}*`],
234
+ }),
235
+ new iam.PolicyStatement({
236
+ sid: "ListMediaAndPreview",
237
+ actions: ["s3:ListBucket"],
238
+ resources: [mediaBucketArn],
239
+ conditions: { StringLike: { "s3:prefix": ["media/*", `${resolveStoragePreviewPrefix(config)}*`] } },
240
+ }),
241
+ new iam.PolicyStatement({
242
+ sid: "StartAndInspectAmplifyJobs",
243
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
244
+ resources: amplifyAppIds.flatMap((appId) => [
245
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
246
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
247
+ ]),
248
+ }),
249
+ ];
250
+ }
251
+
252
+ private addAuthoringRole(config: AmplifyAppShellSiteConfig, cmsAppId: string, amplifyAppIds: string[]): void {
253
+ const authoringRole = new iam.Role(this, "PapyrusAuthoringRole", {
254
+ roleName: `${config.siteId}-papyrus-authoring`,
255
+ description: `Papyrus CLI authoring role for ${config.siteId}: AppSync (SigV4), media and preview storage, rebuild jobs. Assumable by principals in this account that are allowed sts:AssumeRole on it.`,
256
+ assumedBy: new iam.AccountRootPrincipal(),
257
+ });
258
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
259
+ authoringRole.addToPolicy(statement);
117
260
  }
261
+ new CfnOutput(this, "PapyrusAuthoringRoleArn", {
262
+ value: authoringRole.roleArn,
263
+ description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
264
+ });
265
+ }
266
+
267
+ private addGithubCiRole(
268
+ config: AmplifyAppShellSiteConfig,
269
+ github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
270
+ amplifyAppIds: string[],
271
+ ): void {
272
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
273
+ this,
274
+ "GithubOidcProvider",
275
+ this.formatArn({
276
+ service: "iam",
277
+ region: "",
278
+ resource: "oidc-provider",
279
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
280
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
281
+ }),
282
+ );
283
+
284
+ const ciRole = new iam.Role(this, "GithubCiRole", {
285
+ roleName: `${config.siteId}-github-ci`,
286
+ description: `GitHub Actions role for ${github.owner}/${github.repo} (OIDC, least privilege, no admin policy)`,
287
+ maxSessionDuration: Duration.hours(1),
288
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
289
+ StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
290
+ StringLike: {
291
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
292
+ (branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
293
+ ),
294
+ },
295
+ }),
296
+ });
297
+
298
+ for (const statement of this.authoringStatements(config, this.cmsAppId, amplifyAppIds)) {
299
+ ciRole.addToPolicy(statement);
300
+ }
301
+ ciRole.addToPolicy(
302
+ new iam.PolicyStatement({
303
+ sid: "WhoAmI",
304
+ actions: ["sts:GetCallerIdentity"],
305
+ resources: ["*"],
306
+ }),
307
+ );
308
+ if (github.ciCanDeployInfra) {
309
+ ciRole.addToPolicy(
310
+ new iam.PolicyStatement({
311
+ sid: "DeployThisSitesAppShellStack",
312
+ actions: ["cloudformation:*"],
313
+ resources: [
314
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `${resolveStackName(config)}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
315
+ ],
316
+ }),
317
+ );
318
+ }
319
+
320
+ new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
118
321
  }
119
322
 
120
323
  private addReaderApp(
121
324
  config: AmplifyAppShellSiteConfig,
122
325
  reader: NonNullable<AmplifyAppShellSiteConfig["reader"]>,
123
- cmsAppId: string,
124
326
  ): string {
125
327
  const readerAppName = reader.appName ?? `${config.siteId}-reader`;
126
328
  const readerBranchName = reader.branchName ?? "main";
127
329
 
128
330
  const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
129
331
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
130
- description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
332
+ description: `Static reader build role for Amplify app ${readerAppName} (reads published media only; content is read as a Cognito guest)`,
131
333
  });
132
- readerServiceRole.addToPolicy(
133
- new iam.PolicyStatement({
134
- sid: "ReadSiteSecretsForJwtMinting",
135
- actions: ["ssm:GetParameter", "ssm:GetParameters"],
136
- resources: [
137
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
138
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
139
- ],
140
- }),
141
- );
142
334
  readerServiceRole.addToPolicy(
143
335
  new iam.PolicyStatement({
144
336
  sid: "ReadMediaForExport",
@@ -153,6 +345,11 @@ export class AmplifyAppShellStack extends Stack {
153
345
  platform: "WEB",
154
346
  iamServiceRole: readerServiceRole.roleArn,
155
347
  buildSpec: readerBuildSpec(config),
348
+ customRules: [
349
+ ...(reader.redirects ?? []),
350
+ { source: "/<*>", target: "/404.html", status: "404-200" },
351
+ ],
352
+ customHeaders: readerCustomHeaders(reader.customHeaders),
156
353
  });
157
354
 
158
355
  const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
@@ -167,15 +364,21 @@ export class AmplifyAppShellStack extends Stack {
167
364
  }),
168
365
  });
169
366
 
170
- const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
171
- appId: readerApp.attrAppId,
172
- domainName: reader.domainName,
173
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
174
- });
175
- readerDomain.addResourceDependency(readerBranch);
367
+ if (reader.domainName) {
368
+ const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
369
+ appId: readerApp.attrAppId,
370
+ domainName: reader.domainName,
371
+ subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
372
+ });
373
+ readerDomain.addResourceDependency(readerBranch);
374
+ }
176
375
 
177
376
  new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
178
- new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
377
+ new CfnOutput(this, "ReaderOrigin", {
378
+ value: reader.domainName
379
+ ? `https://${reader.domainName}/`
380
+ : Fn.join("", [`https://${readerBranchName}.`, readerApp.attrDefaultDomain, "/"]),
381
+ });
179
382
  return readerApp.attrAppId;
180
383
  }
181
384
  }
@@ -14,6 +14,8 @@ function commandLines(commands, indent) {
14
14
  }
15
15
  const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
16
16
  const FRONTEND_ENVIRONMENT_CAPTURE = "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
17
+ const CHECKOUT_AS_PAPYRUS_ROOT = "PAPYRUS_ROOT=\"$PWD\"";
18
+ const RUNTIME_OUTPUTS_COPY = "cp amplify_outputs.json .next/amplify_outputs.json";
17
19
  function pythonProvisioningCommands(config) {
18
20
  return [
19
21
  "export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
@@ -50,7 +52,11 @@ export function cmsProductionBuildSpec(config) {
50
52
  "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
51
53
  "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
52
54
  ], " "),
53
- ...frontendSection([], [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", [".next/cache/**/*", ".npm/**/*"]),
55
+ ...frontendSection([], [
56
+ FRONTEND_ENVIRONMENT_CAPTURE,
57
+ "npx papyrus-app sync && npm run build",
58
+ ...(config.frontend === "pretext" ? [RUNTIME_OUTPUTS_COPY] : []),
59
+ ], ".next", [".next/cache/**/*", ".npm/**/*"]),
54
60
  ].join("\n") + "\n";
55
61
  }
56
62
  export function cmsStagingBuildSpec(config) {
@@ -58,12 +64,16 @@ export function cmsStagingBuildSpec(config) {
58
64
  const cachePaths = [".next/cache/**/*", ".npm/**/*"];
59
65
  if (config.frontend === "markus-static") {
60
66
  const readerConfig = reader(config);
61
- preBuild.push(...pythonProvisioningCommands(config), "papyrus auth refresh-jwt --write-env .env", "papyrus content export-published --drafts --out content-export --clean", readerConfig.buildCommand, `papyrus content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
67
+ preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`, readerConfig.buildCommand, `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
62
68
  cachePaths.push(".uv-cache/**/*");
63
69
  }
64
70
  return [
65
71
  "version: 1",
66
- ...frontendSection(preBuild, [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", cachePaths),
72
+ ...frontendSection(preBuild, [
73
+ FRONTEND_ENVIRONMENT_CAPTURE,
74
+ "npx papyrus-app sync && npm run build",
75
+ RUNTIME_OUTPUTS_COPY,
76
+ ], ".next", cachePaths),
67
77
  ].join("\n") + "\n";
68
78
  }
69
79
  export function readerBuildSpec(config) {
@@ -72,8 +82,8 @@ export function readerBuildSpec(config) {
72
82
  "version: 1",
73
83
  ...frontendSection([
74
84
  ...pythonProvisioningCommands(config),
75
- "papyrus auth refresh-jwt --write-env .env",
76
- "papyrus content export-published --out content-export --clean",
85
+ ...(readerConfig.preBuildCommands ?? []),
86
+ ...(readerConfig.skipContentExport ? [] : ["papyrus ops content export-published --auth guest --out content-export --clean"]),
77
87
  ], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
78
88
  ].join("\n") + "\n";
79
89
  }
@@ -22,6 +22,10 @@ const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
22
22
  const FRONTEND_ENVIRONMENT_CAPTURE =
23
23
  "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
24
24
 
25
+ const CHECKOUT_AS_PAPYRUS_ROOT = "PAPYRUS_ROOT=\"$PWD\"";
26
+
27
+ const RUNTIME_OUTPUTS_COPY = "cp amplify_outputs.json .next/amplify_outputs.json";
28
+
25
29
  function pythonProvisioningCommands(config: AmplifyAppShellSiteConfig): string[] {
26
30
  return [
27
31
  "export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
@@ -76,7 +80,11 @@ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): strin
76
80
  ),
77
81
  ...frontendSection(
78
82
  [],
79
- [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
83
+ [
84
+ FRONTEND_ENVIRONMENT_CAPTURE,
85
+ "npx papyrus-app sync && npm run build",
86
+ ...(config.frontend === "pretext" ? [RUNTIME_OUTPUTS_COPY] : []),
87
+ ],
80
88
  ".next",
81
89
  [".next/cache/**/*", ".npm/**/*"],
82
90
  ),
@@ -90,10 +98,9 @@ export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
90
98
  const readerConfig = reader(config);
91
99
  preBuild.push(
92
100
  ...pythonProvisioningCommands(config),
93
- "papyrus auth refresh-jwt --write-env .env",
94
- "papyrus content export-published --drafts --out content-export --clean",
101
+ `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`,
95
102
  readerConfig.buildCommand,
96
- `papyrus content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
103
+ `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
97
104
  );
98
105
  cachePaths.push(".uv-cache/**/*");
99
106
  }
@@ -101,7 +108,11 @@ export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
101
108
  "version: 1",
102
109
  ...frontendSection(
103
110
  preBuild,
104
- [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
111
+ [
112
+ FRONTEND_ENVIRONMENT_CAPTURE,
113
+ "npx papyrus-app sync && npm run build",
114
+ RUNTIME_OUTPUTS_COPY,
115
+ ],
105
116
  ".next",
106
117
  cachePaths,
107
118
  ),
@@ -115,8 +126,8 @@ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
115
126
  ...frontendSection(
116
127
  [
117
128
  ...pythonProvisioningCommands(config),
118
- "papyrus auth refresh-jwt --write-env .env",
119
- "papyrus content export-published --out content-export --clean",
129
+ ...(readerConfig.preBuildCommands ?? []),
130
+ ...(readerConfig.skipContentExport ? [] : ["papyrus ops content export-published --auth guest --out content-export --clean"]),
120
131
  ],
121
132
  [readerConfig.buildCommand],
122
133
  readerConfig.baseDirectory,
@@ -0,0 +1,24 @@
1
+ import { CfnOutput, Stack } from "aws-cdk-lib";
2
+ import * as iam from "aws-cdk-lib/aws-iam";
3
+ export const GITHUB_OIDC_PROVIDER_HOST = "token.actions.githubusercontent.com";
4
+ /**
5
+ * Account-global singleton: AWS allows one IAM OIDC provider per issuer URL per
6
+ * account, so this never belongs in a per-site stack. Deploy it once per
7
+ * account, and only after `aws iam list-open-id-connect-providers` shows no
8
+ * provider for token.actions.githubusercontent.com. When one already exists,
9
+ * do not deploy this stack: per-site CI roles only reference the provider by
10
+ * its ARN (arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com)
11
+ * and work against an existing provider unchanged.
12
+ */
13
+ export class GithubOidcProviderStack extends Stack {
14
+ providerArn;
15
+ constructor(scope, id, props) {
16
+ super(scope, id, props);
17
+ const provider = new iam.OpenIdConnectProvider(this, "GithubOidc", {
18
+ url: `https://${GITHUB_OIDC_PROVIDER_HOST}`,
19
+ clientIds: ["sts.amazonaws.com"],
20
+ });
21
+ this.providerArn = provider.openIdConnectProviderArn;
22
+ new CfnOutput(this, "GithubOidcProviderArn", { value: provider.openIdConnectProviderArn });
23
+ }
24
+ }
@@ -0,0 +1,30 @@
1
+ import { Construct } from "constructs";
2
+ import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+
5
+ export const GITHUB_OIDC_PROVIDER_HOST = "token.actions.githubusercontent.com";
6
+
7
+ /**
8
+ * Account-global singleton: AWS allows one IAM OIDC provider per issuer URL per
9
+ * account, so this never belongs in a per-site stack. Deploy it once per
10
+ * account, and only after `aws iam list-open-id-connect-providers` shows no
11
+ * provider for token.actions.githubusercontent.com. When one already exists,
12
+ * do not deploy this stack: per-site CI roles only reference the provider by
13
+ * its ARN (arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com)
14
+ * and work against an existing provider unchanged.
15
+ */
16
+ export class GithubOidcProviderStack extends Stack {
17
+ public readonly providerArn: string;
18
+
19
+ constructor(scope: Construct, id: string, props?: StackProps) {
20
+ super(scope, id, props);
21
+
22
+ const provider = new iam.OpenIdConnectProvider(this, "GithubOidc", {
23
+ url: `https://${GITHUB_OIDC_PROVIDER_HOST}`,
24
+ clientIds: ["sts.amazonaws.com"],
25
+ });
26
+
27
+ this.providerArn = provider.openIdConnectProviderArn;
28
+ new CfnOutput(this, "GithubOidcProviderArn", { value: provider.openIdConnectProviderArn });
29
+ }
30
+ }
package/infra/index.js CHANGED
@@ -10,5 +10,6 @@ try {
10
10
  );
11
11
  }
12
12
  export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
13
+ export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
13
14
  export { parseSiteConfig } from "./site-config.js";
14
15
  export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";