@anthusai/papyrus 1.0.0-next.8 → 1.0.0

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 (209) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +42 -80
  6. package/amplify/data/resource.ts +42 -80
  7. package/amplify/data/schema.js +45 -60
  8. package/amplify/data/schema.ts +47 -60
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +143 -1
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  76. package/app/api/media/[...path]/route.ts +5 -16
  77. package/app/api/revalidate/route.ts +15 -7
  78. package/app/archive/page.tsx +1 -1
  79. package/app/articles/[slug]/page.tsx +7 -5
  80. package/app/blog-rhythm.css +278 -0
  81. package/app/cyclotron/page.tsx +13 -0
  82. package/app/globals.css +2 -0
  83. package/app/layout.tsx +16 -0
  84. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  85. package/app/newsroom/[section]/page.tsx +6 -3
  86. package/app/newsroom/layout.tsx +4 -9
  87. package/app/newsroom/settings/page.tsx +6 -0
  88. package/app/newsroom/work/page.tsx +10 -0
  89. package/app/page.tsx +22 -8
  90. package/app/robots.ts +2 -5
  91. package/app/tailwind.css +1 -0
  92. package/bin/papyrus-infra.mjs +3 -3
  93. package/components/amplify-client-provider.tsx +5 -22
  94. package/components/archive-shell.tsx +2 -22
  95. package/components/article-video.tsx +152 -5
  96. package/components/blog-page-background.tsx +23 -6
  97. package/components/news-desk-page.tsx +4 -15
  98. package/components/news-desk-taxonomy-client.ts +90 -3
  99. package/components/newspaper.tsx +6 -2
  100. package/components/newsroom-access-gate.tsx +2 -1
  101. package/components/newsroom-board-view.tsx +99 -0
  102. package/components/newsroom-client-shell.tsx +12 -0
  103. package/components/newsroom-curation-provider.tsx +17 -0
  104. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  106. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  109. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  110. package/components/newsroom-newsprint-board.css +48 -0
  111. package/components/newsroom-ops-overview.tsx +28 -11
  112. package/components/newsroom-ops-shell.tsx +7 -1
  113. package/components/newsroom-organizer-shell.tsx +130 -0
  114. package/components/newsroom-organizer.css +63 -0
  115. package/components/newsroom-references-view.tsx +92 -8
  116. package/components/papyrus-console-shell.tsx +4 -2
  117. package/components/pictogram-figure.tsx +9 -2
  118. package/components/presentation-header.tsx +135 -0
  119. package/components/presentation-rhythm-hrule.tsx +17 -0
  120. package/components/priming-proof-page.tsx +92 -0
  121. package/components/publication-cyclotron-panel.tsx +57 -0
  122. package/components/reader-auth-control.tsx +8 -5
  123. package/components/reference-curation-controls.tsx +77 -0
  124. package/components/reference-cyclotron-status.tsx +109 -0
  125. package/components/reference-priming.tsx +18 -0
  126. package/components/reference-relevance-review.tsx +75 -0
  127. package/components/settings-page.tsx +2 -1
  128. package/components/signup-entries-panel.tsx +94 -0
  129. package/components/topic-steering-workspace.tsx +78 -6
  130. package/components/use-blog-header-obstacles.ts +81 -0
  131. package/components/use-brand-fonts-ready.ts +28 -0
  132. package/components/use-near-viewport.ts +30 -0
  133. package/components/use-rhythm-overlay.ts +29 -0
  134. package/infra/amplify-app-shell.js +219 -71
  135. package/infra/amplify-app-shell.ts +231 -77
  136. package/infra/build-specs.js +23 -8
  137. package/infra/build-specs.ts +27 -11
  138. package/infra/site-config.js +277 -34
  139. package/infra/site-config.ts +340 -43
  140. package/lib/amplify-outputs-path.ts +5 -1
  141. package/lib/amplify-server-runtime.ts +36 -1
  142. package/lib/approved-priming-proof-reports.ts +6 -0
  143. package/lib/blog-feature-solver.ts +459 -0
  144. package/lib/blog-rhythm.ts +134 -0
  145. package/lib/brand-fonts-watch.ts +43 -0
  146. package/lib/brand-fonts.ts +112 -0
  147. package/lib/cached-content-repository.ts +12 -0
  148. package/lib/category-repository.ts +43 -93
  149. package/lib/content-item-lifecycle.ts +55 -0
  150. package/lib/content-repository.ts +3 -0
  151. package/lib/content-types.ts +4 -0
  152. package/lib/curation-client.ts +61 -0
  153. package/lib/edition-routes.ts +31 -29
  154. package/lib/graphql-content-repository.ts +56 -22
  155. package/lib/media-route-response.ts +45 -0
  156. package/lib/newspaper-layout.ts +3 -2
  157. package/lib/newsroom-base-path.ts +12 -3
  158. package/lib/newsroom-board.ts +99 -0
  159. package/lib/newsroom-category-drilldown.ts +3 -2
  160. package/lib/newsroom-forum-routes.ts +6 -4
  161. package/lib/newsroom-index-filters.ts +14 -10
  162. package/lib/newsroom-nav.ts +3 -1
  163. package/lib/newsroom-organizer-nav.ts +37 -0
  164. package/lib/newsroom-section-routing.ts +20 -0
  165. package/lib/oauth-redirect-priority.ts +40 -0
  166. package/lib/papyrus-web-locations.ts +7 -6
  167. package/lib/plugin-settings.ts +11 -0
  168. package/lib/presentation-footer.ts +3 -2
  169. package/lib/pretext-layout.ts +12 -3
  170. package/lib/priming-proof-report.ts +89 -0
  171. package/lib/prospectus-site-identity.ts +303 -0
  172. package/lib/publication-cyclotron.ts +74 -0
  173. package/lib/rate-limit-retry.ts +55 -0
  174. package/lib/reader-base-path.ts +24 -0
  175. package/lib/reader-cache-lifetime.ts +5 -0
  176. package/lib/reader-static-params.ts +85 -4
  177. package/lib/reader-storage-url.ts +9 -12
  178. package/lib/reference-policy.ts +5 -0
  179. package/lib/relevance-decisions.ts +103 -0
  180. package/lib/relevance-status.ts +63 -0
  181. package/lib/renderer.ts +7 -0
  182. package/lib/revalidate-prewarm.ts +53 -0
  183. package/lib/revalidate-secret.ts +59 -0
  184. package/lib/robots-policy.ts +14 -0
  185. package/lib/root-route-redirect.ts +16 -0
  186. package/lib/semantic-graph.ts +7 -0
  187. package/lib/shared-credentials-provider.ts +61 -0
  188. package/lib/signup-csv.ts +24 -0
  189. package/lib/site-brand.ts +59 -0
  190. package/lib/staging-gated-path.ts +23 -0
  191. package/lib/staging-preview-object.ts +21 -5
  192. package/lib/test-editor-override.ts +4 -0
  193. package/lib/video-loading.ts +22 -0
  194. package/lib/video-script.ts +14 -0
  195. package/middleware.ts +11 -19
  196. package/package.json +64 -1
  197. package/renderers/pretext/article-page.tsx +39 -3
  198. package/renderers/pretext/presentation-shared.tsx +113 -0
  199. package/renderers/pretext/presentation-shell.tsx +15 -102
  200. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  201. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  202. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  203. package/routes.manifest.json +68 -12
  204. package/src/sync.mjs +30 -6
  205. package/src/with-papyrus.d.mts +9 -0
  206. package/src/with-papyrus.mjs +48 -3
  207. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  208. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  209. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,23 +1,69 @@
