@anthusai/papyrus 1.0.0-next.9 → 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 (208) 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/components/amplify-client-provider.tsx +5 -22
  93. package/components/archive-shell.tsx +2 -22
  94. package/components/article-video.tsx +152 -5
  95. package/components/blog-page-background.tsx +23 -6
  96. package/components/news-desk-page.tsx +4 -15
  97. package/components/news-desk-taxonomy-client.ts +90 -3
  98. package/components/newspaper.tsx +6 -2
  99. package/components/newsroom-access-gate.tsx +2 -1
  100. package/components/newsroom-board-view.tsx +99 -0
  101. package/components/newsroom-client-shell.tsx +12 -0
  102. package/components/newsroom-curation-provider.tsx +17 -0
  103. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  104. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  106. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  109. package/components/newsroom-newsprint-board.css +48 -0
  110. package/components/newsroom-ops-overview.tsx +28 -11
  111. package/components/newsroom-ops-shell.tsx +7 -1
  112. package/components/newsroom-organizer-shell.tsx +130 -0
  113. package/components/newsroom-organizer.css +63 -0
  114. package/components/newsroom-references-view.tsx +92 -8
  115. package/components/papyrus-console-shell.tsx +4 -2
  116. package/components/pictogram-figure.tsx +9 -2
  117. package/components/presentation-header.tsx +135 -0
  118. package/components/presentation-rhythm-hrule.tsx +17 -0
  119. package/components/priming-proof-page.tsx +92 -0
  120. package/components/publication-cyclotron-panel.tsx +57 -0
  121. package/components/reader-auth-control.tsx +8 -5
  122. package/components/reference-curation-controls.tsx +77 -0
  123. package/components/reference-cyclotron-status.tsx +109 -0
  124. package/components/reference-priming.tsx +18 -0
  125. package/components/reference-relevance-review.tsx +75 -0
  126. package/components/settings-page.tsx +2 -1
  127. package/components/signup-entries-panel.tsx +94 -0
  128. package/components/topic-steering-workspace.tsx +78 -6
  129. package/components/use-blog-header-obstacles.ts +81 -0
  130. package/components/use-brand-fonts-ready.ts +28 -0
  131. package/components/use-near-viewport.ts +30 -0
  132. package/components/use-rhythm-overlay.ts +29 -0
  133. package/infra/amplify-app-shell.js +148 -27
  134. package/infra/amplify-app-shell.ts +160 -31
  135. package/infra/build-specs.js +23 -7
  136. package/infra/build-specs.ts +27 -10
  137. package/infra/site-config.js +248 -36
  138. package/infra/site-config.ts +313 -51
  139. package/lib/amplify-outputs-path.ts +5 -1
  140. package/lib/amplify-server-runtime.ts +36 -1
  141. package/lib/approved-priming-proof-reports.ts +6 -0
  142. package/lib/blog-feature-solver.ts +459 -0
  143. package/lib/blog-rhythm.ts +134 -0
  144. package/lib/brand-fonts-watch.ts +43 -0
  145. package/lib/brand-fonts.ts +112 -0
  146. package/lib/cached-content-repository.ts +12 -0
  147. package/lib/category-repository.ts +43 -93
  148. package/lib/content-item-lifecycle.ts +55 -0
  149. package/lib/content-repository.ts +3 -0
  150. package/lib/content-types.ts +4 -0
  151. package/lib/curation-client.ts +61 -0
  152. package/lib/edition-routes.ts +31 -29
  153. package/lib/graphql-content-repository.ts +56 -22
  154. package/lib/media-route-response.ts +45 -0
  155. package/lib/newspaper-layout.ts +3 -2
  156. package/lib/newsroom-base-path.ts +12 -3
  157. package/lib/newsroom-board.ts +99 -0
  158. package/lib/newsroom-category-drilldown.ts +3 -2
  159. package/lib/newsroom-forum-routes.ts +6 -4
  160. package/lib/newsroom-index-filters.ts +14 -10
  161. package/lib/newsroom-nav.ts +3 -1
  162. package/lib/newsroom-organizer-nav.ts +37 -0
  163. package/lib/newsroom-section-routing.ts +20 -0
  164. package/lib/oauth-redirect-priority.ts +40 -0
  165. package/lib/papyrus-web-locations.ts +7 -6
  166. package/lib/plugin-settings.ts +11 -0
  167. package/lib/presentation-footer.ts +3 -2
  168. package/lib/pretext-layout.ts +12 -3
  169. package/lib/priming-proof-report.ts +89 -0
  170. package/lib/prospectus-site-identity.ts +303 -0
  171. package/lib/publication-cyclotron.ts +74 -0
  172. package/lib/rate-limit-retry.ts +55 -0
  173. package/lib/reader-base-path.ts +24 -0
  174. package/lib/reader-cache-lifetime.ts +5 -0
  175. package/lib/reader-static-params.ts +85 -4
  176. package/lib/reader-storage-url.ts +9 -12
  177. package/lib/reference-policy.ts +5 -0
  178. package/lib/relevance-decisions.ts +103 -0
  179. package/lib/relevance-status.ts +63 -0
  180. package/lib/renderer.ts +7 -0
  181. package/lib/revalidate-prewarm.ts +53 -0
  182. package/lib/revalidate-secret.ts +59 -0
  183. package/lib/robots-policy.ts +14 -0
  184. package/lib/root-route-redirect.ts +16 -0
  185. package/lib/semantic-graph.ts +7 -0
  186. package/lib/shared-credentials-provider.ts +61 -0
  187. package/lib/signup-csv.ts +24 -0
  188. package/lib/site-brand.ts +59 -0
  189. package/lib/staging-gated-path.ts +23 -0
  190. package/lib/staging-preview-object.ts +21 -5
  191. package/lib/test-editor-override.ts +4 -0
  192. package/lib/video-loading.ts +22 -0
  193. package/lib/video-script.ts +14 -0
  194. package/middleware.ts +11 -19
  195. package/package.json +64 -1
  196. package/renderers/pretext/article-page.tsx +39 -3
  197. package/renderers/pretext/presentation-shared.tsx +113 -0
  198. package/renderers/pretext/presentation-shell.tsx +15 -102
  199. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  200. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  201. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  202. package/routes.manifest.json +68 -12
  203. package/src/sync.mjs +30 -6
  204. package/src/with-papyrus.d.mts +9 -0
  205. package/src/with-papyrus.mjs +48 -3
  206. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  207. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  208. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,3 +1,49 @@
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;
@@ -5,21 +51,19 @@ export type AmplifyAppShellSiteConfig = {
5
51
  frontend: "pretext" | "markus-static";
6
52
  hostedZoneId?: string;
7
53
  stackName?: string;
8
- cms: {
9
- appName?: string;
10
- domainName?: string;
11
- staging?: boolean;
12
- buildComputeType?: "STANDARD" | "STANDARD_8GB";
13
- environment: Record<string, string>;
14
- stagingDomainName?: string;
15
- };
54
+ cms?: CmsConfig;
16
55
  reader?: {
17
56
  appName?: string;
18
57
  domainName?: string;
58
+ domainPrefixes?: string[];
19
59
  branchName?: string;
20
60
  buildCommand: string;
21
61
  baseDirectory: string;
22
62
  environment?: Record<string, string>;
63
+ redirects?: ReaderRedirect[];
64
+ customHeaders?: ReaderCustomHeaderRule[];
65
+ preBuildCommands?: string[];
66
+ skipContentExport?: boolean;
23
67
  };
24
68
  papyrusVersion: string;
25
69
  storagePreviewPrefix?: string;
@@ -31,18 +75,59 @@ export type AmplifyAppShellSiteConfig = {
31
75
  };
32
76
  };
