@anthusai/papyrus 1.0.0-next.3 → 1.0.0-next.5

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 (40) hide show
  1. package/amplify/storage/resource.js +3 -0
  2. package/amplify/storage/resource.ts +3 -0
  3. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  4. package/app/globals.css +19 -0
  5. package/app/layout.tsx +4 -0
  6. package/app/robots.ts +11 -0
  7. package/bin/papyrus-app.mjs +12 -2
  8. package/bin/papyrus-infra.mjs +72 -16
  9. package/components/article-video.tsx +112 -3
  10. package/components/staging-banner.tsx +11 -0
  11. package/infra/amplify-app-shell.js +178 -80
  12. package/infra/amplify-app-shell.ts +217 -113
  13. package/infra/build-specs.js +86 -0
  14. package/infra/build-specs.ts +132 -0
  15. package/infra/github-oidc-provider.js +24 -0
  16. package/infra/github-oidc-provider.ts +30 -0
  17. package/infra/index.js +3 -0
  18. package/infra/site-config.js +155 -0
  19. package/infra/site-config.ts +200 -0
  20. package/lib/cached-content-repository.ts +2 -1
  21. package/lib/content-repository.ts +3 -0
  22. package/lib/content-source-context.ts +16 -0
  23. package/lib/graphql-content-repository.ts +182 -37
  24. package/lib/site-brand.ts +13 -0
  25. package/lib/site-env.ts +52 -0
  26. package/lib/staging-gate.ts +37 -0
  27. package/lib/staging-preview-object.ts +98 -0
  28. package/lib/video-mode.ts +42 -0
  29. package/lib/video-script.ts +63 -0
  30. package/middleware.ts +51 -1
  31. package/package.json +11 -1
  32. package/routes.manifest.json +11 -1
  33. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  34. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  35. package/scripts/videoml/bundle-common.mjs +129 -0
  36. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  37. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  38. package/scripts/videoml/preview/preview-timing.ts +42 -0
  39. package/scripts/videoml/preview.html +77 -0
  40. package/scripts/videoml/standard-entry.tsx +46 -0
@@ -1,109 +1,207 @@
1
- import { Stack, SecretValue } from "aws-cdk-lib";
1
+ import { ArnFormat, CfnOutput, Duration, Stack } from "aws-cdk-lib";
2
2
  import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
+ import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
5
+ import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
6
+ import { resolveStagingDomainName } from "./site-config.js";
7
+ function environmentVariables(variables) {
8
+ return Object.entries(variables).map(([name, value]) => ({ name, value }));
9
+ }
4
10
  /**
5
- * Provisions the Amplify *app shell* for a Papyrus publication CMS:
6
- *
7
- * - AWS::Amplify::App (WEB_COMPUTE, GitHub repo connection via PAT in Secrets Manager)
8
- * - AWS::Amplify::Branch (main, with PAPYRUS_SITE_BRAND env vars)
9
- * - AWS::Amplify::Domain (newsroom.<site>.us on the site's Route 53 zone)
10
- * - IAM service role (backend deploy) + compute role (SSR rendering)
11
+ * Provisions the Amplify *app shell* of a Papyrus publication from its
12
+ * site.json:
11
13
  *
12
- * The Papyrus *backend* (AppSync, Storage, Lambda) is NOT created here. It
13
- * deploys via `ampx pipeline-deploy` from the repo `amplify.yml` once Amplify
14
- * CI runs a build on the branch. This stack only creates the container the
15
- * backend deploys into, so the app is never "created manually" in the console.
14
+ * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
15
+ * and `staging` (frontend only, reads the production backend)
16
+ * - for `markus-static` sites, a reader app (WEB) with branch `main`
17
+ * - one domain association per branch/app, IAM service and compute roles
16
18
  *
17
- * The GitHub access token is read from Secrets Manager by dynamic reference,
18
- * so the token value never appears in the synthesized template.
19
+ * No repository and no access token are configured: the apps are manual-deploy
20
+ * until Amplify's GitHub App is connected once in the console
21
+ * (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
22
+ * app by `ampx pipeline-deploy` from the production build.
19
23
  */
