@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.71

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 (165) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2028 -1979
  4. package/amplify/data/schema.ts +72 -14
  5. package/amplify/functions/assignment-action/data-access.js +90 -0
  6. package/amplify/functions/assignment-action/data-access.ts +89 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +61 -2
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  15. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +63 -4
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/newsletter/config.js +245 -0
  40. package/amplify/plugins/newsletter/config.ts +305 -0
  41. package/amplify/plugins/newsletter/function/handler.py +6 -0
  42. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  43. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  44. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  45. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  46. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  48. package/amplify/plugins/newsletter/schema.js +56 -0
  49. package/amplify/plugins/newsletter/schema.ts +61 -0
  50. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  51. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  52. package/amplify/plugins/newsletter/sending.js +140 -0
  53. package/amplify/plugins/newsletter/sending.ts +186 -0
  54. package/amplify/plugins/reader-steering/config.js +180 -0
  55. package/amplify/plugins/reader-steering/config.ts +280 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  58. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  59. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  60. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  61. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  62. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.js +184 -0
  64. package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
  65. package/amplify/plugins/reader-steering/schema.js +204 -0
  66. package/amplify/plugins/reader-steering/schema.ts +206 -0
  67. package/amplify/plugins/registry.js +4 -2
  68. package/amplify/plugins/registry.ts +4 -2
  69. package/amplify/plugins/types.ts +5 -0
  70. package/amplify/plugins/wire.js +1 -0
  71. package/amplify/plugins/wire.ts +1 -0
  72. package/amplify/publication/projection-lease.js +30 -0
  73. package/amplify/publication/projection-lease.ts +39 -0
  74. package/amplify/publication/redirect-and-index.js +105 -0
  75. package/amplify/publication/redirect-rules.js +73 -0
  76. package/amplify/publication/redirect-rules.ts +93 -0
  77. package/amplify/publication/s3-projection.js +7 -1
  78. package/amplify/publication/s3-projection.ts +9 -1
  79. package/amplify/site-backend-config.ts +17 -0
  80. package/amplify/site-backend.js +38 -1
  81. package/amplify/site-backend.ts +46 -1
  82. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  83. package/app/api/newsletter/confirm/route.ts +13 -0
  84. package/app/api/newsletter/preferences/route.ts +13 -0
  85. package/app/api/newsletter/state/route.ts +9 -0
  86. package/app/api/newsletter/subscribe/route.ts +9 -0
  87. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  88. package/app/api/newsroom/curation/context/route.ts +9 -0
  89. package/app/api/newsroom/curation/priming/route.ts +9 -0
  90. package/app/api/newsroom/curation/publication/route.ts +9 -0
  91. package/app/api/reader/comment/delete/route.ts +9 -0
  92. package/app/api/reader/comment/edit/route.ts +9 -0
  93. package/app/api/reader/comment/route.ts +9 -0
  94. package/app/api/reader/flag/route.ts +9 -0
  95. package/app/api/reader/handle/route.ts +9 -0
  96. package/app/api/reader/me/route.ts +9 -0
  97. package/app/api/reader/rating/route.ts +9 -0
  98. package/app/api/reader/report/route.ts +9 -0
  99. package/app/api/reader/retired/route.ts +9 -0
  100. package/app/api/reader/submit/route.ts +9 -0
  101. package/app/api/reader/vote/route.ts +9 -0
  102. package/bin/papyrus-infra.mjs +20 -1
  103. package/components/editorial-checks-admin-panel.tsx +36 -11
  104. package/components/moderation-panel.tsx +248 -0
  105. package/components/monitoring-overview-view.tsx +241 -0
  106. package/components/monitoring-panel.tsx +40 -0
  107. package/components/newsletter-panel.tsx +474 -0
  108. package/components/newsletter-sections.tsx +163 -0
  109. package/components/newsroom-article-editor.tsx +2 -4
  110. package/components/newsroom-curation-bootstrap.tsx +45 -0
  111. package/components/newsroom-organizer.css +31 -0
  112. package/components/newsroom-references-view.tsx +14 -14
  113. package/components/publication-cyclotron-panel.tsx +15 -10
  114. package/components/reference-add-entry.tsx +75 -0
  115. package/components/reference-curation-controls.tsx +31 -22
  116. package/components/reference-cyclotron-priming.tsx +58 -19
  117. package/components/reference-cyclotron-suggestion.tsx +1 -1
  118. package/components/reference-priming.tsx +14 -10
  119. package/components/reference-section-suggestion.tsx +1 -1
  120. package/components/topic-steering-workspace.tsx +64 -20
  121. package/components/use-editorial-checks.ts +12 -10
  122. package/infra/amplify-app-shell.js +90 -16
  123. package/infra/amplify-app-shell.ts +102 -18
  124. package/infra/index.js +1 -0
  125. package/infra/secure-string-parameter-handler.js +41 -0
  126. package/infra/secure-string-parameter-handler.ts +41 -0
  127. package/infra/secure-string-parameter.js +79 -0
  128. package/infra/secure-string-parameter.ts +91 -0
  129. package/infra/site-config.js +86 -6
  130. package/infra/site-config.ts +129 -6
  131. package/lib/category-repository.ts +11 -9
  132. package/lib/curation-routes.ts +345 -0
  133. package/lib/editorial-checks-backend.ts +1 -1
  134. package/lib/editorial-checks-client.ts +19 -1
  135. package/lib/editorial-checks-demo.ts +2 -2
  136. package/lib/editorial-checks-settings.ts +3 -0
  137. package/lib/editorial-checks.ts +8 -11
  138. package/lib/editorial-preview.ts +5 -5
  139. package/lib/monitoring-client.ts +29 -0
  140. package/lib/monitoring-overview-fixture.ts +51 -0
  141. package/lib/monitoring-overview.ts +145 -0
  142. package/lib/newsletter-api.ts +418 -0
  143. package/lib/newsletter-client.ts +46 -0
  144. package/lib/newsletter-fixture.ts +184 -0
  145. package/lib/newsletter-model.ts +202 -0
  146. package/lib/prospectus-session.ts +15 -14
  147. package/lib/prospectus-sign-in-routes.ts +84 -19
  148. package/lib/prospectus-site-identity.ts +24 -1
  149. package/lib/reader-api.ts +145 -0
  150. package/lib/reader-moderation.ts +220 -0
  151. package/lib/reader-retirement-route.ts +89 -0
  152. package/lib/reader-subject.md +29 -0
  153. package/lib/reader-subject.ts +53 -0
  154. package/lib/relevance-status.ts +2 -2
  155. package/lib/semantic-graph.ts +2 -0
  156. package/lib/site-brand-tenant.ts +11 -6
  157. package/lib/site-brand.ts +6 -3
  158. package/lib/site-origin.ts +67 -0
  159. package/middleware.ts +14 -3
  160. package/package.json +42 -3
  161. package/routes.manifest.json +215 -2
  162. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  163. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
  164. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
  165. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -14,6 +14,9 @@ import type { ReactNode, RefObject } from "react";
