@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.40

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 (118) hide show
  1. package/amplify/data/resource.js +22 -3
  2. package/amplify/data/resource.ts +24 -3
  3. package/amplify/data/schema.js +2029 -1967
  4. package/amplify/data/schema.ts +72 -2
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/content-actions/handler.py +33 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -0
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/editorial-checks/handler.py +14 -0
  12. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  13. package/amplify/functions/editorial-checks/resource.js +43 -0
  14. package/amplify/functions/editorial-checks/resource.ts +60 -0
  15. package/amplify/functions/editorial-checks/schema.js +121 -0
  16. package/amplify/functions/editorial-checks/schema.ts +123 -0
  17. package/amplify/functions/editorial-checks/wire.js +53 -0
  18. package/amplify/functions/editorial-checks/wire.ts +61 -0
  19. package/amplify/functions/monitoring-view/handler.py +11 -0
  20. package/amplify/functions/monitoring-view/resource.js +24 -0
  21. package/amplify/functions/monitoring-view/resource.ts +34 -0
  22. package/amplify/functions/monitoring-view/schema.js +16 -0
  23. package/amplify/functions/monitoring-view/schema.ts +18 -0
  24. package/amplify/functions/monitoring-view/wire.js +37 -0
  25. package/amplify/functions/monitoring-view/wire.ts +47 -0
  26. package/amplify/functions/public-forms/core.js +247 -0
  27. package/amplify/functions/public-forms/core.ts +310 -0
  28. package/amplify/functions/public-forms/handler.ts +41 -0
  29. package/amplify/functions/public-forms/origins.js +35 -0
  30. package/amplify/functions/public-forms/origins.ts +43 -0
  31. package/amplify/functions/public-forms/resource.js +9 -0
  32. package/amplify/functions/public-forms/resource.ts +10 -0
  33. package/amplify/functions/public-forms/wire.js +25 -0
  34. package/amplify/functions/public-forms/wire.ts +32 -0
  35. package/amplify/functions/shared/python-bundle.js +20 -14
  36. package/amplify/functions/shared/python-bundle.ts +25 -18
  37. package/amplify/publication/redirect-and-index.js +105 -0
  38. package/amplify/publication/redirect-rules.js +73 -0
  39. package/amplify/publication/redirect-rules.ts +93 -0
  40. package/amplify/publication/s3-projection.js +7 -1
  41. package/amplify/publication/s3-projection.ts +9 -1
  42. package/amplify/site-backend-config.ts +17 -0
  43. package/amplify/site-backend.js +36 -1
  44. package/amplify/site-backend.ts +44 -1
  45. package/app/api/newsroom/curation/context/route.ts +9 -0
  46. package/app/api/newsroom/curation/priming/route.ts +9 -0
  47. package/app/api/newsroom/curation/publication/route.ts +9 -0
  48. package/bin/papyrus-infra.mjs +20 -1
  49. package/components/editorial-checks-admin-panel.tsx +362 -0
  50. package/components/editorial-finding-card.tsx +162 -0
  51. package/components/editorial-findings-panel.tsx +91 -0
  52. package/components/editorial-preview-popover.tsx +103 -0
  53. package/components/markus-body-editor.tsx +213 -0
  54. package/components/markus-ir-preview.tsx +147 -6
  55. package/components/monitoring-overview-view.tsx +241 -0
  56. package/components/monitoring-panel.tsx +40 -0
  57. package/components/newsroom-article-editor.tsx +136 -11
  58. package/components/newsroom-board-view.tsx +6 -7
  59. package/components/newsroom-curation-bootstrap.tsx +45 -0
  60. package/components/newsroom-ops-shell.tsx +7 -3
  61. package/components/newsroom-organizer.css +74 -61
  62. package/components/newsroom-references-view.tsx +14 -14
  63. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  64. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  65. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  66. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  67. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  68. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  69. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  70. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  71. package/components/newsroom-theme.css +39 -0
  72. package/components/newsroom-theme.tsx +33 -0
  73. package/components/publication-cyclotron-panel.tsx +15 -10
  74. package/components/reference-add-entry.tsx +75 -0
  75. package/components/reference-curation-controls.tsx +31 -22
  76. package/components/reference-cyclotron-priming.tsx +4 -4
  77. package/components/reference-cyclotron-suggestion.tsx +1 -1
  78. package/components/reference-priming.tsx +14 -10
  79. package/components/reference-section-suggestion.tsx +1 -1
  80. package/components/topic-steering-workspace.tsx +27 -2
  81. package/components/use-editorial-checks.ts +133 -0
  82. package/infra/amplify-app-shell.js +76 -16
  83. package/infra/amplify-app-shell.ts +86 -18
  84. package/infra/index.js +1 -0
  85. package/infra/secure-string-parameter-handler.js +41 -0
  86. package/infra/secure-string-parameter-handler.ts +41 -0
  87. package/infra/secure-string-parameter.js +79 -0
  88. package/infra/secure-string-parameter.ts +91 -0
  89. package/infra/site-config.js +75 -6
  90. package/infra/site-config.ts +106 -6
  91. package/lib/category-repository.ts +11 -9
  92. package/lib/curation-routes.ts +345 -0
  93. package/lib/editorial-checks-backend.ts +16 -0
  94. package/lib/editorial-checks-client.ts +212 -0
  95. package/lib/editorial-checks-demo.ts +147 -0
  96. package/lib/editorial-checks-settings.ts +27 -0
  97. package/lib/editorial-checks.ts +171 -0
  98. package/lib/editorial-preview.ts +228 -0
  99. package/lib/monitoring-client.ts +29 -0
  100. package/lib/monitoring-overview-fixture.ts +51 -0
  101. package/lib/monitoring-overview.ts +145 -0
  102. package/lib/newsroom-board.ts +27 -2
  103. package/lib/prospectus-session.ts +14 -14
  104. package/lib/prospectus-sign-in-routes.ts +52 -11
  105. package/lib/relevance-status.ts +2 -2
  106. package/lib/site-brand-tenant.ts +11 -6
  107. package/lib/site-brand.ts +23 -3
  108. package/lib/site-origin.ts +67 -0
  109. package/middleware.ts +14 -3
  110. package/package.json +48 -4
  111. package/routes.manifest.json +34 -1
  112. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  113. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  115. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  118. package/components/newsroom-newsprint-board.css +0 -7
@@ -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_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,9 @@ 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
+ }
46
66
  export function resolveRevalidateSecretParameterName(config) {
47
67
  return `/papyrus/${config.siteId}/revalidate-secret`;
48
68
  }
@@ -168,6 +188,9 @@ export function resolveCmsHostName(config) {
168
188
  return undefined;
169
189
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
170
190
  }
191
+ export function resolveSiteOrigin(config) {
192
+ return config.cms.siteOrigin;
193
+ }
171
194
  export function resolveStagingDomainName(config) {
172
195
  if (!isStagingEnabled(config))
173
196
  return undefined;
@@ -199,7 +222,7 @@ function parseStagingPreview(value) {
199
222
  }
200
223
  function parseCms(value) {
201
224
  const cmsRecord = requireObject(value, "cms");
202
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
225
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
203
226
  const buildComputeType = cmsRecord.buildComputeType;
204
227
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
205
228
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -217,6 +240,7 @@ function parseCms(value) {
217
240
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
218
241
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
219
242
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
243
+ 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
244
  staging: cmsRecord.staging,
221
245
  cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
222
246
  applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
@@ -235,9 +259,12 @@ function parseCms(value) {
235
259
  }
236
260
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
237
261
  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");
262
+ 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
263
  }
240
264
  }
265
+ if (cms.siteOrigin && cms.domainName) {
266
+ fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
267
+ }
241
268
  if (cms.staging === false && cms.stagingDomainName) {
242
269
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
243
270
  }
@@ -300,9 +327,20 @@ function validateOauthRedirectUrls(config) {
300
327
  }
301
328
  }
302
329
  }
330
+ function parseAws(value) {
331
+ if (value === undefined)
332
+ return undefined;
333
+ const record = requireObject(value, "aws");
334
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
335
+ return {
336
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
337
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
338
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
339
+ };
340
+ }
303
341
  export function parseSiteConfig(raw) {
304
342
  const record = requireObject(raw, "site");
305
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
343
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
306
344
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
307
345
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
308
346
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -315,6 +353,7 @@ export function parseSiteConfig(raw) {
315
353
  }
316
354
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
317
355
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
356
+ const aws = parseAws(record.aws);
318
357
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
319
358
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
320
359
  const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
@@ -352,12 +391,15 @@ export function parseSiteConfig(raw) {
352
391
  if (cms?.stagingPreview !== undefined && cms.staging === false) {
353
392
  fail("cms.stagingPreview", "must not be set when cms.staging is false");
354
393
  }
394
+ const autoDeploy = record.autoDeploy ?? true;
395
+ if (typeof autoDeploy !== "boolean")
396
+ fail("autoDeploy", "must be a boolean");
355
397
  let github;
356
398
  if (record.github !== undefined) {
357
399
  if (cms === undefined)
358
400
  fail("github", "requires cms (the CI role is scoped to the CMS app)");
359
401
  const githubRecord = requireObject(record.github, "github");
360
- rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
402
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
361
403
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
362
404
  const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
363
405
  if (repository !== `https://github.com/${owner}/${repo}`) {
@@ -378,7 +420,18 @@ export function parseSiteConfig(raw) {
378
420
  const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
379
421
  if (typeof ciCanDeployInfra !== "boolean")
380
422
  fail("github.ciCanDeployInfra", "must be a boolean");
381
- github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
423
+ const optionalId = (value, name) => {
424
+ if (value === undefined)
425
+ return undefined;
426
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
427
+ fail(name, "must be a positive integer (GitHub numeric id)");
428
+ return value;
429
+ };
430
+ const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
431
+ const repoId = optionalId(githubRecord.repoId, "github.repoId");
432
+ if ((ownerId === undefined) !== (repoId === undefined))
433
+ fail("github", "ownerId and repoId must be set together");
434
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
382
435
  }
383
436
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
384
437
  if (hasDomain && hostedZoneId === undefined) {
@@ -395,13 +448,29 @@ export function parseSiteConfig(raw) {
395
448
  frontend,
396
449
  hostedZoneId,
397
450
  stackName,
451
+ aws,
398
452
  cms,
399
453
  reader,
400
454
  papyrusVersion,
401
455
  storagePreviewPrefix,
456
+ autoDeploy: autoDeploy,
402
457
  github,
403
458
  };
404
459
  if (hasCmsApp(config))
405
460
  validateOauthRedirectUrls(config);
406
461
  return config;
407
462
  }
463
+ /**
464
+ * The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
465
+ * deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
466
+ * one.
467
+ */
468
+ export function resolveDeployEnvironment(config) {
469
+ const account = config.aws?.accountId;
470
+ const region = config.aws?.region;
471
+ const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
472
+ if (!account || !region) {
473
+ 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.`);
474
+ }
475
+ return { account, region };
476
+ }
@@ -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;
@@ -53,6 +55,7 @@ export type AmplifyAppShellSiteConfig = {
53
55
  frontend: "pretext" | "markus-static";
54
56
  hostedZoneId?: string;
55
57
  stackName?: string;
58
+ aws?: SiteAwsConfig;
56
59
  cms?: CmsConfig;
57
60
  reader?: {
58
61
  appName?: string;
@@ -69,20 +72,62 @@ export type AmplifyAppShellSiteConfig = {
69
72
  };
70
73
  papyrusVersion: string;
71
74
  storagePreviewPrefix?: string;
75
+ /**
76
+ * Merge-to-main deploy from GitHub Actions through OIDC. When true (the default) the stack creates the
77
+ * `<siteId>-github-deploy` role and turns off Amplify's own auto-build on the production branches, so the
78
+ * public repository's workflow is the only trigger. Set false for a site whose repository is private:
79
+ * private repositories get no Actions minutes, so Amplify's own repository-connected auto-build stays on.
80
+ */
81
+ autoDeploy: boolean;
72
82
  github?: {
73
83
  owner: string;
74
84
  repo: string;
75
85
  branches: string[];
76
86
  ciCanDeployInfra: boolean;
87
+ /**
88
+ * GitHub's numeric ids of the owner and the repository. Repositories with the immutable OIDC subject
89
+ * (`repo:<owner>@<ownerId>/<repo>@<repoId>:ref:...`, see `gh api repos/<owner>/<repo>/actions/oidc/customization/sub`)
90
+ * present that form instead of `repo:<owner>/<repo>:ref:...`; with both ids set, roles trust both forms.
91
+ */
92
+ ownerId?: number;
93
+ repoId?: number;
77
94
  };
78
95
  };
79
96
 
97
+ /**
98
+ * Where the site's AWS resources live. `bin/app-shell.ts` deploys the stack into exactly this account and
99
+ * region (see `resolveDeployEnvironment`), and `papyrus site google-secrets` asserts `accountId` with STS
100
+ * before it writes anything.
101
+ */
102
+ export type SiteAwsConfig = {
103
+ profile?: string;
104
+ accountId?: string;
105
+ region?: string;
106
+ };
107
+
80
108
  export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
81
109
 
82
110
  export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
83
111
  return config.cms !== undefined;
84
112
  }
85
113
 
114
+ export function resolveGithubRepository(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">): { owner: string; repo: string } {
115
+ if (config.github) return { owner: config.github.owner, repo: config.github.repo };
116
+ const [owner, repo] = config.repository.replace("https://github.com/", "").split("/");
117
+ return { owner, repo };
118
+ }
119
+
120
+ /** The OIDC `sub` values a GitHub Actions run on `ref` of the site's repository can present. */
121
+ export function githubOidcSubjects(config: Pick<AmplifyAppShellSiteConfig, "repository" | "github">, ref: string): string[] {
122
+ const { owner, repo } = resolveGithubRepository(config);
123
+ const subjects = [`repo:${owner}/${repo}:ref:refs/heads/${ref}`];
124
+ const { ownerId, repoId } = config.github ?? {};
125
+ if (ownerId !== undefined && repoId !== undefined) {
126
+ subjects.push(`repo:${owner}@${ownerId}/${repo}@${repoId}:ref:refs/heads/${ref}`);
127
+ }
128
+ return subjects;
129
+ }
130
+
86
131
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
87
132
 
88
133
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -90,6 +135,7 @@ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_
90
135
  const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
91
136
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
92
137
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
138
+ const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
93
139
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
94
140
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
95
141
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
@@ -100,7 +146,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
100
146
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
101
147
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
102
148
  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"];
149
+ 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_SITE_ORIGIN"];
104
150
 
105
151
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
106
152
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -127,6 +173,10 @@ function originOfRedirectUrl(value: string): string {
127
173
  }
128
174
  }
129
175
 
176
+ export function resolveProspectusSessionSealingParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
177
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
178
+ }
179
+
130
180
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
131
181
  return `/papyrus/${config.siteId}/revalidate-secret`;
132
182
  }
@@ -251,6 +301,10 @@ export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string |
251
301
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
252
302
  }
253
303
 
304
+ export function resolveSiteOrigin(config: Pick<CmsSiteConfig, "cms">): string | undefined {
305
+ return config.cms.siteOrigin;
306
+ }
307
+
254
308
  export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
255
309
  if (!isStagingEnabled(config)) return undefined;
256
310
  if (config.cms.domainPrefix) {
@@ -282,7 +336,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
282
336
 
283
337
  function parseCms(value: unknown): CmsConfig {
284
338
  const cmsRecord = requireObject(value, "cms");
285
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
339
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
286
340
  const buildComputeType = cmsRecord.buildComputeType;
287
341
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
288
342
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -300,6 +354,7 @@ function parseCms(value: unknown): CmsConfig {
300
354
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
301
355
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
302
356
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
357
+ 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
358
  staging: cmsRecord.staging as boolean | undefined,
304
359
  cognitoDomainPrefix: requireString(
305
360
  cmsRecord.cognitoDomainPrefix,
@@ -329,10 +384,13 @@ function parseCms(value: unknown): CmsConfig {
329
384
  }
330
385
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
331
386
  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");
387
+ 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
388
  }
334
389
  }
335
390
 
391
+ if (cms.siteOrigin && cms.domainName) {
392
+ fail("cms.siteOrigin", "is for sites whose host is served by their own edge; remove it when cms.domainName is set");
393
+ }
336
394
  if (cms.staging === false && cms.stagingDomainName) {
337
395
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
338
396
  }
@@ -395,11 +453,22 @@ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
395
453
  }
396
454
  }
397
455
 
456
+ function parseAws(value: unknown): SiteAwsConfig | undefined {
457
+ if (value === undefined) return undefined;
458
+ const record = requireObject(value, "aws");
459
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
460
+ return {
461
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
462
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
463
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
464
+ };
465
+ }
466
+
398
467
  export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
399
468
  const record = requireObject(raw, "site");
400
469
  rejectUnknownKeys(
401
470
  record,
402
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
471
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
403
472
  "site",
404
473
  );
405
474
 
@@ -415,6 +484,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
415
484
  }
416
485
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
417
486
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
487
+ const aws = parseAws(record.aws);
418
488
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
419
489
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
420
490
 
@@ -452,11 +522,14 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
452
522
  fail("cms.stagingPreview", "must not be set when cms.staging is false");
453
523
  }
454
524
 
525
+ const autoDeploy = record.autoDeploy ?? true;
526
+ if (typeof autoDeploy !== "boolean") fail("autoDeploy", "must be a boolean");
527
+
455
528
  let github: AmplifyAppShellSiteConfig["github"];
456
529
  if (record.github !== undefined) {
457
530
  if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
458
531
  const githubRecord = requireObject(record.github, "github");
459
- rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
532
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra", "ownerId", "repoId"], "github");
460
533
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
461
534
  const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
462
535
  if (repository !== `https://github.com/${owner}/${repo}`) {
@@ -476,7 +549,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
476
549
  }
477
550
  const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
478
551
  if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
479
- github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
552
+ const optionalId = (value: unknown, name: string): number | undefined => {
553
+ if (value === undefined) return undefined;
554
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) fail(name, "must be a positive integer (GitHub numeric id)");
555
+ return value as number;
556
+ };
557
+ const ownerId = optionalId(githubRecord.ownerId, "github.ownerId");
558
+ const repoId = optionalId(githubRecord.repoId, "github.repoId");
559
+ if ((ownerId === undefined) !== (repoId === undefined)) fail("github", "ownerId and repoId must be set together");
560
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
480
561
  }
481
562
 
482
563
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
@@ -495,10 +576,12 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
495
576
  frontend,
496
577
  hostedZoneId,
497
578
  stackName,
579
+ aws,
498
580
  cms,
499
581
  reader,
500
582
  papyrusVersion,
501
583
  storagePreviewPrefix,
584
+ autoDeploy: autoDeploy as boolean,
502
585
  github,
503
586
  };
504
587
 
@@ -506,3 +589,20 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
506
589
 
507
590
  return config;
508
591
  }
592
+
593
+ /**
594
+ * The CDK environment for a site's app-shell stack, taken only from the site's own `aws` block. There is
595
+ * deliberately no default account: a deploy that guesses an account can land in, or fail against, the wrong
596
+ * one.
597
+ */
598
+ export function resolveDeployEnvironment(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "aws">): { account: string; region: string } {
599
+ const account = config.aws?.accountId;
600
+ const region = config.aws?.region;
601
+ const missing = [account ? "" : "aws.accountId", region ? "" : "aws.region"].filter(Boolean);
602
+ if (!account || !region) {
603
+ throw new Error(
604
+ `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.`,
605
+ );
606
+ }
607
+ return { account, region };
608
+ }
@@ -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
  },