@anthusai/papyrus 2.0.0-next.5 → 2.0.0-next.50

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 (162) 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 +53 -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/functions/editorial-checks/handler.py +14 -0
  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 +195 -0
  40. package/amplify/plugins/newsletter/config.ts +240 -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 +260 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -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 +162 -0
  55. package/amplify/plugins/reader-steering/config.ts +248 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1363 -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/resource.js +27 -0
  61. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  62. package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
  64. package/amplify/plugins/reader-steering/schema.js +140 -0
  65. package/amplify/plugins/reader-steering/schema.ts +142 -0
  66. package/amplify/plugins/registry.js +4 -2
  67. package/amplify/plugins/registry.ts +4 -2
  68. package/amplify/plugins/types.ts +5 -0
  69. package/amplify/plugins/wire.js +1 -0
  70. package/amplify/plugins/wire.ts +1 -0
  71. package/amplify/publication/redirect-and-index.js +105 -0
  72. package/amplify/publication/redirect-rules.js +73 -0
  73. package/amplify/publication/redirect-rules.ts +93 -0
  74. package/amplify/publication/s3-projection.js +7 -1
  75. package/amplify/publication/s3-projection.ts +9 -1
  76. package/amplify/site-backend-config.ts +17 -0
  77. package/amplify/site-backend.js +36 -1
  78. package/amplify/site-backend.ts +44 -1
  79. package/app/api/newsletter/confirm/route.ts +13 -0
  80. package/app/api/newsletter/preferences/route.ts +13 -0
  81. package/app/api/newsletter/subscribe/route.ts +9 -0
  82. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  83. package/app/api/newsroom/curation/context/route.ts +9 -0
  84. package/app/api/newsroom/curation/priming/route.ts +9 -0
  85. package/app/api/newsroom/curation/publication/route.ts +9 -0
  86. package/app/api/reader/comment/delete/route.ts +9 -0
  87. package/app/api/reader/comment/edit/route.ts +9 -0
  88. package/app/api/reader/comment/route.ts +9 -0
  89. package/app/api/reader/handle/route.ts +9 -0
  90. package/app/api/reader/me/route.ts +9 -0
  91. package/app/api/reader/rating/route.ts +9 -0
  92. package/app/api/reader/report/route.ts +9 -0
  93. package/app/api/reader/retired/route.ts +9 -0
  94. package/app/api/reader/submit/route.ts +9 -0
  95. package/app/api/reader/vote/route.ts +9 -0
  96. package/bin/papyrus-infra.mjs +20 -1
  97. package/components/editorial-checks-admin-panel.tsx +36 -11
  98. package/components/moderation-panel.tsx +248 -0
  99. package/components/monitoring-overview-view.tsx +241 -0
  100. package/components/monitoring-panel.tsx +40 -0
  101. package/components/newsletter-panel.tsx +474 -0
  102. package/components/newsletter-sections.tsx +163 -0
  103. package/components/newsroom-article-editor.tsx +2 -4
  104. package/components/newsroom-board-view.tsx +4 -1
  105. package/components/newsroom-curation-bootstrap.tsx +45 -0
  106. package/components/newsroom-organizer.css +31 -0
  107. package/components/newsroom-references-view.tsx +14 -14
  108. package/components/publication-cyclotron-panel.tsx +15 -10
  109. package/components/reference-add-entry.tsx +75 -0
  110. package/components/reference-curation-controls.tsx +31 -22
  111. package/components/reference-cyclotron-priming.tsx +58 -19
  112. package/components/reference-cyclotron-suggestion.tsx +1 -1
  113. package/components/reference-priming.tsx +14 -10
  114. package/components/reference-section-suggestion.tsx +1 -1
  115. package/components/topic-steering-workspace.tsx +64 -20
  116. package/components/use-editorial-checks.ts +12 -10
  117. package/infra/amplify-app-shell.js +90 -16
  118. package/infra/amplify-app-shell.ts +102 -18
  119. package/infra/index.js +1 -0
  120. package/infra/secure-string-parameter-handler.js +41 -0
  121. package/infra/secure-string-parameter-handler.ts +41 -0
  122. package/infra/secure-string-parameter.js +79 -0
  123. package/infra/secure-string-parameter.ts +91 -0
  124. package/infra/site-config.js +85 -6
  125. package/infra/site-config.ts +123 -6
  126. package/lib/category-repository.ts +11 -9
  127. package/lib/curation-routes.ts +345 -0
  128. package/lib/editorial-checks-backend.ts +1 -1
  129. package/lib/editorial-checks-client.ts +19 -1
  130. package/lib/editorial-checks-demo.ts +2 -2
  131. package/lib/editorial-checks-settings.ts +3 -0
  132. package/lib/editorial-checks.ts +8 -11
  133. package/lib/editorial-preview.ts +5 -5
  134. package/lib/monitoring-client.ts +29 -0
  135. package/lib/monitoring-overview-fixture.ts +51 -0
  136. package/lib/monitoring-overview.ts +145 -0
  137. package/lib/newsletter-api.ts +391 -0
  138. package/lib/newsletter-client.ts +46 -0
  139. package/lib/newsletter-fixture.ts +184 -0
  140. package/lib/newsletter-model.ts +202 -0
  141. package/lib/newsletter-pages.ts +264 -0
  142. package/lib/newsroom-board.ts +27 -2
  143. package/lib/prospectus-session.ts +15 -14
  144. package/lib/prospectus-sign-in-routes.ts +54 -13
  145. package/lib/prospectus-site-identity.ts +24 -1
  146. package/lib/reader-api.ts +145 -0
  147. package/lib/reader-moderation.ts +220 -0
  148. package/lib/reader-retirement-route.ts +89 -0
  149. package/lib/reader-subject.md +29 -0
  150. package/lib/reader-subject.ts +53 -0
  151. package/lib/relevance-status.ts +2 -2
  152. package/lib/site-brand-tenant.ts +11 -6
  153. package/lib/site-brand.ts +6 -3
  154. package/lib/site-origin.ts +67 -0
  155. package/middleware.ts +14 -3
  156. package/package.json +42 -4
  157. package/routes.manifest.json +191 -1
  158. package/amplify/plugins/editorial-checks/function/handler.py +0 -11
  159. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  160. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
  161. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
  162. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -2,12 +2,29 @@ export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