1
+ export type CmsRedirect = {
2
+ source: string;
3
+ status: 301 | 302;
4
+ };
5
+
6
+ export type ReaderRedirectStatus = "301" | "302" | "200" | "404" | "404-200";
7
+
8
+ export type ReaderRedirect = {
9
+ source: string;
10
+ target: string;
11
+ status: ReaderRedirectStatus;
12
+ };
13
+
14
+ export type ReaderCustomHeaderRule = {
15
+ pattern: string;
16
+ headers: Record<string, string>;
17
+ };
18
+
19
+ export const READER_REDIRECT_STATUSES: ReaderRedirectStatus[] = ["301", "302", "200", "404", "404-200"];
20
+
21
+ /**
22
+ * What the CMS `staging` branch builds to preview drafts of a markus-static site: the site build
23
+ * command and the directory it writes. Without it, and without a `reader` to borrow them from,
24
+ * the staging branch builds the newsroom only.
25
+ */
26
+ export type StagingPreviewConfig = {
27
+ buildCommand: string;
28
+ baseDirectory: string;
29
+ };
30
+
31
+ export type CmsConfig = {
32
+ appName?: string;
33
+ domainName?: string;
34
+ domainPrefix?: string;
35
+ staging?: boolean;
36
+ stagingPreview?: StagingPreviewConfig;
37
+ cognitoDomainPrefix: string;
38
+ applyCognitoDomainPrefix?: boolean;
39
+ buildComputeType?: "STANDARD" | "STANDARD_8GB";
40
+ environment: Record<string, string>;
41
+ stagingDomainName?: string;
42
+ stagingDomainPrefix?: string;
43
+ redirects?: CmsRedirect[];
44
+ prospectusAccountsApiStageArn?: string;
45
+ };
46
+
1
47
  export type AmplifyAppShellSiteConfig = {
2
48
  siteId: string;
3
49
  repository: string;
4
50
  brand: string;
5
51
  frontend: "pretext" | "markus-static";
6
- hostedZoneId: string;
7
- cms: {
8
- appName?: string;
9
- domainName: string;
10
- buildComputeType?: "STANDARD" | "STANDARD_8GB";
11
- environment: Record<string, string>;
12
- stagingDomainName?: string;
13
- };
52
+ hostedZoneId?: string;
53
+ stackName?: string;
54
+ cms?: CmsConfig;
14
55
  reader?: {
15
56
  appName?: string;
16
- domainName: string;
57
+ domainName?: string;
58
+ domainPrefixes?: string[];
17
59
  branchName?: string;
18
60
  buildCommand: string;
19
61
  baseDirectory: string;
20
62
  environment?: Record<string, string>;
63
+ redirects?: ReaderRedirect[];
64
+ customHeaders?: ReaderCustomHeaderRule[];
65
+ preBuildCommands?: string[];
66
+ skipContentExport?: boolean;
21
67
  };
22
68
  papyrusVersion: string;
23
69
  storagePreviewPrefix?: string;
@@ -29,17 +75,59 @@ export type AmplifyAppShellSiteConfig = {
29
75
  };
30
76
  };
