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

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 +212 -50
  75. package/infra/amplify-app-shell.ts +240 -53
  76. package/infra/build-specs.js +14 -5
  77. package/infra/build-specs.ts +17 -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 +167 -10
  82. package/infra/site-config.ts +196 -14
  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,9 +1,10 @@
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, 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 }));
@@ -16,7 +17,19 @@ function environmentVariables(variables: Record<string, string>): amplify.CfnBra
16
17
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
17
18
  * and `staging` (frontend only, reads the production backend)
18
19
  * - for `markus-static` sites, a reader app (WEB) with branch `main`
19
- * - one domain association per branch/app, IAM service and compute roles
20
+ * - a domain association per branch/app when a domain is configured (without
21
+ * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
22
+ * URL is used), IAM service and compute roles
23
+ * - `cms.staging: false` omits the staging branch and its domain
24
+ *
25
+ * Google sign-in is always on: the branches never set PAPYRUS_DISABLE_GOOGLE_OAUTH,
26
+ and `cms.cognitoDomainPrefix` becomes the hosted-UI domain
27
+ (PAPYRUS_COGNITO_DOMAIN_PREFIX) only when `cms.applyCognitoDomainPrefix` is true
28
+ (PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX); otherwise Amplify's generated domain is kept. The secrets GOOGLE_CLIENT_ID and
29
+ GOOGLE_CLIENT_SECRET are Amplify backend secrets set in the console, never here.
30
+ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
31
+ * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
32
+ * principal in the account that may assume it); no token is stored anywhere.
20
33
  *
21
34
  * No repository and no access token are configured: the apps are manual-deploy
22
35
  * until Amplify's GitHub App is connected once in the console
@@ -31,7 +44,9 @@ export class AmplifyAppShellStack extends Stack {
31
44
  super(scope, id, props);
32
45
 
33
46
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
47
+ const stagingEnabled = isStagingEnabled(config);
34
48
  const stagingDomainName = resolveStagingDomainName(config);
49
+ const cmsHostName = resolveCmsHostName(config);
35
50
 
36
51
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
37
52
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
@@ -48,6 +63,19 @@ export class AmplifyAppShellStack extends Stack {
48
63
  ],
49
64
  });
50
65
 
66
+ const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
67
+ if (revalidateSecretParameterName) {
68
+ computeRole.addToPolicy(
69
+ new iam.PolicyStatement({
70
+ sid: "ReadRevalidateSecret",
71
+ actions: ["ssm:GetParameter"],
72
+ resources: [
73
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
74
+ ],
75
+ }),
76
+ );
77
+ }
78
+
51
79
  const cmsApp = new amplify.CfnApp(this, "App", {
52
80
  name: cmsAppName,
53
81
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
@@ -56,13 +84,35 @@ export class AmplifyAppShellStack extends Stack {
56
84
  computeRoleArn: computeRole.roleArn,
57
85
  buildSpec: cmsProductionBuildSpec(config),
58
86
  jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
59
- customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
87
+ customRules: [
88
+ ...(config.cms.redirects ?? []).map((redirect) => ({
89
+ source: `https://${redirect.source}`,
90
+ target: `https://${resolveCmsHostName(config)}`,
91
+ status: String(redirect.status),
92
+ })),
93
+ { source: "/<*>", target: "/index.html", status: "404-200" },
94
+ ],
60
95
  });
61
96
 
62
97
  const stagingMode: Record<string, string> = config.frontend === "markus-static"
63
98
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
64
99
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
65
100
 
101
+ const productionOrigin = cmsHostName
102
+ ? `https://${cmsHostName}/`
103
+ : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
104
+ const stagingOrigin = stagingDomainName
105
+ ? `https://${stagingDomainName}/`
106
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
107
+ const sharedRootDomain = config.cms.domainPrefix !== undefined;
108
+ const defaultOriginsToAllow = [
109
+ ...(config.cms.domainName ? [] : [productionOrigin]),
110
+ ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
111
+ ];
112
+ const oauthRedirectUrls = defaultOriginsToAllow.length === 0
113
+ ? config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS
114
+ : Fn.join(",", [config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS, ...defaultOriginsToAllow]);
115
+
66
116
  const productionBranch = new amplify.CfnBranch(this, "Branch", {
67
117
  appId: cmsApp.attrAppId,
68
118
  branchName: "main",
@@ -71,74 +121,204 @@ export class AmplifyAppShellStack extends Stack {
71
121
  enablePerformanceMode: false,
72
122
  environmentVariables: environmentVariables({
73
123
  ...config.cms.environment,
124
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
125
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
126
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
74
127
  PAPYRUS_SITE_BRAND: config.brand,
75
128
  SITE_ENV: "production",
76
129
  PAPYRUS_CONTENT_SOURCE: "published",
130
+ ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
77
131
  }),
78
132
  });
79
133
 
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
- });
134
+ let stagingBranch: amplify.CfnBranch | undefined;
135
+ if (stagingEnabled) {
136
+ stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
137
+ appId: cmsApp.attrAppId,
138
+ branchName: "staging",
139
+ stage: "BETA",
140
+ enableAutoBuild: true,
141
+ enablePerformanceMode: false,
142
+ buildSpec: cmsStagingBuildSpec(config),
143
+ environmentVariables: environmentVariables({
144
+ ...config.cms.environment,
145
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
146
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
147
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
148
+ PAPYRUS_SITE_BRAND: config.brand,
149
+ SITE_ENV: "staging",
150
+ ...stagingMode,
151
+ NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
152
+ ? `https://${stagingDomainName}`
153
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
154
+ }),
155
+ });
95
156
 
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);
157
+ if (stagingDomainName && !sharedRootDomain) {
158
+ const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
159
+ appId: cmsApp.attrAppId,
160
+ domainName: stagingDomainName,
161
+ subDomainSettings: [{ branchName: "staging", prefix: "" }],
162
+ });
163
+ stagingDomain.addResourceDependency(stagingBranch);
164
+ }
165
+ }
102
166
 
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);
167
+ if (config.cms.domainName) {
168
+ const subDomainSettings = sharedRootDomain
169
+ ? [
170
+ { branchName: "main", prefix: config.cms.domainPrefix as string },
171
+ ...(config.cms.redirects ?? []).map((redirect) => ({
172
+ branchName: "main",
173
+ prefix: resolveRedirectSubDomainPrefix(config.cms.domainName as string, redirect.source),
174
+ })),
175
+ ...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix as string }] : []),
176
+ ]
177
+ : [{ branchName: "main", prefix: "" }];
178
+ const productionDomain = new amplify.CfnDomain(this, "Domain", {
179
+ appId: cmsApp.attrAppId,
180
+ domainName: config.cms.domainName,
181
+ subDomainSettings,
182
+ });
183
+ productionDomain.addResourceDependency(productionBranch);
184
+ if (sharedRootDomain && stagingBranch) {
185
+ productionDomain.addResourceDependency(stagingBranch);
186
+ }
187
+ }
109
188
 