33
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
+
34
84
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
35
85
 
36
86
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
37
87
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
38
88
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
39
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])?$/;
40
91
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
41
92
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
42
93
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
43
94
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
44
95
  const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
45
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
+
46
131
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
47
132
 
48
133
  function fail(field: string, problem: string): never {
@@ -86,12 +171,88 @@ function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], f
86
171
  }
87
172
  }
88
173
 
89
- export function isStagingEnabled(config: Pick<AmplifyAppShellSiteConfig, "cms">): boolean {
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 {
90
243
  return config.cms.staging !== false;
91
244
  }
92
245
 
93
- export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
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 {
94
252
  if (!isStagingEnabled(config)) return undefined;
253
+ if (config.cms.domainPrefix) {
254
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
255
+ }
95
256
  if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
96
257
  if (!config.cms.domainName) return undefined;
97
258
  const [, ...rest] = config.cms.domainName.split(".");
@@ -106,28 +267,19 @@ export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConf
106
267
  return config.storagePreviewPrefix ?? "preview/";
107
268
  }
108
269
 
109
- export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
110
- const record = requireObject(raw, "site");
111
- rejectUnknownKeys(
112
- record,
113
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
114
- "site",
115
- );
116
-
117
- const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
118
- const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
119
- const brand = requireString(record.brand, "brand");
120
- const frontend = requireString(record.frontend, "frontend");
121
- if (frontend !== "pretext" && frontend !== "markus-static") {
122
- fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
123
- }
124
- const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
125
- const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
126
- const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
127
- const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
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
+ }
128
279
 