31
77
 
78
+ export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
79
+
80
+ export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
81
+ return config.cms !== undefined;
82
+ }
83
+
32
84
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
33
85
 
34
86
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
35
87
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
36
88
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
37
89
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
90
+ const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
38
91
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
92
+ const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
39
93
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
40
94
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
41
95
  const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
42
96
  const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
97
+ const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
98
+ const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
99
+ const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
100
+ 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"];
101
+
102
+ /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
103
+ export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
104
+ "GET/v1/identities/me",
105
+ "POST/v1/identities/google/challenge",
106
+ "POST/v1/identities/google/resolve",
107
+ "GET/v1/accounts",
108
+ "GET/v1/accounts/*",
109
+ "POST/v1/accounts/*/authorize",
110
+ "GET/v1/account-directory",
111
+ "GET/v1/account-directory/*",
112
+ ];
113
+
114
+ export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn: string): string {
115
+ const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
116
+ const [apiId, stageName] = apiPath.split("/");
117
+ return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
118
+ }
119
+ function originOfRedirectUrl(value: string): string {
120
+ try {
121
+ return new URL(value).origin;
122
+ } catch {
123
+ return value;
124
+ }
125
+ }
126
+
127
+ export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
128
+ return `/papyrus/${config.siteId}/revalidate-secret`;
129
+ }
130
+
43
131
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
44
132
 
