@anthusai/papyrus 2.0.0-next.10 → 2.0.0-next.100
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.
- package/amplify/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +152 -62
- package/amplify/data/resource.ts +158 -62
- package/amplify/data/schema.js +2102 -1986
- package/amplify/data/schema.ts +199 -70
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +64 -3
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +29 -0
- package/amplify/functions/monitoring-view/schema.ts +31 -0
- package/amplify/functions/monitoring-view/wire.js +64 -0
- package/amplify/functions/monitoring-view/wire.ts +81 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1567 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +458 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +229 -0
- package/amplify/plugins/reader-steering/schema.ts +231 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.js +19 -0
- package/amplify/site-backend-config.ts +45 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +46 -4
- package/amplify/site-backend.ts +49 -4
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/app/layout.tsx +5 -1
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +38 -13
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +414 -0
- package/components/monitoring-panel.tsx +60 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +11 -11
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +281 -0
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +237 -158
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +96 -16
- package/infra/amplify-app-shell.ts +108 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +92 -6
- package/infra/site-config.ts +138 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +83 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +188 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +30 -14
- package/lib/prospectus-sign-in-routes.ts +106 -31
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +120 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +246 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +8 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +65 -3
- package/routes.manifest.json +227 -2
- package/src/with-papyrus.mjs +1 -1
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -27,14 +27,16 @@ export function useEditorialChecks({
|
|
|
27
27
|
section,
|
|
28
28
|
itemId,
|
|
29
29
|
}: {
|
|
30
|
-
/** null
|
|
30
|
+
/** null when there is no backend to check against (a preview). Whether a publication runs checks is its stored setting. */
|
|
31
31
|
backend: EditorialChecksBackend | null;
|
|
32
32
|
body: string | null;
|
|
33
33
|
section: string;
|
|
34
34
|
itemId: string | null;
|
|
35
35
|
}) {
|
|
36
|
-
const
|
|
36
|
+
const available = backend !== null;
|
|
37
37
|
const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
|
|
38
|
+
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
|
39
|
+
const enabled = available && settings.enabled;
|
|
38
40
|
const [result, setResult] = useState<EditorialCheckResult | null>(null);
|
|
39
41
|
const [checkedBody, setCheckedBody] = useState<string | null>(null);
|
|
40
42
|
const [status, setStatus] = useState<CheckStatus>({ state: "idle" });
|
|
@@ -46,9 +48,9 @@ export function useEditorialChecks({
|
|
|
46
48
|
const sequence = useRef(0);
|
|
47
49
|
|
|
48
50
|
useEffect(() => {
|
|
49
|
-
if (!
|
|
50
|
-
backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined);
|
|
51
|
-
}, [
|
|
51
|
+
if (!available) return;
|
|
52
|
+
backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined).finally(() => setSettingsLoaded(true));
|
|
53
|
+
}, [available, backend]);
|
|
52
54
|
|
|
53
55
|
useEffect(() => {
|
|
54
56
|
if (!enabled || !itemId) return;
|
|
@@ -57,7 +59,7 @@ export function useEditorialChecks({
|
|
|
57
59
|
|
|
58
60
|
const check = useCallback(async () => {
|
|
59
61
|
const text = latestBody.current;
|
|
60
|
-
if (!backend || text === null || !text.trim()) return;
|
|
62
|
+
if (!backend || !enabled || text === null || !text.trim()) return;
|
|
61
63
|
const run = ++sequence.current;
|
|
62
64
|
setStatus({ state: "checking" });
|
|
63
65
|
try {
|
|
@@ -73,13 +75,13 @@ export function useEditorialChecks({
|
|
|
73
75
|
} catch (error) {
|
|
74
76
|
if (run === sequence.current) setStatus({ state: "error", message: error instanceof Error ? error.message : "The check failed." });
|
|
75
77
|
}
|
|
76
|
-
}, [backend, section]);
|
|
78
|
+
}, [backend, enabled, section]);
|
|
77
79
|
|
|
78
80
|
useEffect(() => {
|
|
79
|
-
if (!enabled || !settings.checkWhileTyping || body === null || body === checkedBody) return;
|
|
81
|
+
if (!enabled || !settingsLoaded || !settings.checkWhileTyping || body === null || body === checkedBody) return;
|
|
80
82
|
const timer = window.setTimeout(() => void check(), CHECK_PAUSE_MS);
|
|
81
83
|
return () => window.clearTimeout(timer);
|
|
82
|
-
}, [body, check, checkedBody, enabled, settings.checkWhileTyping]);
|
|
84
|
+
}, [body, check, checkedBody, enabled, settings.checkWhileTyping, settingsLoaded]);
|
|
83
85
|
|
|
84
86
|
const record = useCallback(
|
|
85
87
|
(finding: EditorialFinding, decision: FeedbackDecision, reason: string | null = null) => {
|
|
@@ -105,7 +107,7 @@ export function useEditorialChecks({
|
|
|
105
107
|
[backend, section],
|
|
106
108
|
);
|
|
107
109
|
|
|
108
|
-
const current = result && checkedBody === body;
|
|
110
|
+
const current = enabled && result && checkedBody === body;
|
|
109
111
|
return {
|
|
110
112
|
enabled,
|
|
111
113
|
settings,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ArnFormat, CfnOutput, Duration, Fn, 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
|
|
4
|
+
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
|
+
import { addCloudEmail } from "./cloud-email.js";
|
|
6
|
+
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
5
7
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
8
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
9
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
8
10
|
function environmentVariables(variables) {
|
|
9
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
10
12
|
}
|
|
@@ -68,9 +70,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
68
70
|
if (!config.reader)
|
|
69
71
|
throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
|
|
70
72
|
this.readerAppId = this.addReaderApp(config, config.reader);
|
|
73
|
+
if (config.autoDeploy) {
|
|
74
|
+
this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
|
|
75
|
+
}
|
|
71
76
|
return;
|
|
72
77
|
}
|
|
73
78
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
79
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId));
|
|
74
80
|
const stagingEnabled = isStagingEnabled(config);
|
|
75
81
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
76
82
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -89,33 +95,49 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
89
95
|
});
|
|
90
96
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
91
97
|
if (revalidateSecretParameterName) {
|
|
98
|
+
const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
|
|
99
|
+
parameterName: revalidateSecretParameterName,
|
|
100
|
+
description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
|
|
101
|
+
});
|
|
92
102
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
93
103
|
sid: "ReadRevalidateSecret",
|
|
94
104
|
actions: ["ssm:GetParameter"],
|
|
95
|
-
resources: [
|
|
96
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
97
|
-
],
|
|
105
|
+
resources: [revalidateSecret.parameterArn],
|
|
98
106
|
}));
|
|
99
107
|
}
|
|
100
108
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
101
|
-
const
|
|
102
|
-
? new
|
|
109
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
110
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
111
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
103
112
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
104
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
105
113
|
})
|
|
106
114
|
: undefined;
|
|
107
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
115
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
108
116
|
? {
|
|
109
117
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
110
|
-
|
|
118
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
111
119
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
112
120
|
}
|
|
113
121
|
: {};
|
|
114
|
-
if (
|
|
122
|
+
if (prospectusSessionSealing) {
|
|
115
123
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
116
124
|
sid: "ReadProspectusSessionSealingSecret",
|
|
117
|
-
actions: ["
|
|
118
|
-
resources: [
|
|
125
|
+
actions: ["ssm:GetParameter"],
|
|
126
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
130
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
131
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
132
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
133
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
134
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
135
|
+
});
|
|
136
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
137
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
138
|
+
sid: "ReadReaderPollSecret",
|
|
139
|
+
actions: ["ssm:GetParameter"],
|
|
140
|
+
resources: [readerPollSecret.parameterArn],
|
|
119
141
|
}));
|
|
120
142
|
}
|
|
121
143
|
if (prospectusAccountsApiStageArn) {
|
|
@@ -153,9 +175,15 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
153
175
|
const stagingOrigin = stagingDomainName
|
|
154
176
|
? `https://${stagingDomainName}/`
|
|
155
177
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
|
|
178
|
+
// The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
|
|
179
|
+
// A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
|
|
180
|
+
const siteOrigin = resolveSiteOrigin(config);
|
|
181
|
+
const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
|
|
182
|
+
const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
|
|
156
183
|
const sharedRootDomain = config.cms.domainPrefix !== undefined;
|
|
157
184
|
const defaultOriginsToAllow = [
|
|
158
185
|
...(config.cms.domainName ? [] : [productionOrigin]),
|
|
186
|
+
...(siteOrigin ? [`${siteOrigin}/`] : []),
|
|
159
187
|
...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
|
|
160
188
|
];
|
|
161
189
|
const oauthRedirectUrls = defaultOriginsToAllow.length === 0
|
|
@@ -165,15 +193,19 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
165
193
|
appId: cmsApp.attrAppId,
|
|
166
194
|
branchName: "main",
|
|
167
195
|
stage: "PRODUCTION",
|
|
168
|
-
|
|
196
|
+
// Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
|
|
197
|
+
enableAutoBuild: !config.autoDeploy,
|
|
169
198
|
enablePerformanceMode: false,
|
|
170
199
|
environmentVariables: environmentVariables({
|
|
171
200
|
...config.cms.environment,
|
|
201
|
+
...emailEnvironment,
|
|
202
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
172
203
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
173
204
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
174
205
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
175
206
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
176
207
|
SITE_ENV: "production",
|
|
208
|
+
PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
|
|
177
209
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
178
210
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
179
211
|
...prospectusEnvironment,
|
|
@@ -190,11 +222,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
190
222
|
buildSpec: cmsStagingBuildSpec(config),
|
|
191
223
|
environmentVariables: environmentVariables({
|
|
192
224
|
...config.cms.environment,
|
|
225
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "false",
|
|
193
226
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
194
227
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
195
228
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
196
229
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
197
230
|
SITE_ENV: "staging",
|
|
231
|
+
PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
|
|
198
232
|
...stagingMode,
|
|
199
233
|
...prospectusEnvironment,
|
|
200
234
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
@@ -247,6 +281,12 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
247
281
|
}
|
|
248
282
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
249
283
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
284
|
+
if (config.autoDeploy) {
|
|
285
|
+
this.addGithubDeployRole(config, [
|
|
286
|
+
{ appId: cmsApp.attrAppId, branch: "main" },
|
|
287
|
+
...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
250
290
|
if (config.github) {
|
|
251
291
|
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
252
292
|
}
|
|
@@ -294,6 +334,46 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
294
334
|
description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
|
|
295
335
|
});
|
|
296
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
|
|
339
|
+
* repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
|
|
340
|
+
* production branch of the site's apps.
|
|
341
|
+
*/
|
|
342
|
+
addGithubDeployRole(config, targets) {
|
|
343
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
344
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
|
|
345
|
+
service: "iam",
|
|
346
|
+
region: "",
|
|
347
|
+
resource: "oidc-provider",
|
|
348
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
349
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
350
|
+
}));
|
|
351
|
+
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
352
|
+
roleName: `${config.siteId}-github-deploy`,
|
|
353
|
+
description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
|
|
354
|
+
maxSessionDuration: Duration.hours(1),
|
|
355
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
356
|
+
StringEquals: {
|
|
357
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
|
|
358
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
deployRole.addToPolicy(new iam.PolicyStatement({
|
|
363
|
+
sid: "StartAndReadProductionJobs",
|
|
364
|
+
actions: ["amplify:StartJob", "amplify:GetJob"],
|
|
365
|
+
resources: targets.map((target) => this.formatArn({
|
|
366
|
+
service: "amplify",
|
|
367
|
+
resource: "apps",
|
|
368
|
+
resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
|
|
369
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
370
|
+
})),
|
|
371
|
+
}));
|
|
372
|
+
new CfnOutput(this, "GithubDeployRoleArn", {
|
|
373
|
+
value: deployRole.roleArn,
|
|
374
|
+
description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
297
377
|
addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
|
|
298
378
|
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
|
|
299
379
|
service: "iam",
|
|
@@ -309,7 +389,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
309
389
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
310
390
|
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
311
391
|
StringLike: {
|
|
312
|
-
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.
|
|
392
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
|
|
313
393
|
},
|
|
314
394
|
}),
|
|
315
395
|
});
|
|
@@ -361,7 +441,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
361
441
|
appId: readerApp.attrAppId,
|
|
362
442
|
branchName: readerBranchName,
|
|
363
443
|
stage: "PRODUCTION",
|
|
364
|
-
enableAutoBuild:
|
|
444
|
+
enableAutoBuild: !config.autoDeploy,
|
|
365
445
|
environmentVariables: environmentVariables({
|
|
366
446
|
...(reader.environment ?? {}),
|
|
367
447
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
@@ -2,10 +2,12 @@ import { Construct } from "constructs";
|
|
|
2
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
|
|
5
|
+
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
|
+
import { addCloudEmail } from "./cloud-email";
|
|
7
|
+
import { parseCloudEmail } from "./cloud-email-config";
|
|
6
8
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
9
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
10
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
9
11
|
|
|
10
12
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
11
13
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -72,10 +74,14 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
72
74
|
if (!hasCmsApp(config)) {
|
|
73
75
|
if (!config.reader) throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
|
|
74
76
|
this.readerAppId = this.addReaderApp(config, config.reader);
|
|
77
|
+
if (config.autoDeploy) {
|
|
78
|
+
this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
|
|
79
|
+
}
|
|
75
80
|
return;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
84
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId)!);
|
|
79
85
|
const stagingEnabled = isStagingEnabled(config);
|
|
80
86
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
81
87
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -97,37 +103,55 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
97
103
|
|
|
98
104
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
99
105
|
if (revalidateSecretParameterName) {
|
|
106
|
+
const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
|
|
107
|
+
parameterName: revalidateSecretParameterName,
|
|
108
|
+
description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
|
|
109
|
+
});
|
|
100
110
|
computeRole.addToPolicy(
|
|
101
111
|
new iam.PolicyStatement({
|
|
102
112
|
sid: "ReadRevalidateSecret",
|
|
103
113
|
actions: ["ssm:GetParameter"],
|
|
104
|
-
resources: [
|
|
105
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
106
|
-
],
|
|
114
|
+
resources: [revalidateSecret.parameterArn],
|
|
107
115
|
}),
|
|
108
116
|
);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
112
|
-
const
|
|
113
|
-
? new
|
|
120
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
121
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
122
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
114
123
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
115
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
116
124
|
})
|
|
117
125
|
: undefined;
|
|
118
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
126
|
+
const prospectusEnvironment: Record<string, string> = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
119
127
|
? {
|
|
120
128
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
121
|
-
|
|
129
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
122
130
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
123
131
|
}
|
|
124
132
|
: {};
|
|
125
|
-
if (
|
|
133
|
+
if (prospectusSessionSealing) {
|
|
126
134
|
computeRole.addToPolicy(
|
|
127
135
|
new iam.PolicyStatement({
|
|
128
136
|
sid: "ReadProspectusSessionSealingSecret",
|
|
129
|
-
actions: ["
|
|
130
|
-
resources: [
|
|
137
|
+
actions: ["ssm:GetParameter"],
|
|
138
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
143
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
144
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
145
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
146
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
147
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
148
|
+
});
|
|
149
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
150
|
+
computeRole.addToPolicy(
|
|
151
|
+
new iam.PolicyStatement({
|
|
152
|
+
sid: "ReadReaderPollSecret",
|
|
153
|
+
actions: ["ssm:GetParameter"],
|
|
154
|
+
resources: [readerPollSecret.parameterArn],
|
|
131
155
|
}),
|
|
132
156
|
);
|
|
133
157
|
}
|
|
@@ -172,9 +196,15 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
172
196
|
const stagingOrigin = stagingDomainName
|
|
173
197
|
? `https://${stagingDomainName}/`
|
|
174
198
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
|
|
199
|
+
// The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
|
|
200
|
+
// A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
|
|
201
|
+
const siteOrigin = resolveSiteOrigin(config);
|
|
202
|
+
const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
|
|
203
|
+
const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
|
|
175
204
|
const sharedRootDomain = config.cms.domainPrefix !== undefined;
|
|
176
205
|
const defaultOriginsToAllow = [
|
|
177
206
|
...(config.cms.domainName ? [] : [productionOrigin]),
|
|
207
|
+
...(siteOrigin ? [`${siteOrigin}/`] : []),
|
|
178
208
|
...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
|
|
179
209
|
];
|
|
180
210
|
const oauthRedirectUrls = defaultOriginsToAllow.length === 0
|
|
@@ -185,15 +215,19 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
185
215
|
appId: cmsApp.attrAppId,
|
|
186
216
|
branchName: "main",
|
|
187
217
|
stage: "PRODUCTION",
|
|
188
|
-
|
|
218
|
+
// Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
|
|
219
|
+
enableAutoBuild: !config.autoDeploy,
|
|
189
220
|
enablePerformanceMode: false,
|
|
190
221
|
environmentVariables: environmentVariables({
|
|
191
222
|
...config.cms.environment,
|
|
223
|
+
...emailEnvironment,
|
|
224
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
192
225
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
193
226
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
194
227
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
195
228
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
196
229
|
SITE_ENV: "production",
|
|
230
|
+
PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
|
|
197
231
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
198
232
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
199
233
|
...prospectusEnvironment,
|
|
@@ -211,11 +245,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
211
245
|
buildSpec: cmsStagingBuildSpec(config),
|
|
212
246
|
environmentVariables: environmentVariables({
|
|
213
247
|
...config.cms.environment,
|
|
248
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "false",
|
|
214
249
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
215
250
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
216
251
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
217
252
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
218
253
|
SITE_ENV: "staging",
|
|
254
|
+
PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
|
|
219
255
|
...stagingMode,
|
|
220
256
|
...prospectusEnvironment,
|
|
221
257
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
@@ -274,6 +310,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
274
310
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
275
311
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
276
312
|
|
|
313
|
+
if (config.autoDeploy) {
|
|
314
|
+
this.addGithubDeployRole(config, [
|
|
315
|
+
{ appId: cmsApp.attrAppId, branch: "main" },
|
|
316
|
+
...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
|
|
317
|
+
]);
|
|
318
|
+
}
|
|
319
|
+
|
|
277
320
|
if (config.github) {
|
|
278
321
|
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
279
322
|
}
|
|
@@ -326,6 +369,55 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
326
369
|
});
|
|
327
370
|
}
|
|
328
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
|
|
374
|
+
* repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
|
|
375
|
+
* production branch of the site's apps.
|
|
376
|
+
*/
|
|
377
|
+
private addGithubDeployRole(config: AmplifyAppShellSiteConfig, targets: { appId: string; branch: string }[]): void {
|
|
378
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
379
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
380
|
+
this,
|
|
381
|
+
"GithubDeployOidcProvider",
|
|
382
|
+
this.formatArn({
|
|
383
|
+
service: "iam",
|
|
384
|
+
region: "",
|
|
385
|
+
resource: "oidc-provider",
|
|
386
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
387
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
391
|
+
roleName: `${config.siteId}-github-deploy`,
|
|
392
|
+
description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
|
|
393
|
+
maxSessionDuration: Duration.hours(1),
|
|
394
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
395
|
+
StringEquals: {
|
|
396
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
|
|
397
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
});
|
|
401
|
+
deployRole.addToPolicy(
|
|
402
|
+
new iam.PolicyStatement({
|
|
403
|
+
sid: "StartAndReadProductionJobs",
|
|
404
|
+
actions: ["amplify:StartJob", "amplify:GetJob"],
|
|
405
|
+
resources: targets.map((target) =>
|
|
406
|
+
this.formatArn({
|
|
407
|
+
service: "amplify",
|
|
408
|
+
resource: "apps",
|
|
409
|
+
resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
|
|
410
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
411
|
+
}),
|
|
412
|
+
),
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
new CfnOutput(this, "GithubDeployRoleArn", {
|
|
416
|
+
value: deployRole.roleArn,
|
|
417
|
+
description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
329
421
|
private addGithubCiRole(
|
|
330
422
|
config: AmplifyAppShellSiteConfig,
|
|
331
423
|
github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
|
|
@@ -351,9 +443,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
351
443
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
352
444
|
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
353
445
|
StringLike: {
|
|
354
|
-
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.
|
|
355
|
-
(branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
|
|
356
|
-
),
|
|
446
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
|
|
357
447
|
},
|
|
358
448
|
}),
|
|
359
449
|
});
|
|
@@ -420,7 +510,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
420
510
|
appId: readerApp.attrAppId,
|
|
421
511
|
branchName: readerBranchName,
|
|
422
512
|
stage: "PRODUCTION",
|
|
423
|
-
enableAutoBuild:
|
|
513
|
+
enableAutoBuild: !config.autoDeploy,
|
|
424
514
|
environmentVariables: environmentVariables({
|
|
425
515
|
...(reader.environment ?? {}),
|
|
426
516
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseCloudEmail(raw, cloud, siteId) {
|
|
2
|
+
if (!cloud) {
|
|
3
|
+
if (raw !== undefined)
|
|
4
|
+
throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
8
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
9
|
+
}
|
|
10
|
+
const value = raw;
|
|
11
|
+
for (const key of Object.keys(value)) {
|
|
12
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
13
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (value.mode !== "managed" && value.mode !== "existing")
|
|
17
|
+
throw new Error("site.json: email.mode must be managed or existing");
|
|
18
|
+
const string = (key, pattern, fallback) => {
|
|
19
|
+
const result = value[key] ?? fallback;
|
|
20
|
+
if (typeof result !== "string" || !pattern.test(result))
|
|
21
|
+
throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
mode: value.mode,
|
|
26
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
27
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
28
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
29
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
30
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Email belongs to a cloud site, independently of any newsletter plugin. */
|
|
2
|
+
export type CloudEmailConfig = {
|
|
3
|
+
mode: "managed" | "existing";
|
|
4
|
+
sendingDomain: string;
|
|
5
|
+
senderLocalPart: string;
|
|
6
|
+
configurationSet: string;
|
|
7
|
+
eventTopicName: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseCloudEmail(raw: unknown, cloud: boolean, siteId: string): CloudEmailConfig | undefined {
|
|
11
|
+
if (!cloud) {
|
|
12
|
+
if (raw !== undefined) throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
16
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
17
|
+
}
|
|
18
|
+
const value = raw as Record<string, unknown>;
|
|
19
|
+
for (const key of Object.keys(value)) {
|
|
20
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
21
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (value.mode !== "managed" && value.mode !== "existing") throw new Error("site.json: email.mode must be managed or existing");
|
|
25
|
+
const string = (key: string, pattern: RegExp, fallback?: string) => {
|
|
26
|
+
const result = value[key] ?? fallback;
|
|
27
|
+
if (typeof result !== "string" || !pattern.test(result)) throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
mode: value.mode,
|
|
32
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
33
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
34
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
35
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
36
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ArnFormat, CfnOutput, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
5
|
+
/** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
|
|
6
|
+
export function addCloudEmail(stack, config) {
|
|
7
|
+
if (config.mode === "managed") {
|
|
8
|
+
const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
|
|
9
|
+
name: config.configurationSet,
|
|
10
|
+
reputationOptions: { reputationMetricsEnabled: true },
|
|
11
|
+
suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
|
|
12
|
+
sendingOptions: { sendingEnabled: true },
|
|
13
|
+
});
|
|
14
|
+
configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
15
|
+
const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
|
|
16
|
+
emailIdentity: config.sendingDomain,
|
|
17
|
+
dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
|
|
18
|
+
configurationSetAttributes: { configurationSetName: configurationSet.ref },
|
|
19
|
+
});
|
|
20
|
+
identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
21
|
+
const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
|
|
22
|
+
topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
23
|
+
topic.addToResourcePolicy(new iam.PolicyStatement({
|
|
24
|
+
principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
|
|
25
|
+
resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
|
|
26
|
+
}));
|
|
27
|
+
const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
|
|
28
|
+
configurationSetName: configurationSet.ref,
|
|
29
|
+
eventDestination: { name: "delivery-events", enabled: true,
|
|
30
|
+
matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
|
|
31
|
+
snsDestination: { topicArn: topic.topicArn } },
|
|
32
|
+
});
|
|
33
|
+
events.node.addDependency(topic);
|
|
34
|
+
// These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
|
|
35
|
+
new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
|
|
36
|
+
configurationSetName: configurationSet.ref,
|
|
37
|
+
eventDestination: { name: "cloudwatch-metrics", enabled: true,
|
|
38
|
+
matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
39
|
+
cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
|
|
40
|
+
dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
|
|
41
|
+
});
|
|
42
|
+
const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
|
|
43
|
+
[identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
|
|
44
|
+
[identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
|
|
45
|
+
records.forEach(([name, value], index) => {
|
|
46
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
|
|
47
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
|
|
51
|
+
new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
|
|
52
|
+
new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
|
|
53
|
+
new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
|
|
54
|
+
return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
|
|
55
|
+
PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
|
|
56
|
+
PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
|
|
57
|
+
}
|