110
189
  this.cmsAppId = cmsApp.attrAppId;
111
190
  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}/` });
191
+ new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
192
+ if (stagingEnabled) {
193
+ new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
194
+ }
114
195
 
115
196
  if (config.reader) {
116
- this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
197
+ this.readerAppId = this.addReaderApp(config, config.reader);
198
+ }
199
+
200
+ const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
201
+ this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
202
+
203
+ if (config.github) {
204
+ this.addGithubCiRole(config, config.github, amplifyAppIds);
117
205
  }
118
206
  }
119
207
 
208
+ private authoringStatements(config: AmplifyAppShellSiteConfig, cmsAppId: string, amplifyAppIds: string[]): iam.PolicyStatement[] {
209
+ const mediaBucketArn = `arn:${this.partition}:s3:::amplify-${cmsAppId}-*`;
210
+ return [
211
+ new iam.PolicyStatement({
212
+ sid: "SignAppSyncAuthoringRequests",
213
+ actions: ["appsync:GraphQL"],
214
+ resources: ["Query", "Mutation"].map((typeName) =>
215
+ this.formatArn({ service: "appsync", resource: "apis", resourceName: `*/types/${typeName}/fields/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
216
+ ),
217
+ }),
218
+ new iam.PolicyStatement({
219
+ sid: "ReadWriteMediaAndPreview",
220
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
221
+ resources: [`${mediaBucketArn}/media/*`, `${mediaBucketArn}/${resolveStoragePreviewPrefix(config)}*`],
222
+ }),
223
+ new iam.PolicyStatement({
224
+ sid: "ListMediaAndPreview",
225
+ actions: ["s3:ListBucket"],
226
+ resources: [mediaBucketArn],
227
+ conditions: { StringLike: { "s3:prefix": ["media/*", `${resolveStoragePreviewPrefix(config)}*`] } },
228
+ }),
229
+ new iam.PolicyStatement({
230
+ sid: "StartAndInspectAmplifyJobs",
231
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
232
+ resources: amplifyAppIds.flatMap((appId) => [
233
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
234
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
235
+ ]),
236
+ }),
237
+ ];
238
+ }
239
+
240
+ private addAuthoringRole(config: AmplifyAppShellSiteConfig, cmsAppId: string, amplifyAppIds: string[]): void {
241
+ const authoringRole = new iam.Role(this, "PapyrusAuthoringRole", {
242
+ roleName: `${config.siteId}-papyrus-authoring`,
243
+ 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.`,
244
+ assumedBy: new iam.AccountRootPrincipal(),
245
+ });
246
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
247
+ authoringRole.addToPolicy(statement);
248
+ }
249
+ new CfnOutput(this, "PapyrusAuthoringRoleArn", {
250
+ value: authoringRole.roleArn,
251
+ description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
252
+ });
253
+ }
254
+
255
+ private addGithubCiRole(
256
+ config: AmplifyAppShellSiteConfig,
257
+ github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
258
+ amplifyAppIds: string[],
259
+ ): void {
260
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
261
+ this,
262
+ "GithubOidcProvider",
263
+ this.formatArn({
264
+ service: "iam",
265
+ region: "",
266
+ resource: "oidc-provider",
267
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
268
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
269
+ }),
270
+ );
271
+
272
+ const ciRole = new iam.Role(this, "GithubCiRole", {
273
+ roleName: `${config.siteId}-github-ci`,
274
+ description: `GitHub Actions role for ${github.owner}/${github.repo} (OIDC, least privilege, no admin policy)`,
275
+ maxSessionDuration: Duration.hours(1),
276
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
277
+ StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
278
+ StringLike: {
279
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
280
+ (branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
281
+ ),
282
+ },
283
+ }),
284
+ });
285
+
286
+ for (const statement of this.authoringStatements(config, this.cmsAppId, amplifyAppIds)) {
287
+ ciRole.addToPolicy(statement);
288
+ }
289
+ ciRole.addToPolicy(
290
+ new iam.PolicyStatement({
291
+ sid: "WhoAmI",
292
+ actions: ["sts:GetCallerIdentity"],
293
+ resources: ["*"],
294
+ }),
295
+ );
296
+ if (github.ciCanDeployInfra) {
297
+ ciRole.addToPolicy(
298
+ new iam.PolicyStatement({
299
+ sid: "DeployThisSitesAppShellStack",
300
+ actions: ["cloudformation:*"],
301
+ resources: [
302
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `${resolveStackName(config)}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
303
+ ],
304
+ }),
305
+ );
306
+ }
307
+
308
+ new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
309
+ }
310
+
120
311
  private addReaderApp(
121
312
  config: AmplifyAppShellSiteConfig,
122
313
  reader: NonNullable<AmplifyAppShellSiteConfig["reader"]>,
123
- cmsAppId: string,
124
314
  ): string {
125
315
  const readerAppName = reader.appName ?? `${config.siteId}-reader`;
126
316
  const readerBranchName = reader.branchName ?? "main";
127
317
 
128
318
  const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
129
319
  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)`,
320
+ description: `Static reader build role for Amplify app ${readerAppName} (reads published media only; content is read as a Cognito guest)`,
131
321
  });
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
322
  readerServiceRole.addToPolicy(
143
323
  new iam.PolicyStatement({
144
324
  sid: "ReadMediaForExport",
@@ -153,6 +333,7 @@ export class AmplifyAppShellStack extends Stack {
153
333
  platform: "WEB",
154
334
  iamServiceRole: readerServiceRole.roleArn,
155
335
  buildSpec: readerBuildSpec(config),
336
+ customRules: [{ source: "/<*>", target: "/404.html", status: "404-200" }],
156
337
  });
157
338
 
158
339
  const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
@@ -167,15 +348,21 @@ export class AmplifyAppShellStack extends Stack {
167
348
  }),
168
349
  });
169
350
 
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);
351
+ if (reader.domainName) {
352
+ const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
353
+ appId: readerApp.attrAppId,
354
+ domainName: reader.domainName,
355
+ subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
356
+ });
357
+ readerDomain.addResourceDependency(readerBranch);
358
+ }
176
359
 
177
360
  new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
178
- new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
361
+ new CfnOutput(this, "ReaderOrigin", {
362
+ value: reader.domainName
363
+ ? `https://${reader.domainName}/`
364
+ : Fn.join("", [`https://${readerBranchName}.`, readerApp.attrDefaultDomain, "/"]),
365
+ });
179
366
  return readerApp.attrAppId;
180
367
  }
181
368
  }
@@ -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,7 @@ 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
+ "papyrus ops content export-published --auth guest --out content-export --clean",
77
86
  ], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
78
87
  ].join("\n") + "\n";
79
88
  }
@@ -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,7 @@ 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
+ "papyrus ops content export-published --auth guest --out content-export --clean",
120
130
  ],
121
131
  [readerConfig.buildCommand],
122
132
  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";