45
133
  function fail(field: string, problem: string): never {
@@ -83,21 +171,228 @@ function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], f
83
171
  }
84
172
  }
85
173
 
86
- export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string {
174
+ function parseRedirects(value: unknown): CmsRedirect[] | undefined {
175
+ if (value === undefined) return undefined;
176
+ if (!Array.isArray(value) || value.length === 0) fail("cms.redirects", "must be a non-empty array");
177
+ return (value as unknown[]).map((entry, index) => {
178
+ const field = `cms.redirects[${index}]`;
179
+ const record = requireObject(entry, field);
180
+ rejectUnknownKeys(record, ["source", "status"], field);
181
+ const source = requireString(record.source, `${field}.source`, HOST_NAME_PATTERN, "host name without scheme or path");
182
+ const status = record.status ?? 301;
183
+ if (status !== 301 && status !== 302) fail(`${field}.status`, `must be 301 or 302, got ${JSON.stringify(status)}`);
184
+ return { source, status };
185
+ });
186
+ }
187
+
188
+ function parseReaderDomainPrefixes(value: unknown): string[] | undefined {
189
+ if (value === undefined) return undefined;
190
+ if (!Array.isArray(value) || value.length === 0) fail("reader.domainPrefixes", "must be a non-empty array");
191
+ const prefixes = (value as unknown[]).map((entry, index) => {
192
+ const field = `reader.domainPrefixes[${index}]`;
193
+ if (entry === "") return "";
194
+ return requireString(entry, field, DOMAIN_PREFIX_PATTERN, 'single DNS label, or "" for the apex');
195
+ });
196
+ if (new Set(prefixes).size !== prefixes.length) fail("reader.domainPrefixes", "must not repeat a prefix");
197
+ return prefixes;
198
+ }
199
+
200
+ function parseReaderRedirects(value: unknown): ReaderRedirect[] | undefined {
201
+ if (value === undefined) return undefined;
202
+ if (!Array.isArray(value) || value.length === 0) fail("reader.redirects", "must be a non-empty array");
203
+ return (value as unknown[]).map((entry, index) => {
204
+ const field = `reader.redirects[${index}]`;
205
+ const record = requireObject(entry, field);
206
+ rejectUnknownKeys(record, ["source", "target", "status"], field);
207
+ const status = record.status ?? "301";
208
+ if (typeof status !== "string" || !READER_REDIRECT_STATUSES.includes(status as ReaderRedirectStatus)) {
209
+ fail(`${field}.status`, `must be one of ${READER_REDIRECT_STATUSES.join(", ")}, got ${JSON.stringify(status)}`);
210
+ }
211
+ return {
212
+ source: requireString(record.source, `${field}.source`),
213
+ target: requireString(record.target, `${field}.target`),
214
+ status: status as ReaderRedirectStatus,
215
+ };
216
+ });
217
+ }
218
+
219
+ function parseReaderCustomHeaders(value: unknown): ReaderCustomHeaderRule[] | undefined {
220
+ if (value === undefined) return undefined;
221
+ if (!Array.isArray(value) || value.length === 0) fail("reader.customHeaders", "must be a non-empty array");
222
+ return (value as unknown[]).map((entry, index) => {
223
+ const field = `reader.customHeaders[${index}]`;
224
+ const record = requireObject(entry, field);
225
+ rejectUnknownKeys(record, ["pattern", "headers"], field);
226
+ const headers = requireStringRecord(record.headers, `${field}.headers`);
227
+ if (Object.keys(headers).length === 0) fail(`${field}.headers`, "must not be empty");
228
+ return { pattern: requireString(record.pattern, `${field}.pattern`), headers };
229
+ });
230
+ }
231
+
232
+ function parseReaderPreBuildCommands(value: unknown): string[] | undefined {
233
+ if (value === undefined) return undefined;
234
+ if (!Array.isArray(value) || value.length === 0) fail("reader.preBuildCommands", "must be a non-empty array of commands");
235
+ return (value as unknown[]).map((command, index) => requireString(command, `reader.preBuildCommands[${index}]`));
236
+ }
237
+
238
+ export function resolveRedirectSubDomainPrefix(rootDomain: string, source: string): string {
239
+ return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
240
+ }
241
+
242
+ export function isStagingEnabled(config: Pick<CmsSiteConfig, "cms">): boolean {
243
+ return config.cms.staging !== false;
244
+ }
245
+
246
+ export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
247
+ if (!config.cms.domainName) return undefined;
248
+ return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
249
+ }
250
+
251
+ export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
252
+ if (!isStagingEnabled(config)) return undefined;
253
+ if (config.cms.domainPrefix) {
254
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
255
+ }
87
256
  if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
