@anthusai/papyrus 1.0.0-next.4 → 1.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 (147) 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 +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  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 +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +212 -50
  75. package/infra/amplify-app-shell.ts +240 -53
  76. package/infra/build-specs.js +14 -5
  77. package/infra/build-specs.ts +17 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +167 -10
  82. package/infra/site-config.ts +196 -14
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,10 +1,29 @@
1
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
1
2
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2
3
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
3
4
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
4
5
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
6
+ const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
5
7
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
8
+ const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
6
9
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
7
10
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
11
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
12
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
13
+ const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
14
+ const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
15
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
16
+ function originOfRedirectUrl(value) {
17
+ try {
18
+ return new URL(value).origin;
19
+ }
20
+ catch {
21
+ return value;
22
+ }
23
+ }
24
+ export function resolveRevalidateSecretParameterName(config) {
25
+ return `/papyrus/${config.siteId}/revalidate-secret`;
26
+ }
8
27
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
9
28
  function fail(field, problem) {
10
29
  throw new Error(`site.json: ${field} ${problem}`);
@@ -43,18 +62,55 @@ function rejectUnknownKeys(record, allowed, field) {
43
62
  fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
44
63
  }
45
64
  }
65
+ function parseRedirects(value) {
66
+ if (value === undefined)
67
+ return undefined;
68
+ if (!Array.isArray(value) || value.length === 0)
69
+ fail("cms.redirects", "must be a non-empty array");
70
+ return value.map((entry, index) => {
71
+ const field = `cms.redirects[${index}]`;
72
+ const record = requireObject(entry, field);
73
+ rejectUnknownKeys(record, ["source", "status"], field);
74
+ const source = requireString(record.source, `${field}.source`, HOST_NAME_PATTERN, "host name without scheme or path");
75
+ const status = record.status ?? 301;
76
+ if (status !== 301 && status !== 302)
77
+ fail(`${field}.status`, `must be 301 or 302, got ${JSON.stringify(status)}`);
78
+ return { source, status };
79
+ });
80
+ }
81
+ export function resolveRedirectSubDomainPrefix(rootDomain, source) {
82
+ return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
83
+ }
84
+ export function isStagingEnabled(config) {
85
+ return config.cms.staging !== false;
86
+ }
87
+ export function resolveCmsHostName(config) {
88
+ if (!config.cms.domainName)
89
+ return undefined;
90
+ return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
91
+ }
46
92
  export function resolveStagingDomainName(config) {
93
+ if (!isStagingEnabled(config))
94
+ return undefined;
95
+ if (config.cms.domainPrefix) {
96
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
97
+ }
47
98
  if (config.cms.stagingDomainName)
48
99
  return config.cms.stagingDomainName;
100
+ if (!config.cms.domainName)
101
+ return undefined;
49
102
  const [, ...rest] = config.cms.domainName.split(".");
50
103
  return ["staging", ...rest].join(".");
51
104
  }
105
+ export function resolveStackName(config) {
106
+ return config.stackName ?? `amplify-app-shell-${config.siteId}`;
107
+ }
52
108
  export function resolveStoragePreviewPrefix(config) {
53
109
  return config.storagePreviewPrefix ?? "preview/";
54
110
  }
55
111
  export function parseSiteConfig(raw) {
56
112
  const record = requireObject(raw, "site");
57
- rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"], "site");
113
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
58
114
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
59
115
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
60
116
  const brand = requireString(record.brand, "brand");
@@ -62,22 +118,86 @@ export function parseSiteConfig(raw) {
62
118
  if (frontend !== "pretext" && frontend !== "markus-static") {
63
119
  fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
64
120
  }
65
- const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
121
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
122
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
66
123
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
67
124
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
68
125
  const cmsRecord = requireObject(record.cms, "cms");
69
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
126
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
70
127
  const buildComputeType = cmsRecord.buildComputeType;
71
128
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
72
129
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
73
130
  }
131
+ if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
132
+ fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
133
+ }
134
+ if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
135
+ fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
136
+ }
74
137
  const cms = {
75
138
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
76
- domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
139
+ domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
140
+ domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
141
+ staging: cmsRecord.staging,
142
+ cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
143
+ applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
77
144
  buildComputeType: buildComputeType,
78
145
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
79
146
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
147
+ stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
148
+ redirects: parseRedirects(cmsRecord.redirects),
80
149
  };