20
24
  export class AmplifyAppShellStack extends Stack {
21
- appId;
22
- defaultDomain;
25
+ cmsAppId;
26
+ readerAppId;
23
27
  constructor(scope, id, config, props) {
24
28
  super(scope, id, props);
25
- const githubToken = SecretValue.secretsManager(config.githubTokenSecretName).unsafeUnwrap();
26
- // Service role Amplify assumes to deploy the backend (ampx pipeline-deploy
27
- // creates AppSync, Storage, Lambda, etc., so it needs broad permissions).
28
- const serviceRole = new iam.Role(this, "AmplifyServiceRole", {
29
+ const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
30
+ const stagingDomainName = resolveStagingDomainName(config);
31
+ const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
29
32
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
30
- description: `Backend deploy service role for Amplify app ${config.appName}`,
33
+ description: `Backend deploy and staging build service role for Amplify app ${cmsAppName} (AdministratorAccess: documented risk)`,
31
34
  managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")],
32
35
  });
33
- // WEB_COMPUTE (Next.js SSR) requires a compute role for the rendering server.
34
36
  const computeRole = new iam.Role(this, "AmplifyComputeRole", {
35
37
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
36
- description: `SSR compute role for Amplify app ${config.appName}`,
38
+ description: `SSR compute role for Amplify app ${cmsAppName}`,
37
39
  managedPolicies: [
38
40
  iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
39
41
  iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
40
42
  ],
41
43
  });
42
- const app = new amplify.CfnApp(this, "App", {
43
- name: config.appName,
44
- description: config.description,
45
- platform: config.platform,
46
- repository: config.repository,
47
- accessToken: githubToken,
48
- iamServiceRole: serviceRole.roleArn,
44
+ const cmsApp = new amplify.CfnApp(this, "App", {
45
+ name: cmsAppName,
46
+ description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
47
+ platform: "WEB_COMPUTE",
48
+ iamServiceRole: cmsServiceRole.roleArn,
49
49
  computeRoleArn: computeRole.roleArn,
50
- buildSpec: this.buildSpec(),
50
+ buildSpec: cmsProductionBuildSpec(config),
51
+ jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
51
52
  customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
52
53
  });
53
- this.appId = app.attrAppId;
54
- this.defaultDomain = `${app.attrAppId}.amplifyapp.com`;
55
- const branch = new amplify.CfnBranch(this, "Branch", {
56
- appId: app.attrAppId,
57
- branchName: config.branchName,
54
+ const stagingMode = config.frontend === "markus-static"
55
+ ? { PAPYRUS_STAGING_PREVIEW: "static" }
56
+ : { PAPYRUS_CONTENT_SOURCE: "drafts" };
57
+ const productionBranch = new amplify.CfnBranch(this, "Branch", {
58
+ appId: cmsApp.attrAppId,
59
+ branchName: "main",
58
60
  stage: "PRODUCTION",
59
- enableAutoBuild: config.enableAutoBuild ?? true,
61
+ enableAutoBuild: true,
62
+ enablePerformanceMode: false,
63
+ environmentVariables: environmentVariables({
64
+ ...config.cms.environment,
65
+ PAPYRUS_SITE_BRAND: config.brand,
66
+ SITE_ENV: "production",
67
+ PAPYRUS_CONTENT_SOURCE: "published",
68
+ }),
69
+ });
70
+ const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
71
+ appId: cmsApp.attrAppId,
72
+ branchName: "staging",
73
+ stage: "BETA",
74
+ enableAutoBuild: true,
60
75
  enablePerformanceMode: false,
61
- environmentVariables: Object.entries(config.environment).map(([key, value]) => ({
62
- name: key,
63
- value,
64
- })),
76
+ buildSpec: cmsStagingBuildSpec(config),
77
+ environmentVariables: environmentVariables({
78
+ ...config.cms.environment,
79
+ PAPYRUS_SITE_BRAND: config.brand,
80
+ SITE_ENV: "staging",
81
+ ...stagingMode,
82
+ NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
83
+ }),
65
84
  });
66
- branch.addResourceDependency(app);
67
- const domain = new amplify.CfnDomain(this, "Domain", {
68
- appId: app.attrAppId,
69
- domainName: config.domainName,
70
- subDomainSettings: [{ branchName: config.branchName, prefix: "" }],
85
+ const productionDomain = new amplify.CfnDomain(this, "Domain", {
86
+ appId: cmsApp.attrAppId,
87
+ domainName: config.cms.domainName,
88
+ subDomainSettings: [{ branchName: "main", prefix: "" }],
71
89
  });
72
- domain.addResourceDependency(branch);
73
- this.exportValue(app.attrAppId, { description: `Amplify app id for ${config.appName}` });
90
+ productionDomain.addResourceDependency(productionBranch);
91
+ const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
92
+ appId: cmsApp.attrAppId,
93
+ domainName: stagingDomainName,
94
+ subDomainSettings: [{ branchName: "staging", prefix: "" }],
95
+ });
96
+ stagingDomain.addResourceDependency(stagingBranch);
97
+ this.cmsAppId = cmsApp.attrAppId;
98
+ new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
99
+ new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
100
+ new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
101
+ if (config.reader) {
102
+ this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
103
+ }
104
+ if (config.github) {
105
+ this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
106
+ }
107
+ }
108
+ addGithubCiRole(config, github, amplifyAppIds) {
109
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
110
+ service: "iam",
111
+ region: "",
112
+ resource: "oidc-provider",
113
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
114
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
115
+ }));
116
+ const ciRole = new iam.Role(this, "GithubCiRole", {
117
+ roleName: `${config.siteId}-github-ci`,
118
+ description: `GitHub Actions role for ${github.owner}/${github.repo} (OIDC, least privilege, no admin policy)`,
119
+ maxSessionDuration: Duration.hours(1),
120
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
121
+ StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
122
+ StringLike: {
123
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
124
+ },
125
+ }),
126
+ });
127
+ ciRole.addToPolicy(new iam.PolicyStatement({
128
+ sid: "StartAndInspectAmplifyJobs",
129
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
130
+ resources: amplifyAppIds.flatMap((appId) => [
131
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
132
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
133
+ ]),
134
+ }));
135
+ ciRole.addToPolicy(new iam.PolicyStatement({
136
+ sid: "ReadSiteSecretsForJwtMinting",
137
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
138
+ resources: [
139
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
140
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
141
+ ],
142
+ }));
143
+ ciRole.addToPolicy(new iam.PolicyStatement({
144
+ sid: "WhoAmI",
145
+ actions: ["sts:GetCallerIdentity"],
146
+ resources: ["*"],
147
+ }));
148
+ if (github.ciCanDeployInfra) {
149
+ ciRole.addToPolicy(new iam.PolicyStatement({
150
+ sid: "DeployThisSitesAppShellStack",
151
+ actions: ["cloudformation:*"],
152
+ resources: [
153
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `amplify-app-shell-${config.siteId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
154
+ ],
155
+ }));
156
+ }
157
+ new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
74
158
  }
75
- /**
76
- * Build spec mirrors the repo root amplify.yml so the app deploys the full
77
- * Papyrus backend + Next.js frontend on every build. Inlined because Amplify
78
- * needs a buildSpec on the app for the first deploy before the repo is cloned.
79
- */
80
- buildSpec() {
81
- return [
82
- "version: 1",
83
- "backend:",
84
- " phases:",
85
- " build:",
86
- " commands:",
87
- " - npm install --cache .npm --prefer-offline",
88
- " - |",
89
- " if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then",
90
- " export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true",
91
- " fi",
92
- " - npx ampx pipeline-deploy --debug --branch $AWS_BRANCH --app-id $AWS_APP_ID",
93
- "frontend:",
94
- " phases:",
95
- " build:",
96
- " commands:",
97
- " - env | grep -e '^PAPYRUS_CONTENT_SOURCE=' -e '^PAPYRUS_EDITION_SLUG=' -e '^PAPYRUS_REVALIDATE_SECRET=' >> .env.production",
98
- " - npm run build",
99
- " artifacts:",
100
- " baseDirectory: .next",
101
- " files:",
102
- " - '**/*'",
103
- " cache:",
104
- " paths:",
105
- " - .next/cache/**/*",
106
- " - .npm/**/*",
107
- ].join("\n");
159
+ addReaderApp(config, reader, cmsAppId) {
160
+ const readerAppName = reader.appName ?? `${config.siteId}-reader`;
161
+ const readerBranchName = reader.branchName ?? "main";
162
+ const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
163
+ assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
164
+ description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
165
+ });
166
+ readerServiceRole.addToPolicy(new iam.PolicyStatement({
167
+ sid: "ReadSiteSecretsForJwtMinting",
168
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
169
+ resources: [
170
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
171
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
172
+ ],
173
+ }));
174
+ readerServiceRole.addToPolicy(new iam.PolicyStatement({
175
+ sid: "ReadMediaForExport",
176
+ actions: ["s3:GetObject"],
177
+ resources: ["arn:aws:s3:::amplify-*/media/*"],
178
+ }));
179
+ const readerApp = new amplify.CfnApp(this, "ReaderApp", {
180
+ name: readerAppName,
181
+ description: `Papyrus static reader for ${config.siteId} (WEB)`,
182
+ platform: "WEB",
183
+ iamServiceRole: readerServiceRole.roleArn,
184
+ buildSpec: readerBuildSpec(config),
185
+ });
186
+ const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
187
+ appId: readerApp.attrAppId,
188
+ branchName: readerBranchName,
189
+ stage: "PRODUCTION",
190
+ enableAutoBuild: true,
191
+ environmentVariables: environmentVariables({
192
+ ...(reader.environment ?? {}),
193
+ PAPYRUS_SITE_BRAND: config.brand,
194
+ SITE_ENV: "production",
195
+ }),
196
+ });
197
+ const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
198
+ appId: readerApp.attrAppId,
199
+ domainName: reader.domainName,
200
+ subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
201
+ });
202
+ readerDomain.addResourceDependency(readerBranch);
203
+ new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
204
+ new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
205
+ return readerApp.attrAppId;
108
206
  }
109
207
  }