257
+ if (!config.cms.domainName) return undefined;
88
258
  const [, ...rest] = config.cms.domainName.split(".");
89
259
  return ["staging", ...rest].join(".");
90
260
  }
91
261
 
262
+ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "stackName">): string {
263
+ return config.stackName ?? `amplify-app-shell-${config.siteId}`;
264
+ }
265
+
92
266
  export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
93
267
  return config.storagePreviewPrefix ?? "preview/";
94
268
  }
95
269
 
270
+ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
271
+ if (value === undefined) return undefined;
272
+ const record = requireObject(value, "cms.stagingPreview");
273
+ rejectUnknownKeys(record, ["buildCommand", "baseDirectory"], "cms.stagingPreview");
274
+ return {
275
+ buildCommand: requireString(record.buildCommand, "cms.stagingPreview.buildCommand"),
276
+ baseDirectory: requireString(record.baseDirectory, "cms.stagingPreview.baseDirectory"),
277
+ };
278
+ }
279
+
280
+ function parseCms(value: unknown): CmsConfig {
281
+ const cmsRecord = requireObject(value, "cms");
282
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
283
+ const buildComputeType = cmsRecord.buildComputeType;
284
+ if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
285
+ fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
286
+ }
287
+ if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
288
+ fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
289
+ }
290
+ if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
291
+ fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
292
+ }
293
+ const cms: CmsConfig = {
294
+ appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
295
+ domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
296
+ domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
297
+ staging: cmsRecord.staging as boolean | undefined,
298
+ cognitoDomainPrefix: requireString(
299
+ cmsRecord.cognitoDomainPrefix,
300
+ "cms.cognitoDomainPrefix",
301
+ COGNITO_DOMAIN_PREFIX_PATTERN,
302
+ "lowercase letters, digits and hyphens; globally unique per region",
303
+ ),
304
+ applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix as boolean | undefined,
305
+ buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
306
+ environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
307
+ stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
308
+ stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
309
+ redirects: parseRedirects(cmsRecord.redirects),
310
+ stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
311
+ prospectusAccountsApiStageArn: optionalString(
312
+ cmsRecord.prospectusAccountsApiStageArn,
313
+ "cms.prospectusAccountsApiStageArn",
314
+ PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN,
315
+ "arn:aws:execute-api:<region>:<account>:<api id>/api",
316
+ ),
317
+ };
318
+
319
+ const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
320
+ if (reservedWord) {
321
+ fail("cms.cognitoDomainPrefix", `must not contain "${reservedWord}" (Cognito reserves it): ${JSON.stringify(cms.cognitoDomainPrefix)}`);
322
+ }
323
+ for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
324
+ if (key in cms.environment) {
325
+ 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");
326
+ }
327
+ }
328
+
329
+ if (cms.staging === false && cms.stagingDomainName) {
330
+ fail("cms.stagingDomainName", "must not be set when cms.staging is false");
331
+ }
332
+ if (cms.stagingDomainName && !cms.domainName) {
333
+ fail("cms.stagingDomainName", "requires cms.domainName");
334
+ }
335
+ if (cms.domainPrefix && !cms.domainName) {
336
+ fail("cms.domainPrefix", "requires cms.domainName (the root domain)");
337
+ }
338
+ if (cms.stagingDomainPrefix && !cms.domainPrefix) {
339
+ fail("cms.stagingDomainPrefix", "requires cms.domainPrefix");
340
+ }
341
+ if (cms.domainPrefix && cms.stagingDomainName) {
342
+ fail("cms.stagingDomainName", "must not be set with cms.domainPrefix: use cms.stagingDomainPrefix");
343
+ }
344
+ if (cms.staging === false && cms.stagingDomainPrefix) {
345
+ fail("cms.stagingDomainPrefix", "must not be set when cms.staging is false");
346
+ }
347
+ if (cms.domainPrefix && cms.staging !== false && !cms.stagingDomainPrefix) {
348
+ fail("cms.stagingDomainPrefix", "is required when cms.domainPrefix is set and staging is enabled: the staging host is never derived under a shared root domain");
349
+ }
350
+ if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
351
+ fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
352
+ }
353
+
354
+ if (cms.redirects) {
355
+ if (!cms.domainPrefix) fail("cms.redirects", "requires cms.domainPrefix (redirects target the primary host under a shared root domain)");
356
+ cms.redirects.forEach((redirect, index) => {
357
+ const field = `cms.redirects[${index}].source`;
358
+ const root = cms.domainName as string;
359
+ if (redirect.source !== root && !(redirect.source.endsWith(`.${root}`) && DOMAIN_PREFIX_PATTERN.test(redirect.source.slice(0, -root.length - 1)))) {
360
+ fail(field, `must be the root domain ${root} or a single label under it: ${JSON.stringify(redirect.source)}`);
361
+ }
362
+ if (redirect.source === resolveCmsHostName({ cms })) fail(field, "must differ from the primary host");
363
+ if (redirect.source === resolveStagingDomainName({ cms })) fail(field, "must differ from the staging host");
364
+ });
365
+ if (new Set(cms.redirects.map((redirect) => redirect.source)).size !== cms.redirects.length) {
366
+ fail("cms.redirects", "must not repeat a source");
367
+ }
368
+ }
369
+
370
+ return cms;
371
+ }
372
+
373
+ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
374
+ const redirectValue = config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
375
+ if (redirectValue === undefined) fail("config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
376
+ const redirectUrls = redirectValue.split(",").map((url) => url.trim());
377
+ const stagingDomainName = resolveStagingDomainName(config);
378
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
379
+ const requiredOrigins = [
380
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
381
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
382
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
383
+ ];
384
+ for (const origin of requiredOrigins) {
385
+ if (!listedOrigins.has(origin)) {
386
+ fail("config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
387
+ }
388
+ }
389
+ }
390
+
96
391
  export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
97
392
  const record = requireObject(raw, "site");
98
393
  rejectUnknownKeys(
99
394
  record,
100
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
395
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
101
396
  "site",
102
397
  );
103
398
 
@@ -108,43 +403,48 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
108
403
  if (frontend !== "pretext" && frontend !== "markus-static") {
109
404
  fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
110
405
  }
111
- const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
406
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
407
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
112
408
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
113
409
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
114
410
 
115
- const cmsRecord = requireObject(record.cms, "cms");
116
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
117
- const buildComputeType = cmsRecord.buildComputeType;
118
- if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
119
- fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
120
- }
121
- const cms: AmplifyAppShellSiteConfig["cms"] = {
122
- appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
123
- domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
124
- buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
125
- environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
126
- stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
127
- };
411
+ const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
128
412
 