150
+ const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
151
+ if (reservedWord) {
152
+ fail("cms.cognitoDomainPrefix", `must not contain "${reservedWord}" (Cognito reserves it): ${JSON.stringify(cms.cognitoDomainPrefix)}`);
153
+ }
154
+ for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
155
+ if (key in cms.environment) {
156
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
157
+ }
158
+ }
159
+ if (cms.staging === false && cms.stagingDomainName) {
160
+ fail("cms.stagingDomainName", "must not be set when cms.staging is false");
161
+ }
162
+ if (cms.stagingDomainName && !cms.domainName) {
163
+ fail("cms.stagingDomainName", "requires cms.domainName");
164
+ }
165
+ if (cms.domainPrefix && !cms.domainName) {
166
+ fail("cms.domainPrefix", "requires cms.domainName (the root domain)");
167
+ }
168
+ if (cms.stagingDomainPrefix && !cms.domainPrefix) {
169
+ fail("cms.stagingDomainPrefix", "requires cms.domainPrefix");
170
+ }
171
+ if (cms.domainPrefix && cms.stagingDomainName) {
172
+ fail("cms.stagingDomainName", "must not be set with cms.domainPrefix: use cms.stagingDomainPrefix");
173
+ }
174
+ if (cms.staging === false && cms.stagingDomainPrefix) {
175
+ fail("cms.stagingDomainPrefix", "must not be set when cms.staging is false");
176
+ }
177
+ if (cms.domainPrefix && cms.staging !== false && !cms.stagingDomainPrefix) {
178
+ 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");
179
+ }
180
+ if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
181
+ fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
182
+ }
183
+ if (cms.redirects) {
184
+ if (!cms.domainPrefix)
185
+ fail("cms.redirects", "requires cms.domainPrefix (redirects target the primary host under a shared root domain)");
186
+ cms.redirects.forEach((redirect, index) => {
187
+ const field = `cms.redirects[${index}].source`;
188
+ const root = cms.domainName;
189
+ if (redirect.source !== root && !(redirect.source.endsWith(`.${root}`) && DOMAIN_PREFIX_PATTERN.test(redirect.source.slice(0, -root.length - 1)))) {
190
+ fail(field, `must be the root domain ${root} or a single label under it: ${JSON.stringify(redirect.source)}`);
191
+ }
192
+ if (redirect.source === resolveCmsHostName({ cms }))
193
+ fail(field, "must differ from the primary host");
194
+ if (redirect.source === resolveStagingDomainName({ cms }))
195
+ fail(field, "must differ from the staging host");
196
+ });
197
+ if (new Set(cms.redirects.map((redirect) => redirect.source)).size !== cms.redirects.length) {
198
+ fail("cms.redirects", "must not repeat a source");
199
+ }
200
+ }
81
201
  let reader;