14
14
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
15
15
  import amplifyOutputs from "papyrus-amplify-outputs";
16
16
  import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
+ import { MonitoringPanel } from "./monitoring-panel";
18
+ import { ModerationPanel } from "./moderation-panel";
19
+ import { NewsletterPanel } from "./newsletter-panel";
17
20
  import { SignupEntriesPanel } from "./signup-entries-panel";
18
21
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
19
22
  import { generateClient } from "aws-amplify/data";
@@ -89,6 +92,7 @@ import { ReferenceSourcePreview } from "./reference-source-preview";
89
92
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
90
93
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
91
94
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
95
+ import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
92
96
  import { NewsroomReferencesView } from "./newsroom-references-view";
93
97
  import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
94
98
  import { NewsroomTopicsView } from "./newsroom-topics-view";
@@ -247,7 +251,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
247
251
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
248
252
  type AssignmentDeskViewMode = "queue" | "budget";
249
253
  type UserRoleAction = "grant" | "revoke";
250
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks";
254
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
251
255
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
252
256
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
253
257
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -879,7 +883,7 @@ function NewsDeskDashboard({
879
883
  const [mergeSelection, setMergeSelection] = useState<MergeSelection | null>(null);
880
884
  const [isPending, startTransition] = useTransition();
881
885
  const controlsDisabled = isPending || !canEdit;
882
- const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel);
886
+ const initialAdministrationPanel = normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo));
883
887
  const [administrationPanel, setAdministrationPanel] = useState<AdministrationPanel>(initialAdministrationPanel);
884
888
  const [doctrineDrafts, setDoctrineDrafts] = useState<DoctrineEditorState>(() => buildDoctrineEditorState(dashboard.doctrineRecords));
885
889
  const [loadedSections, setLoadedSections] = useState<Record<string, boolean>>({
@@ -1120,8 +1124,8 @@ function NewsDeskDashboard({
1120
1124
  }, [dashboard.procedureDefinitions, dashboard.procedureRuns, dashboard.procedureVersions]);
1121
1125
 
1122
1126
  useEffect(() => {
1123
- setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel));
1124
- }, [initialSelection.panel]);
1127
+ setAdministrationPanel(normalizeAdministrationPanel(initialSelection.panel, Boolean(dashboard.isDemo)));
1128
+ }, [initialSelection.panel, dashboard.isDemo]);
1125
1129
 
1126
1130
  useEffect(() => {
1127
1131
  if (activeTab !== "administration") return;
@@ -3115,6 +3119,7 @@ function NewsDeskDashboard({
3115
3119
  />
3116
3120
  ) : null}
3117
3121
  {activeTab === "references" ? (
3122
+ <NewsroomCurationBootstrap enabled={!dashboard.isDemo}>
3118
3123
  <NewsroomReferencesView
3119
3124
  demo={Boolean(dashboard.isDemo)}
3120
3125
  disabled={controlsDisabled}
@@ -3152,6 +3157,7 @@ function NewsDeskDashboard({
3152
3157
  referenceAttachments={referenceAttachments}
3153
3158
  references={references}
3154
3159
  />
3160
+ </NewsroomCurationBootstrap>
3155
3161
  ) : null}
3156
3162
  {activeTab === "messages" ? (
3157
3163
  <MessagesDeskView
@@ -4318,17 +4324,24 @@ function AdministrationDeskView({
4318
4324
  <strong>Agent delegations</strong>
4319
4325
  <span>Let an agent curate References for you</span>
4320
4326
  </Link>
4321
- {editorialChecksPluginEnabled() ? (
4322
- <Link
4323
- href={administrationPanelHref("editorial-checks", isDemo)}
4324
- data-news-desk-admin-nav="editorial-checks"
4325
- data-active={administrationPanel === "editorial-checks" || undefined}
4326
- onClick={() => onPanelChange("editorial-checks")}
4327
- >
4328
- <strong>Editorial checks</strong>
4329
- <span>Policies the article editor checks drafts against</span>
4330
- </Link>
4331
- ) : null}
4327
+ <Link
4328
+ href={administrationPanelHref("editorial-checks", isDemo)}
4329
+ data-news-desk-admin-nav="editorial-checks"
4330
+ data-active={administrationPanel === "editorial-checks" || undefined}
4331
+ onClick={() => onPanelChange("editorial-checks")}
4332
+ >
4333
+ <strong>Editorial checks</strong>
4334
+ <span>Policies the article editor checks drafts against</span>
4335
+ </Link>
4336
+ <Link
4337
+ href={administrationPanelHref("monitoring", isDemo)}
4338
+ data-news-desk-admin-nav="monitoring"
4339
+ data-active={administrationPanel === "monitoring" || undefined}
4340
+ onClick={() => onPanelChange("monitoring")}
4341
+ >
4342
+ <strong>Monitoring</strong>
4343
+ <span>The automated research pipeline, read only</span>
4344
+ </Link>
4332
4345
  {signupPluginEnabled() ? (
4333
4346
  <Link
4334
4347
  href={administrationPanelHref("signups", isDemo)}
@@ -4340,10 +4353,35 @@ function AdministrationDeskView({
4340
4353
  <span>Addresses from the site&apos;s signup forms</span>
4341
4354
  </Link>
4342
4355
  ) : null}
4356
+ {newsletterPluginEnabled(isDemo) ? (
4357
+ <Link
4358
+ href={administrationPanelHref("newsletter", isDemo)}
4359
+ data-news-desk-admin-nav="newsletter"
4360
+ data-active={administrationPanel === "newsletter" || undefined}
4361
+ onClick={() => onPanelChange("newsletter")}
4362
+ >
4363
+ <strong>Newsletter</strong>
4364
+ <span>Subscribers, issues and delivery for the site&apos;s email</span>
4365
+ </Link>
4366
+ ) : null}
4367
+ {readerPluginEnabled() ? (
4368
+ <Link
4369
+ href={administrationPanelHref("moderation", isDemo)}
4370
+ data-news-desk-admin-nav="moderation"
4371
+ data-active={administrationPanel === "moderation" || undefined}
4372
+ onClick={() => onPanelChange("moderation")}
4373
+ >
4374
+ <strong>Moderation</strong>
4375
+ <span>Reader submissions and comments</span>
4376
+ </Link>
4377
+ ) : null}
4343
4378
  </nav>}
4344
4379
  <div className="news-desk-settings-panel">
4345
4380
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4381
+ {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4382
+ {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4346
4383
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4384
+ {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4347
4385
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
4348
4386
  {administrationPanel === "users" ? (
4349
4387
  <AdministrationUsersPanel
@@ -12807,14 +12845,20 @@ function signupPluginEnabled(): boolean {
12807
12845
  return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
12808
12846
  }
12809
12847
 
12810
- function editorialChecksPluginEnabled(): boolean {
12811
- return pluginSettings(amplifyOutputs as PluginOutputs, "editorial-checks") !== null;
12848
+ /** The Newsletter panel exists when the site enabled the plugin; `?demo=1` shows it with fixture data so it can be seen and tested. */
12849
+ function newsletterPluginEnabled(demo = false): boolean {
12850
+ return demo || pluginSettings(amplifyOutputs as PluginOutputs, "newsletter") !== null;
12851
+ }
12852
+
12853
+ function readerPluginEnabled(): boolean {
12854
+ return pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null;
12812
12855
  }
12813
12856
 
12814
- function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
12815
- if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations") return value;
12857
+ function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12858
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12816
12859
  if (value === "signups" && signupPluginEnabled()) return value;
12817
- if (value === "editorial-checks" && editorialChecksPluginEnabled()) return value;
12860
+ if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12861
+ if (value === "moderation" && readerPluginEnabled()) return value;
12818
12862
  return "users";
12819
12863
  }
12820
12864
 
@@ -27,14 +27,16 @@ export function useEditorialChecks({
27
27
  section,
28
28
  itemId,
29
29
  }: {
30
- /** null turns editorial checks off. */
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 enabled = backend !== null;
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 (!enabled) return;
50
- backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined);
51
- }, [backend, enabled]);
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,10 @@
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 * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
4
+ import { SecureStringParameter } from "./secure-string-parameter.js";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
6
6
  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";
7
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
8
8
  function environmentVariables(variables) {
9
9
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
10
10
  }
@@ -68,6 +68,9 @@ export class AmplifyAppShellStack extends Stack {
68
68
  if (!config.reader)
69
69
  throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
70
70
  this.readerAppId = this.addReaderApp(config, config.reader);
71
+ if (config.autoDeploy) {
72
+ this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
73
+ }
71
74
  return;
72
75
  }
73
76
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
@@ -89,33 +92,49 @@ export class AmplifyAppShellStack extends Stack {
89
92
  });
90
93
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
91
94
  if (revalidateSecretParameterName) {
95
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
96
+ parameterName: revalidateSecretParameterName,
97
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
98
+ });
92
99
  computeRole.addToPolicy(new iam.PolicyStatement({
93
100
  sid: "ReadRevalidateSecret",
94
101
  actions: ["ssm:GetParameter"],
95
- resources: [
96
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
97
- ],
102
+ resources: [revalidateSecret.parameterArn],
98
103
  }));
99
104
  }
100
105
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
101
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
102
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
106
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
107
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
108
+ parameterName: resolveProspectusSessionSealingParameterName(config),
103
109
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
104
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
105
110
  })
106
111
  : undefined;
107
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
112
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
108
113
  ? {
109
114
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
110
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
115
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
111
116
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
112
117
  }
113
118
  : {};
114
- if (prospectusSessionSealingSecret) {
119
+ if (prospectusSessionSealing) {
115
120
  computeRole.addToPolicy(new iam.PolicyStatement({
116
121
  sid: "ReadProspectusSessionSealingSecret",
117
- actions: ["secretsmanager:GetSecretValue"],
118
- resources: [prospectusSessionSealingSecret.secretArn],
122
+ actions: ["ssm:GetParameter"],
123
+ resources: [prospectusSessionSealing.parameterArn],
124
+ }));
125
+ }
126
+ if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
127
+ // Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
128
+ // route through the CMS route /api/reader/retired, which it authenticates with this secret.
129
+ const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
130
+ parameterName: resolveReaderPollSecretParameterName(config),
131
+ description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
132
+ });
133
+ prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
134
+ computeRole.addToPolicy(new iam.PolicyStatement({
135
+ sid: "ReadReaderPollSecret",
136
+ actions: ["ssm:GetParameter"],
137
+ resources: [readerPollSecret.parameterArn],
119
138
  }));
120
139
  }
121
140
  if (prospectusAccountsApiStageArn) {
@@ -153,9 +172,15 @@ export class AmplifyAppShellStack extends Stack {
153
172
  const stagingOrigin = stagingDomainName
154
173
  ? `https://${stagingDomainName}/`
155
174
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
175
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
176
+ // A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
177
+ const siteOrigin = resolveSiteOrigin(config);
178
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
179
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
156
180
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
157
181
  const defaultOriginsToAllow = [
158
182
  ...(config.cms.domainName ? [] : [productionOrigin]),
183
+ ...(siteOrigin ? [`${siteOrigin}/`] : []),
159
184
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
160
185
  ];
161
186
  const oauthRedirectUrls = defaultOriginsToAllow.length === 0
@@ -165,7 +190,8 @@ export class AmplifyAppShellStack extends Stack {
165
190
  appId: cmsApp.attrAppId,
166
191
  branchName: "main",
167
192
  stage: "PRODUCTION",
168
- enableAutoBuild: true,
193
+ // Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
194
+ enableAutoBuild: !config.autoDeploy,
169
195
  enablePerformanceMode: false,
170
196
  environmentVariables: environmentVariables({
171
197
  ...config.cms.environment,
@@ -174,6 +200,7 @@ export class AmplifyAppShellStack extends Stack {
174
200
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
175
201
  PAPYRUS_SITE_BRAND: config.brand,
176
202
  SITE_ENV: "production",
203
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
177
204
  PAPYRUS_CONTENT_SOURCE: "published",
178
205
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
179
206
  ...prospectusEnvironment,
@@ -195,6 +222,7 @@ export class AmplifyAppShellStack extends Stack {
195
222
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
196
223
  PAPYRUS_SITE_BRAND: config.brand,
197
224
  SITE_ENV: "staging",
225
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
198
226
  ...stagingMode,
199
227
  ...prospectusEnvironment,
200
228
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -247,6 +275,12 @@ export class AmplifyAppShellStack extends Stack {
247
275
  }
248
276
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
249
277
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
278
+ if (config.autoDeploy) {
279
+ this.addGithubDeployRole(config, [
280
+ { appId: cmsApp.attrAppId, branch: "main" },
281
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
282
+ ]);
283
+ }
250
284
  if (config.github) {
251
285
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
252
286
  }
@@ -294,6 +328,46 @@ export class AmplifyAppShellStack extends Stack {
294
328
  description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
295
329
  });
296
330
  }
331
+ /**
332
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
333
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
334
+ * production branch of the site's apps.
335
+ */
336
+ addGithubDeployRole(config, targets) {
337
+ const { owner, repo } = resolveGithubRepository(config);
338
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
339
+ service: "iam",
340
+ region: "",
341
+ resource: "oidc-provider",
342
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
343
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
344
+ }));
345
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
346
+ roleName: `${config.siteId}-github-deploy`,
347
+ 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`,
348
+ maxSessionDuration: Duration.hours(1),
349
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
350
+ StringEquals: {
351
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
352
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
353
+ },
354
+ }),
355
+ });
356
+ deployRole.addToPolicy(new iam.PolicyStatement({
357
+ sid: "StartAndReadProductionJobs",
358
+ actions: ["amplify:StartJob", "amplify:GetJob"],
359
+ resources: targets.map((target) => this.formatArn({
360
+ service: "amplify",
361
+ resource: "apps",
362
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
363
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
364
+ })),
365
+ }));
366
+ new CfnOutput(this, "GithubDeployRoleArn", {
367
+ value: deployRole.roleArn,
368
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
369
+ });
370
+ }
297
371
  addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
298
372
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
299
373
  service: "iam",
@@ -309,7 +383,7 @@ export class AmplifyAppShellStack extends Stack {
309
383
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
310
384
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
311
385
  StringLike: {
312
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
386
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
313
387
  },
314
388
  }),
315
389
  });
@@ -361,7 +435,7 @@ export class AmplifyAppShellStack extends Stack {
361
435
  appId: readerApp.attrAppId,
362
436
  branchName: readerBranchName,
363
437
  stage: "PRODUCTION",
364
- enableAutoBuild: true,
438
+ enableAutoBuild: !config.autoDeploy,
365
439
  environmentVariables: environmentVariables({
366
440
  ...(reader.environment ?? {}),
367
441
  PAPYRUS_SITE_BRAND: config.brand,
@@ -2,10 +2,10 @@ 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 * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
5
+ import { SecureStringParameter } from "./secure-string-parameter";
6
6
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
7
7
  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";
8
+ 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
9
 
10
10
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
@@ -72,6 +72,9 @@ export class AmplifyAppShellStack extends Stack {
72
72
  if (!hasCmsApp(config)) {
73
73
  if (!config.reader) throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
74
74
  this.readerAppId = this.addReaderApp(config, config.reader);
75
+ if (config.autoDeploy) {
76
+ this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
77
+ }
75
78
  return;
76
79
  }
77
80
 
@@ -97,37 +100,55 @@ export class AmplifyAppShellStack extends Stack {
97
100
 
98
101
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
99
102
  if (revalidateSecretParameterName) {
103
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
104
+ parameterName: revalidateSecretParameterName,
105
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
106
+ });
100
107
  computeRole.addToPolicy(
101
108
  new iam.PolicyStatement({
102
109
  sid: "ReadRevalidateSecret",
103
110
  actions: ["ssm:GetParameter"],
104
- resources: [
105
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
106
- ],
111
+ resources: [revalidateSecret.parameterArn],
107
112
  }),
108
113
  );
109
114
  }
110
115
 
111
116
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
112
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
113
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
117
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
118
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
119
+ parameterName: resolveProspectusSessionSealingParameterName(config),
114
120
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
115
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
116
121
  })
117
122
  : undefined;
118
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
123
+ const prospectusEnvironment: Record<string, string> = prospectusAccountsApiStageArn && prospectusSessionSealing
119
124
  ? {
120
125
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
121
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
126
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
122
127
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
123
128
  }
124
129
  : {};
125
- if (prospectusSessionSealingSecret) {
130
+ if (prospectusSessionSealing) {
126
131
  computeRole.addToPolicy(
127
132
  new iam.PolicyStatement({
128
133
  sid: "ReadProspectusSessionSealingSecret",
129
- actions: ["secretsmanager:GetSecretValue"],
130
- resources: [prospectusSessionSealingSecret.secretArn],
134
+ actions: ["ssm:GetParameter"],
135
+ resources: [prospectusSessionSealing.parameterArn],
136
+ }),
137
+ );
138
+ }
139
+ if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
140
+ // Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
141
+ // route through the CMS route /api/reader/retired, which it authenticates with this secret.
142
+ const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
143
+ parameterName: resolveReaderPollSecretParameterName(config),
144
+ description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
145
+ });
146
+ prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
147
+ computeRole.addToPolicy(
148
+ new iam.PolicyStatement({
149
+ sid: "ReadReaderPollSecret",
150
+ actions: ["ssm:GetParameter"],
151
+ resources: [readerPollSecret.parameterArn],
131
152
  }),
132
153
  );
133
154
  }
@@ -172,9 +193,15 @@ export class AmplifyAppShellStack extends Stack {
172
193
  const stagingOrigin = stagingDomainName
173
194
  ? `https://${stagingDomainName}/`
174
195
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
196
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
197
+ // A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
198
+ const siteOrigin = resolveSiteOrigin(config);
199
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
200
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
175
201
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
176
202
  const defaultOriginsToAllow = [
177
203
  ...(config.cms.domainName ? [] : [productionOrigin]),
204
+ ...(siteOrigin ? [`${siteOrigin}/`] : []),
178
205
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
179
206
  ];
180
207
  const oauthRedirectUrls = defaultOriginsToAllow.length === 0
@@ -185,7 +212,8 @@ export class AmplifyAppShellStack extends Stack {
185
212
  appId: cmsApp.attrAppId,
186
213
  branchName: "main",
187
214
  stage: "PRODUCTION",
188
- enableAutoBuild: true,
215
+ // Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
216
+ enableAutoBuild: !config.autoDeploy,
189
217
  enablePerformanceMode: false,
190
218
  environmentVariables: environmentVariables({
191
219
  ...config.cms.environment,
@@ -194,6 +222,7 @@ export class AmplifyAppShellStack extends Stack {
194
222
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
195
223
  PAPYRUS_SITE_BRAND: config.brand,
196
224
  SITE_ENV: "production",
225
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
197
226
  PAPYRUS_CONTENT_SOURCE: "published",
198
227
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
199
228
  ...prospectusEnvironment,
@@ -216,6 +245,7 @@ export class AmplifyAppShellStack extends Stack {
216
245
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
217
246
  PAPYRUS_SITE_BRAND: config.brand,
218
247
  SITE_ENV: "staging",
248
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
219
249
  ...stagingMode,
220
250
  ...prospectusEnvironment,
221
251
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -274,6 +304,13 @@ export class AmplifyAppShellStack extends Stack {
274
304
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
275
305
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
276
306
 
307
+ if (config.autoDeploy) {
308
+ this.addGithubDeployRole(config, [
309
+ { appId: cmsApp.attrAppId, branch: "main" },
310
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
311
+ ]);
312
+ }
313
+
277
314
  if (config.github) {
278
315
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
279
316
  }
@@ -326,6 +363,55 @@ export class AmplifyAppShellStack extends Stack {
326
363
  });
327
364
  }
328
365
 
366
+ /**
367
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
368
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
369
+ * production branch of the site's apps.
370
+ */
371
+ private addGithubDeployRole(config: AmplifyAppShellSiteConfig, targets: { appId: string; branch: string }[]): void {
372
+ const { owner, repo } = resolveGithubRepository(config);
373
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
374
+ this,
375
+ "GithubDeployOidcProvider",
376
+ this.formatArn({
377
+ service: "iam",
378
+ region: "",
379
+ resource: "oidc-provider",
380
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
381
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
382
+ }),
383
+ );
384
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
385
+ roleName: `${config.siteId}-github-deploy`,
386
+ 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`,
387
+ maxSessionDuration: Duration.hours(1),
388
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
389
+ StringEquals: {
390
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
391
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
392
+ },
393
+ }),
394
+ });
395
+ deployRole.addToPolicy(
396
+ new iam.PolicyStatement({
397
+ sid: "StartAndReadProductionJobs",
398
+ actions: ["amplify:StartJob", "amplify:GetJob"],
399
+ resources: targets.map((target) =>
400
+ this.formatArn({
401
+ service: "amplify",
402
+ resource: "apps",
403
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
404
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
405
+ }),
406
+ ),
407
+ }),
408
+ );
409
+ new CfnOutput(this, "GithubDeployRoleArn", {
410
+ value: deployRole.roleArn,
411
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
412
+ });
413
+ }
414
+
329
415
  private addGithubCiRole(
330
416
  config: AmplifyAppShellSiteConfig,
331
417
  github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
@@ -351,9 +437,7 @@ export class AmplifyAppShellStack extends Stack {
351
437
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
352
438
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
353
439
  StringLike: {
354
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
355
- (branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
356
- ),
440
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
357
441
  },
358
442
  }),
359
443
  });
@@ -420,7 +504,7 @@ export class AmplifyAppShellStack extends Stack {
420
504
  appId: readerApp.attrAppId,
421
505
  branchName: readerBranchName,
422
506
  stage: "PRODUCTION",
423
- enableAutoBuild: true,
507
+ enableAutoBuild: !config.autoDeploy,
424
508
  environmentVariables: environmentVariables({
425
509
  ...(reader.environment ?? {}),
426
510
  PAPYRUS_SITE_BRAND: config.brand,