129
413
  let reader: AmplifyAppShellSiteConfig["reader"];
130
414
  if (record.reader !== undefined) {
131
415
  if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
132
416
  const readerRecord = requireObject(record.reader, "reader");
133
- rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
417
+ rejectUnknownKeys(readerRecord, ["appName", "domainName", "domainPrefixes", "branchName", "buildCommand", "baseDirectory", "environment", "redirects", "customHeaders", "preBuildCommands", "skipContentExport"], "reader");
418
+ if (readerRecord.skipContentExport !== undefined && typeof readerRecord.skipContentExport !== "boolean") {
419
+ fail("reader.skipContentExport", `must be a boolean, got ${JSON.stringify(readerRecord.skipContentExport)}`);
420
+ }
134
421
  reader = {
135
422
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
136
- domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
423
+ domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
137
424
  branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
138
425
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
139
426
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
140
427
  environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
428
+ domainPrefixes: parseReaderDomainPrefixes(readerRecord.domainPrefixes),
429
+ redirects: parseReaderRedirects(readerRecord.redirects),
430
+ customHeaders: parseReaderCustomHeaders(readerRecord.customHeaders),
431
+ preBuildCommands: parseReaderPreBuildCommands(readerRecord.preBuildCommands),
432
+ skipContentExport: readerRecord.skipContentExport as boolean | undefined,
141
433
  };
142
- } else if (frontend === "markus-static") {
143
- fail("reader", `is required when frontend is "markus-static"`);
434
+ if (reader.domainPrefixes && !reader.domainName) fail("reader.domainPrefixes", "requires reader.domainName");
435
+ } else if (frontend === "markus-static" && cms === undefined) {
436
+ fail("reader", `is required when frontend is "markus-static" and there is no cms section`);
437
+ }
438
+ if (cms?.stagingPreview !== undefined && frontend !== "markus-static") {
439
+ fail("cms.stagingPreview", `is only allowed when frontend is "markus-static"`);
440
+ }
441
+ if (cms?.stagingPreview !== undefined && cms.staging === false) {
442
+ fail("cms.stagingPreview", "must not be set when cms.staging is false");
144
443
  }