82
202
  if (record.reader !== undefined) {
83
203
  if (frontend !== "markus-static")
@@ -86,7 +206,7 @@ export function parseSiteConfig(raw) {
86
206
  rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
87
207
  reader = {
88
208
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
89
- domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
209
+ domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
90
210
  branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
91
211
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
92
212
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
@@ -96,29 +216,66 @@ export function parseSiteConfig(raw) {
96
216
  else if (frontend === "markus-static") {
97
217
  fail("reader", `is required when frontend is "markus-static"`);
98
218
  }
219
+ let github;
220
+ if (record.github !== undefined) {
221
+ const githubRecord = requireObject(record.github, "github");
222
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
223
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
224
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
225
+ if (repository !== `https://github.com/${owner}/${repo}`) {
226
+ fail("github", `owner/repo must match repository ${repository}`);
227
+ }
228
+ let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
229
+ if (githubRecord.branches !== undefined) {
230
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
231
+ fail("github.branches", "must be a non-empty array of branch names");
232
+ }
233
+ branches = githubRecord.branches.map((branch, index) => {
234
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
235
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
236
+ }
237
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
238
+ });
239
+ }
240
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
241
+ if (typeof ciCanDeployInfra !== "boolean")
242
+ fail("github.ciCanDeployInfra", "must be a boolean");
243
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
244
+ }
245
+ const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
246
+ if (hasDomain && hostedZoneId === undefined) {
247
+ fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
248
+ }
249
+ if (!hasDomain && hostedZoneId !== undefined) {
250
+ fail("hostedZoneId", "is only allowed when cms.domainName or reader.domainName is set");
251
+ }
99
252
  const config = {
100
253
  siteId,
101
254
  repository,
102
255
  brand,
103
256
  frontend,
104
257
  hostedZoneId,
258
+ stackName,
105
259
  cms,
106
260
  reader,
107
261
  papyrusVersion,
108
262
  storagePreviewPrefix,
263
+ github,
109
264
  };
110
265
  const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
111
266
  if (redirectValue === undefined)
112
267
  fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
113
268
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
269
+ const stagingDomainName = resolveStagingDomainName(config);
270
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
114
271
  const requiredOrigins = [
115
- `https://${cms.domainName}/`,
116
- `https://${resolveStagingDomainName(config)}/`,
117
- LOCAL_DEVELOPMENT_ORIGIN,
272
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
273
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
274
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
118
275
  ];
119
276
  for (const origin of requiredOrigins) {
120
- if (!redirectUrls.includes(origin)) {
121
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
277
+ if (!listedOrigins.has(origin)) {
278
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
122
279
  }
123
280
  }
124
281
  return config;
@@ -1,19 +1,31 @@
1
+ export type CmsRedirect = {
2
+ source: string;
3
+ status: 301 | 302;
4
+ };
5
+
1
6
  export type AmplifyAppShellSiteConfig = {
2
7
  siteId: string;
3
8
  repository: string;
4
9
  brand: string;
5
10
  frontend: "pretext" | "markus-static";
6
- hostedZoneId: string;
11
+ hostedZoneId?: string;
12
+ stackName?: string;
7
13
  cms: {
8
14
  appName?: string;
9
- domainName: string;
15
+ domainName?: string;
16
+ domainPrefix?: string;
17
+ staging?: boolean;
18
+ cognitoDomainPrefix: string;
19
+ applyCognitoDomainPrefix?: boolean;
10
20
  buildComputeType?: "STANDARD" | "STANDARD_8GB";
11
21
  environment: Record<string, string>;
12
22
  stagingDomainName?: string;
23
+ stagingDomainPrefix?: string;
24
+ redirects?: CmsRedirect[];
13
25
  };
14
26
  reader?: {
15
27
  appName?: string;
16
- domainName: string;
28
+ domainName?: string;
17
29
  branchName?: string;
18
30
  buildCommand: string;
19
31
  baseDirectory: string;
@@ -21,15 +33,42 @@ export type AmplifyAppShellSiteConfig = {
21
33
  };
22
34
  papyrusVersion: string;
23
35
  storagePreviewPrefix?: string;
36
+ github?: {
37
+ owner: string;
38
+ repo: string;
39
+ branches: string[];
40
+ ciCanDeployInfra: boolean;
41
+ };
24
42
  };
25
43
 
44
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
45
+
26
46
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
27
47
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
28
48
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
29
49
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
50
+ const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
30
51
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
52
+ const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
31
53
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
32
54
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
55
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
56
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
57
+ const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
58
+ const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
59
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
60
+ function originOfRedirectUrl(value: string): string {
61
+ try {
62
+ return new URL(value).origin;
63
+ } catch {
64
+ return value;
65
+ }
66
+ }
67
+
68
+ export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
69
+ return `/papyrus/${config.siteId}/revalidate-secret`;
70
+ }
71
+
33
72
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
34
73
 
35
74
  function fail(field: string, problem: string): never {
@@ -73,12 +112,48 @@ function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], f
73
112
  }
74
113
  }
75
114
 
76
- export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string {
115
+ function parseRedirects(value: unknown): CmsRedirect[] | undefined {
116
+ if (value === undefined) return undefined;
117
+ if (!Array.isArray(value) || value.length === 0) fail("cms.redirects", "must be a non-empty array");
118
+ return (value as unknown[]).map((entry, index) => {
119
+ const field = `cms.redirects[${index}]`;
120
+ const record = requireObject(entry, field);
121
+ rejectUnknownKeys(record, ["source", "status"], field);
122
+ const source = requireString(record.source, `${field}.source`, HOST_NAME_PATTERN, "host name without scheme or path");
123
+ const status = record.status ?? 301;
124
+ if (status !== 301 && status !== 302) fail(`${field}.status`, `must be 301 or 302, got ${JSON.stringify(status)}`);
125
+ return { source, status };
126
+ });
127
+ }
128
+
129
+ export function resolveRedirectSubDomainPrefix(rootDomain: string, source: string): string {
130
+ return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
131
+ }
132
+
133
+ export function isStagingEnabled(config: Pick<AmplifyAppShellSiteConfig, "cms">): boolean {
134
+ return config.cms.staging !== false;
135
+ }
136
+
137
+ export function resolveCmsHostName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
138
+ if (!config.cms.domainName) return undefined;
139
+ return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
140
+ }
141
+
142
+ export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
143
+ if (!isStagingEnabled(config)) return undefined;
144
+ if (config.cms.domainPrefix) {
145
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
146
+ }
77
147
  if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
148
+ if (!config.cms.domainName) return undefined;
78
149
  const [, ...rest] = config.cms.domainName.split(".");
79
150
  return ["staging", ...rest].join(".");
80
151
  }