2
2
  export function hasCmsApp(config) {
3
3
  return config.cms !== undefined;
4
4
  }
5
+ export function resolveGithubRepository(config) {
6
+ if (config.github)
7
+ return { owner: config.github.owner, repo: config.github.repo };
8
+ const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
9
+ return { owner, repo };
10
+ }
11
+ /** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
12
+ export function githubOidcSubjects(config, ref) {
13
+ const { owner, repo } = resolveGithubRepository(config);
14
+ const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
15
+ const { ownerId, repoId } = config.github ?? {};
16
+ if (ownerId !== undefined && repoId !== undefined) {
17
+ subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
18
+ }
19
+ return subjects;
20
+ }
5
21
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
6
22
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
23
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
8
24
  const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
9
25
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
10
26
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
27
+ const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
11
28
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
12
29
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
13
30
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
@@ -18,7 +35,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
18
35
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
19
36
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
20
37
  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"];
38
+ 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_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
22
39
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
23
40
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
24
41
  "GET/v1/identities/me",
@@ -43,6 +60,12 @@ function originOfRedirectUrl(value) {
43
60
  return value;
44
61
  }
45
62
  }
63
+ export function resolveProspectusSessionSealingParameterName(config) {
64
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
65
+ }
66
+ export function resolveReaderPollSecretParameterName(config) {
67
+ return `/papyrus/${config.siteId}/reader-poll-secret`;
68
+ }
46
69
  export function resolveRevalidateSecretParameterName(config) {
47
70
  return `/papyrus/${config.siteId}/revalidate-secret`;
48
71
  }
@@ -168,6 +191,9 @@ export function resolveCmsHostName(config) {
168
191
  return undefined;
169
192
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
170
193
  }
194
+ export function resolveSiteOrigin(config) {
195
+ return config.cms.siteOrigin;
196
+ }
171
197
  export function resolveStagingDomainName(config) {
172
198
  if (!isStagingEnabled(config))
173
199
  return undefined;
@@ -199,7 +225,7 @@ function parseStagingPreview(value) {
199
225
  }
200
226
  function parseCms(value) {
201
227
  const cmsRecord = requireObject(value, "cms");
202
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
228
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
203
229
  const buildComputeType = cmsRecord.buildComputeType;
204
230
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
205
231
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -210,6 +236,12 @@ function parseCms(value) {
210
236
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
211
237
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
212
238
  }
239
+ if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
240
+ fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
241
+ }
242
+ if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
243
+ fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
244
+ }
213
245
  if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
214
246
  fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
215
247
  }
@@ -217,6 +249,7 @@ function parseCms(value) {
217
249
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
218
250
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
219
251
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
252
+ 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
253
  staging: cmsRecord.staging,
221
254
  cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
222
255
  applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
@@ -226,6 +259,7 @@ function parseCms(value) {
226
259
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
227
260
  redirects: parseRedirects(cmsRecord.redirects),
228
261
  deployStackInBuild: cmsRecord.deployStackInBuild,
262
+ readerRetirementPoll: cmsRecord.readerRetirementPoll,
229
263
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
230
264
  prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
231
265
  };
@@ -235,9 +269,12 @@ function parseCms(value) {
235
269
  }
236
270
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
237
271
  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");
272
+ 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
273
  }
240
274
  }
275
+ if (cms.siteOrigin && cms.domainName) {
276
+ fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
277
+ }
241
278
  if (cms.staging === false && cms.stagingDomainName) {
242
279
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
243
280
  }
@@ -300,9 +337,20 @@ function validateOauthRedirectUrls(config) {
300
337
  }
301
338
  }
302
339
  }
340
+ function parseAws(value) {
341
+ if (value === undefined)
342
+ return undefined;
343
+ const record = requireObject(value, "aws");
344
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
345
+ return {
346
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
347
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
348
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
349
+ };
350
+ }
303
351
  export function parseSiteConfig(raw) {
304
352
  const record = requireObject(raw, "site");
305
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
353
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
306
354
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
307
355
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
308
356
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -315,6 +363,7 @@ export function parseSiteConfig(raw) {
315
363
  }
316
364
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
317
365
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
366
+ const aws = parseAws(record.aws);
318
367
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
319
368
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
320
369
  const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
@@ -352,12 +401,15 @@ export function parseSiteConfig(raw) {
352
401
  if (cms?.stagingPreview !== undefined && cms.staging === false) {
353
402
  fail("cms.stagingPreview", "must not be set when cms.staging is false");
354
403
  }
404
+ const autoDeploy = record.autoDeploy ?? true;
405
+ if (typeof autoDeploy !== "boolean")
406
+ fail("autoDeploy", "must be a boolean");
355
407
  let github;
356
408
  if (record.github !== undefined) {
357
409
  if (cms === undefined)
358
410
  fail("github", "requires cms (the CI role is scoped to the CMS app)");
359
411
  const githubRecord = requireObject(record.github, "github");
360
- rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
412
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
361
413
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
362
414
  const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
363
415
  if (repository !== `https://github.com/${owner}/${repo}`) {
@@ -378,7 +430,18 @@ export function parseSiteConfig(raw) {
378
430
  const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
379
431
  if (typeof ciCanDeployInfra !== "boolean")
380
432
  fail("github.ciCanDeployInfra", "must be a boolean");
381
- github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
433
+ const optionalId = (value, name) => {
434
+ if (value === undefined)
435
+ return undefined;
436
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
437
+ fail(name, "must be a positive integer (GitHub numeric id)");
438
+ return value;
439
+ };
440
+ const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
441
+ const repoId = optionalId(githubRecord.repoId, "github.repoId");
442
+ if ((ownerId === undefined) !== (repoId === undefined))
443
+ fail("github", "ownerId and repoId must be set together");
444
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
382
445
  }
383
446
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
384
447
  if (hasDomain && hostedZoneId === undefined) {
@@ -395,13 +458,29 @@ export function parseSiteConfig(raw) {
395
458
  frontend,
396
459
  hostedZoneId,
397
460
  stackName,
461
+ aws,
398
462
  cms,
399
463
  reader,
400
464
  papyrusVersion,
401
465
  storagePreviewPrefix,
466
+ autoDeploy: autoDeploy,
402
467
  github,
403
468
  };
404
469
  if (hasCmsApp(config))
405
470
  validateOauthRedirectUrls(config);
406
471
  return config;
407
472
  }
473
+ /**
474
+ * The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
475
+ * deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
476
+ * one.
477
+ */
478
+ export function resolveDeployEnvironment(config) {
479
+ const account = config.aws?.accountId;
480
+ const region = config.aws?.region;
481
+ const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
482
+ if (!account || !region) {
483
+ 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.`);
484
+ }
485
+ return { account, region };
486
+ }
@@ -32,6 +32,8 @@ export type CmsConfig = {
32
32
  appName?: string;
33
33
  domainName?: string;
34
34
  domainPrefix?: string;
35
+ /** Public https origin of a site whose host is served by its own edge (no Amplify domain association). */
36
+ siteOrigin?: string;
35
37
  staging?: boolean;
36
38
  stagingPreview?: StagingPreviewConfig;
37
39
  cognitoDomainPrefix: string;
@@ -42,6 +44,12 @@ export type CmsConfig = {
42
44
  stagingDomainPrefix?: string;
43
45
  redirects?: CmsRedirect[];
44
46
  prospectusAccountsApiStageArn?: string;
47
+ /**
48
+ * The site is a public-reader site that polls Prospectus for retired reader subjects. The template then generates a
49
+ * shared secret that the scheduled snapshot run presents to the CMS route `/api/reader/retired` (requires
50
+ * `prospectusAccountsApiStageArn`; docs/reader-steering.md).
51
+ */
52
+ readerRetirementPoll?: boolean;
45
53
  deployStackInBuild?: boolean;
46
54
  };
47
55
 
@@ -53,6 +61,7 @@ export type AmplifyAppShellSiteConfig = {
53
61
  frontend: "pretext" | "markus-static";
54
62
  hostedZoneId?: string;
55
63
  stackName?: string;
64
+ aws?: SiteAwsConfig;
56
65
  cms?: CmsConfig;
57
66
  reader?: {
58
67
  appName?: string;
@@ -69,20 +78,62 @@ export type AmplifyAppShellSiteConfig = {
69
78
  };
70
79
  papyrusVersion: string;
71
80
  storagePreviewPrefix?: string;
81
+ /**
82
+ * Merge-to-main deploy from GitHub Actions through OIDC. When true (the default) the stack creates the
83
+ * `<siteId>-github-deploy` role and turns off Amplify's own auto-build on the production branches, so the
84
+ * public repository's workflow is the only trigger. Set false for a site whose repository is private:
85
+ * private repositories get no Actions minutes, so Amplify's own repository-connected auto-build stays on.
86
+ */
87
+ autoDeploy: boolean;
72
88
  github?: {
73
89
  owner: string;
74
90
  repo: string;
75
91
  branches: string[];
76
92
  ciCanDeployInfra: boolean;
93
+ /**
94
+ * GitHub's numeric ids of the owner and the repository. Repositories with the immutable OIDC subject
95
+ * (`repo:<owner>@<ownerId>/<repo>@<repoId>:ref:...`, see `gh api repos/<owner>/<repo>/actions/oidc/customization/sub`)
96
+ * present that form instead of `repo:<owner>/<repo>:ref:...`; with both ids set, roles trust both forms.
97
+ */
98
+ ownerId?: number;
99
+ repoId?: number;
77
100
  };
78
101
  };
79
102
 
103
+ /**
104
+ * Where the site's AWS resources live. `bin/app-shell.ts` deploys the stack into exactly this account and
105
+ * region (see `resolveDeployEnvironment`), and `papyrus site google-secrets` asserts `accountId` with STS
106
+ * before it writes anything.
107
+ */
108
+ export type SiteAwsConfig = {
109
+ profile?: string;
110
+ accountId?: string;
111
+ region?: string;
112
+ };
113
+
80
114
  export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
81
115
 
82
116
  export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
83
117
  return config.cms !== undefined;
84
118
  }
85
119
 
120
+ export function resolveGithubRepository(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">): { owner: string; repo: string } {
121
+ if (config.github) return { owner: config.github.owner, repo: config.github.repo };
122
+ const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
123
+ return { owner, repo };
124
+ }
125
+
126
+ /** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
127
+ export function githubOidcSubjects(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">, ref: string): string[] {
128
+ const { owner, repo } = resolveGithubRepository(config);
129
+ const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
130
+ const { ownerId, repoId } = config.github ?? {};
131
+ if (ownerId !== undefined && repoId !== undefined) {
132
+ subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
133
+ }
134
+ return subjects;
135
+ }
136
+
86
137
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
87
138
 
88
139
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -90,6 +141,7 @@ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_
90
141
  const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
91
142
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
92
143
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
144
+ const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
93
145
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
94
146
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
95
147
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
@@ -100,7 +152,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
100
152
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
101
153
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
102
154
  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"];
155
+ 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_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
104
156
 
105
157
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
106
158
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -127,6 +179,14 @@ function originOfRedirectUrl(value: string): string {
127
179
  }
128
180
  }
129
181
 
182
+ export function resolveProspectusSessionSealingParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
183
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
184
+ }
185
+
186
+ export function resolveReaderPollSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
187
+ return `/papyrus/${config.siteId}/reader-poll-secret`;
188
+ }
189
+
130
190
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
131
191
  return `/papyrus/${config.siteId}/revalidate-secret`;
132
192
  }
@@ -251,6 +311,10 @@ export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string |
251
311
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
252
312
  }
253
313
 
314
+ export function resolveSiteOrigin(config: Pick<CmsSiteConfig, "cms">): string | undefined {
315
+ return config.cms.siteOrigin;
316
+ }
317
+
254
318
  export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
255
319
  if (!isStagingEnabled(config)) return undefined;
256
320
  if (config.cms.domainPrefix) {
@@ -282,7 +346,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
282
346
 
283
347
  function parseCms(value: unknown): CmsConfig {
284
348
  const cmsRecord = requireObject(value, "cms");
285
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
349
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
286
350
  const buildComputeType = cmsRecord.buildComputeType;
287
351
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
288
352
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -293,6 +357,12 @@ function parseCms(value: unknown): CmsConfig {
293
357
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
294
358
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
295
359
  }
360
+ if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
361
+ fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
362
+ }
363
+ if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
364
+ fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
365
+ }
296
366
  if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
297
367
  fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
298
368
  }
@@ -300,6 +370,7 @@ function parseCms(value: unknown): CmsConfig {
300
370
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
301
371
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
302
372
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
373
+ 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
374
  staging: cmsRecord.staging as boolean | undefined,
304
375
  cognitoDomainPrefix: requireString(
305
376
  cmsRecord.cognitoDomainPrefix,
@@ -314,6 +385,7 @@ function parseCms(value: unknown): CmsConfig {
314
385
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
315
386
  redirects: parseRedirects(cmsRecord.redirects),
316
387
  deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
388
+ readerRetirementPoll: cmsRecord.readerRetirementPoll as boolean | undefined,
317
389
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
318
390
  prospectusAccountsApiStageArn: optionalString(
319
391
  cmsRecord.prospectusAccountsApiStageArn,
@@ -329,10 +401,13 @@ function parseCms(value: unknown): CmsConfig {
329
401
  }
330
402
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
331
403
  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");
404
+ 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
405
  }
334
406
  }
335
407
 
408
+ if (cms.siteOrigin && cms.domainName) {
409
+ fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
410
+ }
336
411
  if (cms.staging === false && cms.stagingDomainName) {
337
412
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
338
413
  }
@@ -395,11 +470,22 @@ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
395
470
  }
396
471
  }
397
472
 
473
+ function parseAws(value: unknown): SiteAwsConfig | undefined {
474
+ if (value === undefined) return undefined;
475
+ const record = requireObject(value, "aws");
476
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
477
+ return {
478
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
479
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
480
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
481
+ };
482
+ }
483
+
398
484
  export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
399
485
  const record = requireObject(raw, "site");
400
486
  rejectUnknownKeys(
401
487
  record,
402
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
488
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
403
489
  "site",
404
490
  );
405
491
 
@@ -415,6 +501,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
415
501
  }
416
502
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
417
503
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
504
+ const aws = parseAws(record.aws);
418
505
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
419
506
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
420
507
 
@@ -452,11 +539,14 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
452
539
  fail("cms.stagingPreview", "must not be set when cms.staging is false");
453
540
  }
454
541
 
542
+ const autoDeploy = record.autoDeploy ?? true;
543
+ if (typeof autoDeploy !== "boolean") fail("autoDeploy", "must be a boolean");
544
+
455
545
  let github: AmplifyAppShellSiteConfig["github"];
456
546
  if (record.github !== undefined) {
457
547
  if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
458
548
  const githubRecord = requireObject(record.github, "github");
459
- rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
549
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
460
550
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
461
551
  const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
462
552
  if (repository !== `https://github.com/${owner}/${repo}`) {
@@ -476,7 +566,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
476
566
  }
477
567
  const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
478
568
  if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
479
- github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
569
+ const optionalId = (value: unknown, name: string): number | undefined => {
570
+ if (value === undefined) return undefined;
571
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) fail(name, "must be a positive integer (GitHub numeric id)");
572
+ return value as number;
573
+ };
574
+ const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
575
+ const repoId = optionalId(githubRecord.repoId, "github.repoId");
576
+ if ((ownerId === undefined) !== (repoId === undefined)) fail("github", "ownerId and repoId must be set together");
577
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
480
578
  }