129
- const cmsRecord = requireObject(record.cms, "cms");
130
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "buildComputeType", "environment", "stagingDomainName"], "cms");
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");
131
283
  const buildComputeType = cmsRecord.buildComputeType;
132
284
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
133
285
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -135,27 +287,137 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
135
287
  if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
136
288
  fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
137
289
  }
138
- const cms: AmplifyAppShellSiteConfig["cms"] = {
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 = {
139
294
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
140
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"),
141
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,
142
305
  buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
143
306
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
144
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
+ ),
145
317
  };
146
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
+
147
329
  if (cms.staging === false && cms.stagingDomainName) {
148
330
  fail("cms.stagingDomainName", "must not be set when cms.staging is false");
149
331
  }
150
332
  if (cms.stagingDomainName && !cms.domainName) {
151
333
  fail("cms.stagingDomainName", "requires cms.domainName");
152
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
+
391
+ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
392
+ const record = requireObject(raw, "site");
393
+ rejectUnknownKeys(
394
+ record,
395
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
396
+ "site",
397
+ );
398
+
399
+ const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
400
+ const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
401
+ const brand = requireString(record.brand, "brand");
402
+ const frontend = requireString(record.frontend, "frontend");
403
+ if (frontend !== "pretext" && frontend !== "markus-static") {
404
+ fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
405
+ }
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");
408
+ const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
409
+ const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
410
+
411
+ const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
153
412
 
154
413
  let reader: AmplifyAppShellSiteConfig["reader"];
155
414
  if (record.reader !== undefined) {
156
415
  if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
157
416
  const readerRecord = requireObject(record.reader, "reader");
158
- 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
+ }
159
421
  reader = {
160
422
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
161
423
  domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
@@ -163,13 +425,26 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
163
425
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
164
426
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
165
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,
166
433
  };
167
- } else if (frontend === "markus-static") {
168
- 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");
169
443
  }
170
444
 
171
445
  let github: AmplifyAppShellSiteConfig["github"];
172
446
  if (record.github !== undefined) {
447
+ if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
173
448
  const githubRecord = requireObject(record.github, "github");
174
449
  rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
175
450
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
@@ -177,7 +452,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
177
452
  if (repository !== `https://github.com/${owner}/${repo}`) {
178
453
  fail("github", `owner/repo must match repository ${repository}`);
179
454
  }
180
- let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
455
+ let branches = cms?.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
181
456
  if (githubRecord.branches !== undefined) {
182
457
  if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
183
458
  fail("github.branches", "must be a non-empty array of branch names");
@@ -194,7 +469,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
194
469
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
195
470
  }
196
471
 
197
- const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
472
+ const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
198
473
  if (hasDomain && hostedZoneId === undefined) {
199
474
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
200
475
  }
@@ -216,20 +491,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
216
491
  github,
217
492
  };
218
493
 
219
- const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
220
- if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
221
- const redirectUrls = redirectValue.split(",").map((url) => url.trim());
222
- const stagingDomainName = resolveStagingDomainName(config);
223
- const requiredOrigins = [
224
- ...(cms.domainName ? [`https://${cms.domainName}/`] : []),
225
- ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
226
- LOCAL_DEVELOPMENT_ORIGIN,
227
- ];
228
- for (const origin of requiredOrigins) {
229
- if (!redirectUrls.includes(origin)) {
230
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
231
- }
232
- }
494
+ if (hasCmsApp(config)) validateOauthRedirectUrls(config);
233
495
 
234
496
  return config;
235
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> = {};