81
152
 
153
+ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "stackName">): string {
154
+ return config.stackName ?? `amplify-app-shell-${config.siteId}`;
155
+ }
156
+
82
157
  export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
83
158
  return config.storagePreviewPrefix ?? "preview/";
84
159
  }
@@ -87,7 +162,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
87
162
  const record = requireObject(raw, "site");
88
163
  rejectUnknownKeys(
89
164
  record,
90
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"],
165
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
91
166
  "site",
92
167
  );
93
168
 
@@ -98,24 +173,93 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
98
173
  if (frontend !== "pretext" && frontend !== "markus-static") {
99
174
  fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
100
175
  }
101
- const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
176
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
177
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
102
178
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
103
179
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
104
180
 
105
181
  const cmsRecord = requireObject(record.cms, "cms");
106
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
182
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
107
183
  const buildComputeType = cmsRecord.buildComputeType;
108
184
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
109
185
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
110
186
  }
187
+ if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
188
+ fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
189
+ }
190
+ if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
191
+ fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
192
+ }
111
193
  const cms: AmplifyAppShellSiteConfig["cms"] = {
112
194
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
113
- domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
195
+ domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
196
+ domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
197
+ staging: cmsRecord.staging as boolean | undefined,
198
+ cognitoDomainPrefix: requireString(
199
+ cmsRecord.cognitoDomainPrefix,
200
+ "cms.cognitoDomainPrefix",
201
+ COGNITO_DOMAIN_PREFIX_PATTERN,
202
+ "lowercase letters, digits and hyphens; globally unique per region",
203
+ ),
204
+ applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix as boolean | undefined,
114
205
  buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
115
206
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
116
207
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
208
+ stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
209
+ redirects: parseRedirects(cmsRecord.redirects),
117
210
  };
118
211
 
212
+ const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
213
+ if (reservedWord) {
214
+ fail("cms.cognitoDomainPrefix", `must not contain "${reservedWord}" (Cognito reserves it): ${JSON.stringify(cms.cognitoDomainPrefix)}`);
215
+ }
216
+ for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
217
+ if (key in cms.environment) {
218
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
219
+ }
220
+ }
221
+
222
+ if (cms.staging === false && cms.stagingDomainName) {
223
+ fail("cms.stagingDomainName", "must not be set when cms.staging is false");
224
+ }
225
+ if (cms.stagingDomainName && !cms.domainName) {
226
+ fail("cms.stagingDomainName", "requires cms.domainName");
227
+ }
228
+ if (cms.domainPrefix && !cms.domainName) {
229
+ fail("cms.domainPrefix", "requires cms.domainName (the root domain)");
230
+ }
231
+ if (cms.stagingDomainPrefix && !cms.domainPrefix) {
232
+ fail("cms.stagingDomainPrefix", "requires cms.domainPrefix");
233
+ }
234
+ if (cms.domainPrefix && cms.stagingDomainName) {
235
+ fail("cms.stagingDomainName", "must not be set with cms.domainPrefix: use cms.stagingDomainPrefix");
236
+ }
237
+ if (cms.staging === false && cms.stagingDomainPrefix) {
238
+ fail("cms.stagingDomainPrefix", "must not be set when cms.staging is false");
239
+ }
240
+ if (cms.domainPrefix && cms.staging !== false && !cms.stagingDomainPrefix) {
241
+ 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");
242
+ }
243
+ if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
244
+ fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
245
+ }
246
+
247
+ if (cms.redirects) {
248
+ if (!cms.domainPrefix) fail("cms.redirects", "requires cms.domainPrefix (redirects target the primary host under a shared root domain)");
249
+ cms.redirects.forEach((redirect, index) => {
250
+ const field = `cms.redirects[${index}].source`;
251
+ const root = cms.domainName as string;
252
+ if (redirect.source !== root && !(redirect.source.endsWith(`.${root}`) && DOMAIN_PREFIX_PATTERN.test(redirect.source.slice(0, -root.length - 1)))) {
253
+ fail(field, `must be the root domain ${root} or a single label under it: ${JSON.stringify(redirect.source)}`);
254
+ }
255
+ if (redirect.source === resolveCmsHostName({ cms })) fail(field, "must differ from the primary host");
256
+ if (redirect.source === resolveStagingDomainName({ cms })) fail(field, "must differ from the staging host");
257
+ });
258
+ if (new Set(cms.redirects.map((redirect) => redirect.source)).size !== cms.redirects.length) {
259
+ fail("cms.redirects", "must not repeat a source");
260
+ }
261
+ }
262
+
119
263
  let reader: AmplifyAppShellSiteConfig["reader"];
120
264
  if (record.reader !== undefined) {
121
265
  if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
@@ -123,7 +267,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
123
267
  rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
124
268
  reader = {
125
269
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
126
- domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
270
+ domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
127
271
  branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
128
272
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
129
273
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
@@ -133,29 +277,67 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
133
277
  fail("reader", `is required when frontend is "markus-static"`);
134
278
  }
135
279
 
280
+ let github: AmplifyAppShellSiteConfig["github"];
281
+ if (record.github !== undefined) {
282
+ const githubRecord = requireObject(record.github, "github");
283
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
284
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
285
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
286
+ if (repository !== `https://github.com/${owner}/${repo}`) {
287
+ fail("github", `owner/repo must match repository ${repository}`);
288
+ }
289
+ let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
290
+ if (githubRecord.branches !== undefined) {
291
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
292
+ fail("github.branches", "must be a non-empty array of branch names");
293
+ }
294
+ branches = (githubRecord.branches as unknown[]).map((branch, index) => {
295
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
296
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
297
+ }
298
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
299
+ });
300
+ }
301
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
302
+ if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
303
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
304
+ }
305
+
306
+ const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
307
+ if (hasDomain && hostedZoneId === undefined) {
308
+ fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
309
+ }
310
+ if (!hasDomain && hostedZoneId !== undefined) {
311
+ fail("hostedZoneId", "is only allowed when cms.domainName or reader.domainName is set");
312
+ }
313
+
136
314
  const config: AmplifyAppShellSiteConfig = {
137
315
  siteId,
138
316
  repository,
139
317
  brand,
140
318
  frontend,
141
319
  hostedZoneId,
320
+ stackName,
142
321
  cms,
143
322
  reader,
144
323
  papyrusVersion,
145
324
  storagePreviewPrefix,
325
+ github,
146
326
  };
147
327
 
148
328
  const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
149
329
  if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
150
330
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
331
+ const stagingDomainName = resolveStagingDomainName(config);
332
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
151
333
  const requiredOrigins = [
152
- `https://${cms.domainName}/`,
153
- `https://${resolveStagingDomainName(config)}/`,
154
- LOCAL_DEVELOPMENT_ORIGIN,
334
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
335
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
336
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
155
337
  ];
156
338
  for (const origin of requiredOrigins) {
157
- if (!redirectUrls.includes(origin)) {
158
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
339
+ if (!listedOrigins.has(origin)) {
340
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
159
341
  }
160
342
  }
161
343
 
@@ -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 {