481
579
 
482
580
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
@@ -495,10 +593,12 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
495
593
  frontend,
496
594
  hostedZoneId,
497
595
  stackName,
596
+ aws,
498
597
  cms,
499
598
  reader,
500
599
  papyrusVersion,
501
600
  storagePreviewPrefix,
601
+ autoDeploy: autoDeploy as boolean,
502
602
  github,
503
603
  };
504
604
 
@@ -506,3 +606,20 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
506
606
 
507
607
  return config;
508
608
  }
609
+
610
+ /**
611
+ * The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
612
+ * deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
613
+ * one.
614
+ */
615
+ export function resolveDeployEnvironment(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "aws">): { account: string; region: string } {
616
+ const account = config.aws?.accountId;
617
+ const region = config.aws?.region;
618
+ const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
619
+ if (!account || !region) {
620
+ throw new Error(
621
+ `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.`,
622
+ );
623
+ }
624
+ return { account, region };
625
+ }
@@ -200,7 +200,6 @@ export type ProcedureRunRecord = {
200
200
  newsroomFeedKey?: string | null;
201
201
  };
202
202
 
203
- const DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-newsroom-sections.yml");
204
203
  const NEWSROOM_SECTION_TYPES = new Set<NewsroomSectionType>(["canonical", "floating", "rotating"]);
205
204
  type NewsroomSectionSeedRow = Omit<NewsroomSectionRecord, "sortOrder" | "enabled" | "enabledStatus" | "createdAt" | "updatedAt"> & {
206
205
  enabled: boolean;
@@ -690,10 +689,10 @@ export type CategorySteeringDashboard = {
690
689
  loadError?: string | null;
691
690
  };
692
691
 
693
- const DEFAULT_ANALYSIS_PROFILES_PATH = path.join(process.cwd(), "corpora", "papyrus-analysis-profiles.yml");
694
- const DEFAULT_STEERING_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-steering.yml");
695
692
 
696
- export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_PROFILES_PATH): Promise<AnalysisProfileSummary[]> {
693
+ /** Analysis profiles the site keeps in a file; a site that configures none (data-driven sites) has none. No default file is looked up. */
694
+ export async function loadAnalysisProfileSummaries(filepath?: string): Promise<AnalysisProfileSummary[]> {
695
+ if (!filepath) return [];
697
696
  try {
698
697
  const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
699
698
  schemaVersion?: number;
@@ -722,7 +721,9 @@ export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_P
722
721
  }
723
722
  }
724
723
 
725
- export async function loadConfiguredCorpusSummaries(filepath = DEFAULT_STEERING_CONFIG_PATH): Promise<CategorySteeringCorpus[]> {
724
+ /** Corpora a site lists in a steering file; no configured file means none. No default file is looked up. */
725
+ export async function loadConfiguredCorpusSummaries(filepath?: string): Promise<CategorySteeringCorpus[]> {
726
+ if (!filepath) return [];
726
727
  try {
727
728
  const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
728
729
  corpora?: Array<Record<string, unknown>>;
@@ -818,7 +819,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
818
819
  });
819
820
  }
820
821
 
821
- function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH): NewsroomSectionRecord[] {
822
+ function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
823
+ if (!sectionsConfigPath) return [];
822
824
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
823
825
  ...row,
824
826
  enabledStatus: "enabled",
@@ -828,7 +830,7 @@ function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAUL
828
830
  }));
829
831
  }
830
832
 
831
- function loadNewsroomSectionSeedRows(sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH) {
833
+ function loadNewsroomSectionSeedRows(sectionsConfigPath: string) {
832
834
  const cached = newsroomSectionSeedRowsCache.get(sectionsConfigPath);
833
835
  if (cached) return cached;
834
836
  const parsed = YAML.parse(fs.readFileSync(sectionsConfigPath, "utf8")) as {
@@ -883,7 +885,7 @@ function normalizeNewsroomSectionSeedRow(
883
885
 
884
886
  export function createDemoCategorySteeringDashboard(
885
887
  brandId: SiteBrandId = resolveSiteBrandId(),
886
- newsroomSectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH,
888
+ newsroomSectionsConfigPath?: string,
887
889
  ): CategorySteeringDashboard {
888
890
  const importedAt = "2026-05-16T12:00:00.000Z";
889
891
  const profile = getNewsroomDemoProfile(brandId);
@@ -1698,7 +1700,7 @@ export function createDemoCategorySteeringDashboard(
1698
1700
  question: "Should this candidate source become a reference for this publication?",
1699
1701
  reviewControl: "thumbs",
1700
1702
  reviewReason: "program",
1701
- reviewDetail: "Every decision is reviewed while the cyclotron is onboarding.",
1703
+ reviewDetail: "Every suggestion is reviewed while suggestions are getting started.",
1702
1704
  version: 1,
1703
1705
  }),
1704
1706
  },