145
444
 
146
445
  let github: AmplifyAppShellSiteConfig["github"];
147
446
  if (record.github !== undefined) {
447
+ if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
148
448
  const githubRecord = requireObject(record.github, "github");
149
449
  rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
150
450
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
@@ -152,7 +452,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
152
452
  if (repository !== `https://github.com/${owner}/${repo}`) {
153
453
  fail("github", `owner/repo must match repository ${repository}`);
154
454
  }
155
- let branches = DEFAULT_CI_BRANCHES;
455
+ let branches = cms?.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
156
456
  if (githubRecord.branches !== undefined) {
157
457
  if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
158
458
  fail("github.branches", "must be a non-empty array of branch names");
@@ -169,12 +469,21 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
169
469
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
170
470
  }
171
471
 
472
+ const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
473
+ if (hasDomain && hostedZoneId === undefined) {
474
+ fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
475
+ }
476
+ if (!hasDomain && hostedZoneId !== undefined) {
477
+ fail("hostedZoneId", "is only allowed when cms.domainName or reader.domainName is set");
478
+ }
479
+
172
480
  const config: AmplifyAppShellSiteConfig = {
173
481
  siteId,
174
482
  repository,
175
483
  brand,
176
484
  frontend,
177
485
  hostedZoneId,
486
+ stackName,
178
487
  cms,
179
488
  reader,
180
489
  papyrusVersion,
@@ -182,19 +491,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
182
491
  github,
183
492
  };
184
493
 
185
- const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
186
- if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
187
- const redirectUrls = redirectValue.split(",").map((url) => url.trim());
188
- const requiredOrigins = [
189
- `https://${cms.domainName}/`,
190
- `https://${resolveStagingDomainName(config)}/`,
191
- LOCAL_DEVELOPMENT_ORIGIN,
192
- ];
193
- for (const origin of requiredOrigins) {
194
- if (!redirectUrls.includes(origin)) {
195
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
196
- }
197
- }
494
+ if (hasCmsApp(config)) validateOauthRedirectUrls(config);
198
495
 
199
496
  return config;
200
497
  }
@@ -6,7 +6,11 @@ export function getAmplifyOutputsPath(): string {
6
6
  if (explicit) {
7
7
  return path.isAbsolute(explicit) ? explicit : path.join(process.cwd(), explicit);
8
8
  }
9
- return path.join(process.cwd(), "amplify_outputs.json");
9
+ const checkoutRootPath = path.join(process.cwd(), "amplify_outputs.json");
10
+ if (fs.existsSync(checkoutRootPath)) return checkoutRootPath;
11
+ const shippedBundlePath = path.join(process.cwd(), ".next", "amplify_outputs.json");
12
+ if (fs.existsSync(shippedBundlePath)) return shippedBundlePath;
13
+ return checkoutRootPath;
10
14
  }
