@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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 (227) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +64 -3
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1589 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +487 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -1,13 +1,31 @@
1
+ import { parseCloudEmail } from "./cloud-email-config.js";
1
2
  export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
2
3
  export function hasCmsApp(config) {
3
4
  return config.cms !== undefined;
4
5
  }
6
+ export function resolveGithubRepository(config) {
7
+ if (config.github)
8
+ return { owner: config.github.owner, repo: config.github.repo };
9
+ const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
10
+ return { owner, repo };
11
+ }
12
+ /** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
13
+ export function githubOidcSubjects(config, ref) {
14
+ const { owner, repo } = resolveGithubRepository(config);
15
+ const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
16
+ const { ownerId, repoId } = config.github ?? {};
17
+ if (ownerId !== undefined && repoId !== undefined) {
18
+ subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
19
+ }
20
+ return subjects;
21
+ }
5
22
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
6
23
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
24
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
8
25
  const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
9
26
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
10
27
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
28
+ const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
11
29
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
12
30
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
13
31
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
@@ -18,7 +36,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
18
36
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
19
37
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
20
38
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
21
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
39
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
22
40
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
23
41
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
24
42
  "GET/v1/identities/me",
@@ -43,6 +61,12 @@ function originOfRedirectUrl(value) {
43
61
  return value;
44
62
  }
45
63
  }
64
+ export function resolveProspectusSessionSealingParameterName(config) {
65
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
66
+ }
67
+ export function resolveReaderPollSecretParameterName(config) {
68
+ return `/papyrus/${config.siteId}/reader-poll-secret`;
69
+ }
46
70
  export function resolveRevalidateSecretParameterName(config) {
47
71
  return `/papyrus/${config.siteId}/revalidate-secret`;
48
72
  }
@@ -168,6 +192,9 @@ export function resolveCmsHostName(config) {
168
192
  return undefined;
169
193
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
170
194
  }
195
+ export function resolveSiteOrigin(config) {
196
+ return config.cms.siteOrigin;
197
+ }
171
198
  export function resolveStagingDomainName(config) {
172
199
  if (!isStagingEnabled(config))
173
200
  return undefined;
@@ -199,7 +226,7 @@ function parseStagingPreview(value) {
199
226
  }
200
227
  function parseCms(value) {
201
228
  const cmsRecord = requireObject(value, "cms");
202
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
229
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
203
230
  const buildComputeType = cmsRecord.buildComputeType;
204
231
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
205
232
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -210,6 +237,12 @@ function parseCms(value) {
210
237
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
211
238
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
212
239
  }
240
+ if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
241
+ fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
242
+ }
243
+ if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
244
+ fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
245
+ }
213
246
  if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
214
247
  fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
215
248
  }
@@ -217,6 +250,7 @@ function parseCms(value) {
217
250
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
218
251
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
219
252
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
253
+ siteOrigin: optionalString(cmsRecord.siteOrigin, "cms.siteOrigin", SITE_ORIGIN_PATTERN, "https origin with no path, query or trailing slash, such as https://shipped.anth.us"),
220
254
  staging: cmsRecord.staging,
221
255
  cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
222
256
  applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
@@ -226,6 +260,7 @@ function parseCms(value) {
226
260
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
227
261
  redirects: parseRedirects(cmsRecord.redirects),
228
262
  deployStackInBuild: cmsRecord.deployStackInBuild,
263
+ readerRetirementPoll: cmsRecord.readerRetirementPoll,
229
264
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
230
265
  prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
231
266
  };
@@ -235,9 +270,16 @@ function parseCms(value) {
235
270
  }
236
271
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
237
272
  if (key in cms.environment) {
238
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
273
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
239
274
  }
240
275
  }
276
+ const privateChatEnabled = cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED;
277
+ if (privateChatEnabled !== undefined && privateChatEnabled !== "true" && privateChatEnabled !== "false") {
278
+ fail("cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED", "must be the string \"true\" or \"false\"");
279
+ }
280
+ if (cms.siteOrigin && cms.domainName) {
281
+ fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
282
+ }
241
283
  if (cms.staging === false && cms.stagingDomainName) {
242
284
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
243
285
  }
@@ -300,9 +342,20 @@ function validateOauthRedirectUrls(config) {
300
342
  }
301
343
  }
302
344
  }
345
+ function parseAws(value) {
346
+ if (value === undefined)
347
+ return undefined;
348
+ const record = requireObject(value, "aws");
349
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
350
+ return {
351
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
352
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
353
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
354
+ };
355
+ }
303
356
  export function parseSiteConfig(raw) {
304
357
  const record = requireObject(raw, "site");
305
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
358
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
306
359
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
307
360
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
308
361
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -315,6 +368,7 @@ export function parseSiteConfig(raw) {
315
368
  }
316
369
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
317
370
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
371
+ const aws = parseAws(record.aws);
318
372
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
319
373
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
320
374
  const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
@@ -352,12 +406,15 @@ export function parseSiteConfig(raw) {
352
406
  if (cms?.stagingPreview !== undefined && cms.staging === false) {
353
407
  fail("cms.stagingPreview", "must not be set when cms.staging is false");
354
408
  }
409
+ const autoDeploy = record.autoDeploy ?? true;
410
+ if (typeof autoDeploy !== "boolean")
411
+ fail("autoDeploy", "must be a boolean");
355
412
  let github;
356
413
  if (record.github !== undefined) {
357
414
  if (cms === undefined)
358
415
  fail("github", "requires cms (the CI role is scoped to the CMS app)");
359
416
  const githubRecord = requireObject(record.github, "github");
360
- rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
417
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
361
418
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
362
419
  const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
363
420
  if (repository !== `https://github.com/${owner}/${repo}`) {
@@ -378,7 +435,18 @@ export function parseSiteConfig(raw) {
378
435
  const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
379
436
  if (typeof ciCanDeployInfra !== "boolean")
380
437
  fail("github.ciCanDeployInfra", "must be a boolean");
381
- github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
438
+ const optionalId = (value, name) => {
439
+ if (value === undefined)
440
+ return undefined;
441
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
442
+ fail(name, "must be a positive integer (GitHub numeric id)");
443
+ return value;
444
+ };
445
+ const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
446
+ const repoId = optionalId(githubRecord.repoId, "github.repoId");
447
+ if ((ownerId === undefined) !== (repoId === undefined))
448
+ fail("github", "ownerId and repoId must be set together");
449
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
382
450
  }
383
451
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
384
452
  if (hasDomain && hostedZoneId === undefined) {
@@ -395,13 +463,31 @@ export function parseSiteConfig(raw) {
395
463
  frontend,
396
464
  hostedZoneId,
397
465
  stackName,
466
+ aws,
398
467
  cms,
468
+ email: parseCloudEmail(record.email, cms !== undefined, siteId),
399
469
  reader,
400
470
  papyrusVersion,
401
471
  storagePreviewPrefix,
472
+ autoDeploy: autoDeploy,
402
473
  github,
474
+ ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
403
475
  };
404
476
  if (hasCmsApp(config))
405
477
  validateOauthRedirectUrls(config);
406
478
  return config;
407
479
  }
480
+ /**
481
+ * The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
482
+ * deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
483
+ * one.
484
+ */
485
+ export function resolveDeployEnvironment(config) {
486
+ const account = config.aws?.accountId;
487
+ const region = config.aws?.region;
488
+ const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
489
+ if (!account || !region) {
490
+ throw new Error(`site "${config.siteId}" does not say where it deploys: add ${missing.join(" and ")} to its site.json, e.g. "aws": { "accountId": "<12-digit account>", "region": "us-east-1" }. No account is assumed.`);
491
+ }
492
+ return { account, region };
493
+ }
@@ -1,3 +1,5 @@
1
+ import { parseCloudEmail, type CloudEmailConfig } from "./cloud-email-config";
2
+
1
3
  export type CmsRedirect = {
2
4
  source: string;
3
5
  status: 301 | 302;
@@ -32,6 +34,8 @@ export type CmsConfig = {
32
34
  appName?: string;
33
35
  domainName?: string;
34
36
  domainPrefix?: string;
37
+ /** Public https origin of a site whose host is served by its own edge (no Amplify domain association). */
38
+ siteOrigin?: string;
35
39
  staging?: boolean;
36
40
  stagingPreview?: StagingPreviewConfig;
37
41
  cognitoDomainPrefix: string;
@@ -42,6 +46,12 @@ export type CmsConfig = {
42
46
  stagingDomainPrefix?: string;
43
47
  redirects?: CmsRedirect[];
44
48
  prospectusAccountsApiStageArn?: string;
49
+ /**
50
+ * The site is a public-reader site that polls Prospectus for retired reader subjects. The template then generates a
51
+ * shared secret that the scheduled snapshot run presents to the CMS route `/api/reader/retired` (requires
52
+ * `prospectusAccountsApiStageArn`; docs/reader-steering.md).
53
+ */
54
+ readerRetirementPoll?: boolean;
45
55
  deployStackInBuild?: boolean;
46
56
  };
47
57
 
@@ -53,7 +63,10 @@ export type AmplifyAppShellSiteConfig = {
53
63
  frontend: "pretext" | "markus-static";
54
64
  hostedZoneId?: string;
55
65
  stackName?: string;
66
+ aws?: SiteAwsConfig;
56
67
  cms?: CmsConfig;
68
+ /** Required for cloud CMS sites; independent of newsletter feature enablement. */
69
+ email?: CloudEmailConfig;
57
70
  reader?: {
58
71
  appName?: string;
59
72
  domainName?: string;
@@ -69,20 +82,67 @@ export type AmplifyAppShellSiteConfig = {
69
82
  };
70
83
  papyrusVersion: string;
71
84
  storagePreviewPrefix?: string;
85
+ /**
86
+ * The scheduled cloud knowledge run for this publication. The app-shell stack ignores it; it is validated by
87
+ * infra/scheduled-jobs/lib/knowledge-run-config.ts, which owns the schema.
88
+ */
89
+ knowledgeRun?: unknown;
90
+ /**
91
+ * Merge-to-main deploy from GitHub Actions through OIDC. When true (the default) the stack creates the
92
+ * `<siteId>-github-deploy` role and turns off Amplify's own auto-build on the production branches, so the
93
+ * public repository's workflow is the only trigger. Set false for a site whose repository is private:
94
+ * private repositories get no Actions minutes, so Amplify's own repository-connected auto-build stays on.
95
+ */
96
+ autoDeploy: boolean;
72
97
  github?: {
73
98
  owner: string;
74
99
  repo: string;
75
100
  branches: string[];
76
101
  ciCanDeployInfra: boolean;
102
+ /**
103
+ * GitHub's numeric ids of the owner and the repository. Repositories with the immutable OIDC subject
104
+ * (`repo:<owner>@<ownerId>/<repo>@<repoId>:ref:...`, see `gh api repos/<owner>/<repo>/actions/oidc/customization/sub`)
105
+ * present that form instead of `repo:<owner>/<repo>:ref:...`; with both ids set, roles trust both forms.
106
+ */
107
+ ownerId?: number;
108
+ repoId?: number;
77
109
  };
78
110
  };
79
111
 
112
+ /**
113
+ * Where the site's AWS resources live. `bin/app-shell.ts` deploys the stack into exactly this account and
114
+ * region (see `resolveDeployEnvironment`), and `papyrus site google-secrets` asserts `accountId` with STS
115
+ * before it writes anything.
116
+ */
117
+ export type SiteAwsConfig = {
118
+ profile?: string;
119
+ accountId?: string;
120
+ region?: string;
121
+ };
122
+
80
123
  export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
81
124
 
82
125
  export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
83
126
  return config.cms !== undefined;
84
127
  }
85
128
 
129
+ export function resolveGithubRepository(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">): { owner: string; repo: string } {
130
+ if (config.github) return { owner: config.github.owner, repo: config.github.repo };
131
+ const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
132
+ return { owner, repo };
133
+ }
134
+
135
+ /** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
136
+ export function githubOidcSubjects(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">, ref: string): string[] {
137
+ const { owner, repo } = resolveGithubRepository(config);
138
+ const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
139
+ const { ownerId, repoId } = config.github ?? {};
140
+ if (ownerId !== undefined && repoId !== undefined) {
141
+ subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
142
+ }
143
+ return subjects;
144
+ }
145
+
86
146
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
87
147
 
88
148
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -90,6 +150,7 @@ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_
90
150
  const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
91
151
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
92
152
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
153
+ const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
93
154
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
94
155
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
95
156
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
@@ -100,7 +161,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
100
161
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
101
162
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
102
163
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
103
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
164
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
104
165
 
105
166
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
106
167
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -127,6 +188,14 @@ function originOfRedirectUrl(value: string): string {
127
188
  }
128
189
  }
129
190
 
191
+ export function resolveProspectusSessionSealingParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
192
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
193
+ }
194
+
195
+ export function resolveReaderPollSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
196
+ return `/papyrus/${config.siteId}/reader-poll-secret`;
197
+ }
198
+
130
199
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
131
200
  return `/papyrus/${config.siteId}/revalidate-secret`;
132
201
  }
@@ -251,6 +320,10 @@ export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string |
251
320
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
252
321
  }
253
322
 
323
+ export function resolveSiteOrigin(config: Pick<CmsSiteConfig, "cms">): string | undefined {
324
+ return config.cms.siteOrigin;
325
+ }
326
+
254
327
  export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
255
328
  if (!isStagingEnabled(config)) return undefined;
256
329
  if (config.cms.domainPrefix) {
@@ -282,7 +355,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
282
355
 
283
356
  function parseCms(value: unknown): CmsConfig {
284
357
  const cmsRecord = requireObject(value, "cms");
285
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
358
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
286
359
  const buildComputeType = cmsRecord.buildComputeType;
287
360
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
288
361
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -293,6 +366,12 @@ function parseCms(value: unknown): CmsConfig {
293
366
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
294
367
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
295
368
  }
369
+ if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
370
+ fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
371
+ }
372
+ if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
373
+ fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
374
+ }
296
375
  if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
297
376
  fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
298
377
  }
@@ -300,6 +379,7 @@ function parseCms(value: unknown): CmsConfig {
300
379
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
301
380
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
302
381
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
382
+ siteOrigin: optionalString(cmsRecord.siteOrigin, "cms.siteOrigin", SITE_ORIGIN_PATTERN, "https origin with no path, query or trailing slash, such as https://shipped.anth.us"),
303
383
  staging: cmsRecord.staging as boolean | undefined,
304
384
  cognitoDomainPrefix: requireString(
305
385
  cmsRecord.cognitoDomainPrefix,
@@ -314,6 +394,7 @@ function parseCms(value: unknown): CmsConfig {
314
394
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
315
395
  redirects: parseRedirects(cmsRecord.redirects),
316
396
  deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
397
+ readerRetirementPoll: cmsRecord.readerRetirementPoll as boolean | undefined,
317
398
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
318
399
  prospectusAccountsApiStageArn: optionalString(
319
400
  cmsRecord.prospectusAccountsApiStageArn,
@@ -329,10 +410,17 @@ function parseCms(value: unknown): CmsConfig {
329
410
  }
330
411
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
331
412
  if (key in cms.environment) {
332
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
413
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
333
414
  }
334
415
  }
416
+ const privateChatEnabled = cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED;
417
+ if (privateChatEnabled !== undefined && privateChatEnabled !== "true" && privateChatEnabled !== "false") {
418
+ fail("cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED", "must be the string \"true\" or \"false\"");
419
+ }
335
420
 
421
+ if (cms.siteOrigin && cms.domainName) {
422
+ fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
423
+ }
336
424
  if (cms.staging === false && cms.stagingDomainName) {
337
425
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
338
426
  }
@@ -395,11 +483,22 @@ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
395
483
  }
396
484
  }
397
485
 
486
+ function parseAws(value: unknown): SiteAwsConfig | undefined {
487
+ if (value === undefined) return undefined;
488
+ const record = requireObject(value, "aws");
489
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
490
+ return {
491
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
492
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
493
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
494
+ };
495
+ }
496
+
398
497
  export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
399
498
  const record = requireObject(raw, "site");
400
499
  rejectUnknownKeys(
401
500
  record,
402
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
501
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
403
502
  "site",
404
503
  );
405
504
 
@@ -415,6 +514,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
415
514
  }
416
515
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
417
516
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
517
+ const aws = parseAws(record.aws);
418
518
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
419
519
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
420
520
 
@@ -452,11 +552,14 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
452
552
  fail("cms.stagingPreview", "must not be set when cms.staging is false");
453
553
  }
454
554
 
555
+ const autoDeploy = record.autoDeploy ?? true;
556
+ if (typeof autoDeploy !== "boolean") fail("autoDeploy", "must be a boolean");
557
+
455
558
  let github: AmplifyAppShellSiteConfig["github"];
456
559
  if (record.github !== undefined) {
457
560
  if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
458
561
  const githubRecord = requireObject(record.github, "github");
459
- rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
562
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
460
563
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
461
564
  const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
462
565
  if (repository !== `https://github.com/${owner}/${repo}`) {
@@ -476,7 +579,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
476
579
  }
477
580
  const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
478
581
  if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
479
- github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
582
+ const optionalId = (value: unknown, name: string): number | undefined => {
583
+ if (value === undefined) return undefined;
584
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) fail(name, "must be a positive integer (GitHub numeric id)");
585
+ return value as number;
586
+ };
587
+ const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
588
+ const repoId = optionalId(githubRecord.repoId, "github.repoId");
589
+ if ((ownerId === undefined) !== (repoId === undefined)) fail("github", "ownerId and repoId must be set together");
590
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
480
591
  }
481
592
 
482
593
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
@@ -495,14 +606,35 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
495
606
  frontend,
496
607
  hostedZoneId,
497
608
  stackName,
609
+ aws,
498
610
  cms,
611
+ email: parseCloudEmail(record.email, cms !== undefined, siteId),
499
612
  reader,
500
613
  papyrusVersion,
501
614
  storagePreviewPrefix,
615
+ autoDeploy: autoDeploy as boolean,
502
616
  github,
617
+ ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
503
618
  };
504
619
 
505
620
  if (hasCmsApp(config)) validateOauthRedirectUrls(config);
506
621
 
507
622
  return config;
508
623
  }
624
+
625
+ /**
626
+ * The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
627
+ * deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
628
+ * one.
629
+ */
630
+ export function resolveDeployEnvironment(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "aws">): { account: string; region: string } {
631
+ const account = config.aws?.accountId;
632
+ const region = config.aws?.region;
633
+ const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
634
+ if (!account || !region) {
635
+ throw new Error(
636
+ `site "${config.siteId}" does not say where it deploys: add ${missing.join(" and ")} to its site.json, e.g. "aws": { "accountId": "<12-digit account>", "region": "us-east-1" }. No account is assumed.`,
637
+ );
638
+ }
639
+ return { account, region };
640
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { AutomationResult, RunNowResult } from "./automation-overview";
5
+
6
+ type GraphQLResult = { data?: { automationOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
7
+
8
+ /**
9
+ * Reads the automations through the site's own authenticated API (user pool, editor or admin).
10
+ * The browser holds no table name, key or secret: the function behind the query does the reads.
11
+ */
12
+ export async function loadAutomationOverview(): Promise<AutomationResult> {
13
+ const client = generateClient({ authMode: "userPool" });
14
+ let result: GraphQLResult;
15
+ try {
16
+ result = (await client.graphql({
17
+ query: `query AutomationOverview($automationId: String) { automationOverview(automationId: $automationId) }`,
18
+ variables: {},
19
+ })) as GraphQLResult;
20
+ } catch (error) {
21
+ // Amplify throws the response when AppSync returns errors.
22
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
23
+ }
24
+ const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
25
+ if (refused) return { error: "forbidden" };
26
+ if (result.errors?.length || result.data?.automationOverview === undefined || result.data.automationOverview === null) return { state: "unavailable" };
27
+ const raw = result.data.automationOverview;
28
+ return (typeof raw === "string" ? JSON.parse(raw) : raw) as AutomationResult;
29
+ }
30
+
31
+ type RunGraphQLResult = { data?: { runAutomation?: unknown }; errors?: { message?: string; errorType?: string }[] };
32
+
33
+ /**
34
+ * Starts a run of the automation's procedure now, as the signed-in editor or admin. The function behind the
35
+ * mutation checks the groups again, refuses a disabled automation or one at its limit, and writes the row that
36
+ * links the run to the automation's history. `input` is sent as JSON text; null uses the record's own input.
37
+ */
38
+ export async function runAutomationNow(automationId: string, input: Record<string, unknown> | null): Promise<RunNowResult> {
39
+ const client = generateClient({ authMode: "userPool" });
40
+ let result: RunGraphQLResult;
41
+ try {
42
+ result = (await client.graphql({
43
+ query: `mutation RunAutomation($automationId: String!, $input: AWSJSON) { runAutomation(automationId: $automationId, input: $input) }`,
44
+ variables: { automationId, input: input === null ? null : JSON.stringify(input) },
45
+ })) as RunGraphQLResult;
46
+ } catch (error) {
47
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as RunGraphQLResult;
48
+ }
49
+ const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
50
+ if (refused) return { error: "forbidden" };
51
+ if (result.errors?.length || result.data?.runAutomation === undefined || result.data.runAutomation === null) return { state: "unavailable" };
52
+ const raw = result.data.runAutomation;
53
+ return (typeof raw === "string" ? JSON.parse(raw) : raw) as RunNowResult;
54
+ }