11
15
 
12
16
  export function readAmplifyOutputsFile(): Record<string, unknown> | null {
@@ -1,6 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import { createServerRunner } from "@aws-amplify/adapter-nextjs";
3
3
  import { Amplify, type ResourcesConfig } from "aws-amplify";
4
+ import {
5
+ createAWSCredentialsAndIdentityIdProvider,
6
+ createUserPoolsTokenProvider,
7
+ runWithAmplifyServerContext as runWithAmplifyContext,
8
+ } from "aws-amplify/adapter-core";
9
+ import { sharedInMemoryStorage } from "aws-amplify/utils";
10
+ import { createSharedCredentialsProvider } from "./shared-credentials-provider";
4
11
  import { assertSandboxAmplifyOutputsForDev } from "./amplify-outputs-guard";
5
12
  import { getAmplifyOutputsPath } from "./amplify-outputs-path";
6
13
 
@@ -14,14 +21,42 @@ export function getAmplifyServerRuntime(): AmplifyServerRuntime {
14
21
  if (cachedRuntime) return cachedRuntime;
15
22
 
16
23
  const config = loadAmplifyOutputs();
17
- Amplify.configure(config, { ssr: true });
18
24
  cachedRuntime = {
19
25
  ...createServerRunner({ config }),
20
26
  config,
21
27
  };
28
+ Amplify.configure(config, config.Auth ? { ssr: true, Auth: getGuestProviders(config) } : { ssr: true });
22
29
  return cachedRuntime;
23
30
  }
24
31
 
32
+ type GuestProviders = {
33
+ credentialsProvider: ReturnType<typeof createAWSCredentialsAndIdentityIdProvider>;
34
+ tokenProvider: ReturnType<typeof createUserPoolsTokenProvider>;
35
+ };
36
+
37
+ const GUEST_PROVIDERS_KEY = Symbol.for("papyrus.guestAmplifyProviders");
38
+
39
+ function getGuestProviders(config: ResourcesConfig): GuestProviders {
40
+ const holder = globalThis as unknown as Record<symbol, GuestProviders | undefined>;
41
+ const existing = holder[GUEST_PROVIDERS_KEY];
42
+ if (existing) return existing;
43
+ const auth = config.Auth as NonNullable<ResourcesConfig["Auth"]>;
44
+ const created: GuestProviders = {
45
+ credentialsProvider: createSharedCredentialsProvider(
46
+ createAWSCredentialsAndIdentityIdProvider(auth, sharedInMemoryStorage),
47
+ ),
48
+ tokenProvider: createUserPoolsTokenProvider(auth, sharedInMemoryStorage),
49
+ };
50
+ holder[GUEST_PROVIDERS_KEY] = created;
51
+ return created;
52
+ }
53
+
54
+ export function runWithGuestAmplifyContext<T>(operation: (contextSpec: Parameters<Parameters<typeof runWithAmplifyContext>[2]>[0]) => Promise<T>): Promise<T> {
55
+ const { config } = getAmplifyServerRuntime();
56
+ if (!config.Auth) return runWithAmplifyContext(config, {}, operation) as Promise<T>;
57
+ return runWithAmplifyContext(config, { Auth: getGuestProviders(config) }, operation) as Promise<T>;
58
+ }
59
+
25
60
  export function hasAmplifyOutputs(): boolean {
26
61
  return fs.existsSync(getAmplifyOutputsPath());
27
62
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Priming proof reports a publication owner approved for visitors, keyed by site brand id.
3
+ * A report reaches this map only through a reviewed commit of its
4
+ * `publications/priming-proofs/<brand id>.json` file; see docs/priming-proof.md.
5
+ */
6
+ export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {};