@anthusai/papyrus 1.0.0-next.9 → 1.1.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 (222) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/redirect-urls.js +54 -0
  4. package/amplify/auth/redirect-urls.ts +66 -0
  5. package/amplify/auth/resource.js +23 -5
  6. package/amplify/auth/resource.ts +31 -5
  7. package/amplify/data/resource.js +42 -80
  8. package/amplify/data/resource.ts +42 -80
  9. package/amplify/data/schema.js +45 -62
  10. package/amplify/data/schema.ts +47 -62
  11. package/amplify/functions/category-action/handler.ts +5 -3
  12. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  13. package/amplify/functions/console-chat-responder/resource.js +0 -7
  14. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  15. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  16. package/amplify/functions/content-actions/handler.py +143 -1
  17. package/amplify/functions/content-actions/requirements.txt +2 -1
  18. package/amplify/functions/content-actions/resource.js +6 -1
  19. package/amplify/functions/content-actions/resource.ts +9 -2
  20. package/amplify/functions/email-submission-processor/handler.py +3 -1
  21. package/amplify/functions/renditions/handler.py +120 -0
  22. package/amplify/functions/renditions/requirements.txt +4 -0
  23. package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
  24. package/amplify/functions/shared/email-submission.ts +109 -1
  25. package/amplify/functions/shared/python-bundle.js +42 -2
  26. package/amplify/functions/shared/python-bundle.ts +47 -2
  27. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  28. package/amplify/inbound-email/ses-intake.js +86 -0
  29. package/amplify/inbound-email/ses-intake.ts +110 -0
  30. package/amplify/plugins/registry.js +19 -0
  31. package/amplify/plugins/registry.ts +23 -0
  32. package/amplify/plugins/signup/function/handler.py +200 -0
  33. package/amplify/plugins/signup/function/resource.js +27 -0
  34. package/amplify/plugins/signup/function/resource.ts +35 -0
  35. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  36. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  37. package/amplify/plugins/signup/schema.js +40 -0
  38. package/amplify/plugins/signup/schema.ts +41 -0
  39. package/amplify/plugins/signup/waf.js +48 -0
  40. package/amplify/plugins/signup/waf.ts +51 -0
  41. package/amplify/plugins/types.js +1 -0
  42. package/amplify/plugins/types.ts +39 -0
  43. package/amplify/plugins/wire.js +24 -0
  44. package/amplify/plugins/wire.ts +26 -0
  45. package/amplify/publication/default-publication.js +18 -0
  46. package/amplify/publication/default-publication.ts +20 -0
  47. package/amplify/publication/invalidation-alarm.js +26 -0
  48. package/amplify/publication/invalidation-alarm.ts +30 -0
  49. package/amplify/publication/redirect-and-index.js +37 -0
  50. package/amplify/publication/renditions-function.js +74 -0
  51. package/amplify/publication/renditions-function.ts +112 -0
  52. package/amplify/publication/renditions-settings.js +51 -0
  53. package/amplify/publication/renditions-settings.ts +58 -0
  54. package/amplify/publication/reproject-on-deploy.js +73 -0
  55. package/amplify/publication/reproject-on-deploy.ts +78 -0
  56. package/amplify/publication/s3-projection-settings.js +67 -0
  57. package/amplify/publication/s3-projection-settings.ts +101 -0
  58. package/amplify/publication/s3-projection.js +151 -0
  59. package/amplify/publication/s3-projection.ts +201 -0
  60. package/amplify/resolver-waves.js +26 -0
  61. package/amplify/resolver-waves.ts +34 -0
  62. package/amplify/revalidate-invalidation-settings.js +30 -0
  63. package/amplify/revalidate-invalidation-settings.ts +35 -0
  64. package/amplify/revalidate-secret-policy.js +13 -0
  65. package/amplify/revalidate-secret-policy.ts +19 -0
  66. package/amplify/site-backend-config.js +65 -1
  67. package/amplify/site-backend-config.ts +153 -2
  68. package/amplify/site-backend-names.js +103 -0
  69. package/amplify/site-backend-names.ts +166 -0
  70. package/amplify/site-backend.js +150 -176
  71. package/amplify/site-backend.ts +189 -201
  72. package/amplify/storage-backups/construct.js +31 -0
  73. package/amplify/storage-backups/construct.ts +47 -0
  74. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  75. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  76. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  77. package/app/[year]/[month]/[day]/page.tsx +1 -1
  78. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  79. package/app/api/archive/editions/route.ts +1 -1
  80. package/app/api/auth/prospectus/callback/route.ts +9 -0
  81. package/app/api/auth/prospectus/selection/route.ts +9 -0
  82. package/app/api/auth/prospectus/session/route.ts +9 -0
  83. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  84. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  85. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  86. package/app/api/media/[...path]/route.ts +5 -16
  87. package/app/api/revalidate/route.ts +15 -7
  88. package/app/archive/page.tsx +1 -1
  89. package/app/articles/[slug]/page.tsx +7 -5
  90. package/app/blog-rhythm.css +278 -0
  91. package/app/cyclotron/page.tsx +13 -0
  92. package/app/globals.css +2 -0
  93. package/app/layout.tsx +16 -0
  94. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  95. package/app/newsroom/[section]/page.tsx +6 -3
  96. package/app/newsroom/layout.tsx +4 -9
  97. package/app/newsroom/settings/page.tsx +6 -0
  98. package/app/newsroom/work/page.tsx +10 -0
  99. package/app/page.tsx +22 -8
  100. package/app/robots.ts +2 -5
  101. package/app/tailwind.css +1 -6
  102. package/components/amplify-client-provider.tsx +5 -22
  103. package/components/archive-shell.tsx +2 -22
  104. package/components/article-video.tsx +152 -5
  105. package/components/blog-page-background.tsx +23 -6
  106. package/components/news-desk-page.tsx +4 -15
  107. package/components/news-desk-taxonomy-client.ts +90 -3
  108. package/components/newspaper.tsx +6 -2
  109. package/components/newsroom-access-gate.tsx +2 -1
  110. package/components/newsroom-board-view.tsx +99 -0
  111. package/components/newsroom-client-shell.tsx +12 -0
  112. package/components/newsroom-curation-provider.tsx +17 -0
  113. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  115. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  118. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  119. package/components/newsroom-newsprint-board.css +48 -0
  120. package/components/newsroom-ops-overview.tsx +28 -11
  121. package/components/newsroom-ops-shell.tsx +7 -1
  122. package/components/newsroom-organizer-shell.tsx +130 -0
  123. package/components/newsroom-organizer.css +63 -0
  124. package/components/newsroom-references-view.tsx +92 -8
  125. package/components/papyrus-console-shell.tsx +4 -2
  126. package/components/pictogram-figure.tsx +9 -2
  127. package/components/presentation-header.tsx +135 -0
  128. package/components/presentation-rhythm-hrule.tsx +17 -0
  129. package/components/priming-proof-page.tsx +94 -0
  130. package/components/publication-cyclotron-panel.tsx +57 -0
  131. package/components/reader-auth-control.tsx +8 -5
  132. package/components/reference-curation-controls.tsx +77 -0
  133. package/components/reference-cyclotron-status.tsx +109 -0
  134. package/components/reference-priming.tsx +18 -0
  135. package/components/reference-relevance-review.tsx +75 -0
  136. package/components/settings-page.tsx +2 -1
  137. package/components/signup-entries-panel.tsx +94 -0
  138. package/components/topic-steering-workspace.tsx +78 -8
  139. package/components/use-blog-header-obstacles.ts +81 -0
  140. package/components/use-brand-fonts-ready.ts +28 -0
  141. package/components/use-near-viewport.ts +30 -0
  142. package/components/use-rhythm-overlay.ts +29 -0
  143. package/infra/amplify-app-shell.js +166 -27
  144. package/infra/amplify-app-shell.ts +180 -31
  145. package/infra/build-specs.js +23 -7
  146. package/infra/build-specs.ts +27 -10
  147. package/infra/site-config.js +248 -36
  148. package/infra/site-config.ts +313 -51
  149. package/lib/amplify-outputs-path.ts +5 -1
  150. package/lib/amplify-server-runtime.ts +36 -1
  151. package/lib/approved-priming-proof-reports.ts +6 -0
  152. package/lib/blog-feature-solver.ts +459 -0
  153. package/lib/blog-rhythm.ts +134 -0
  154. package/lib/brand-fonts-watch.ts +43 -0
  155. package/lib/brand-fonts.ts +112 -0
  156. package/lib/cached-content-repository.ts +12 -0
  157. package/lib/category-repository.ts +43 -93
  158. package/lib/content-item-lifecycle.ts +55 -0
  159. package/lib/content-repository.ts +3 -0
  160. package/lib/content-types.ts +4 -0
  161. package/lib/curation-client.ts +61 -0
  162. package/lib/edition-routes.ts +31 -29
  163. package/lib/graphql-content-repository.ts +56 -22
  164. package/lib/media-route-response.ts +45 -0
  165. package/lib/newspaper-layout.ts +3 -2
  166. package/lib/newsroom-base-path.ts +12 -3
  167. package/lib/newsroom-board.ts +99 -0
  168. package/lib/newsroom-category-drilldown.ts +3 -2
  169. package/lib/newsroom-forum-routes.ts +6 -4
  170. package/lib/newsroom-index-filters.ts +14 -10
  171. package/lib/newsroom-nav.ts +3 -1
  172. package/lib/newsroom-organizer-nav.ts +37 -0
  173. package/lib/newsroom-section-routing.ts +20 -0
  174. package/lib/oauth-redirect-priority.ts +40 -0
  175. package/lib/papyrus-web-locations.ts +7 -6
  176. package/lib/plugin-settings.ts +11 -0
  177. package/lib/presentation-footer.ts +3 -2
  178. package/lib/pretext-layout.ts +12 -3
  179. package/lib/priming-proof-report.ts +90 -0
  180. package/lib/prospectus-publication-access.ts +53 -0
  181. package/lib/prospectus-session.ts +196 -0
  182. package/lib/prospectus-sign-in-routes.ts +284 -0
  183. package/lib/prospectus-site-identity.ts +303 -0
  184. package/lib/publication-cyclotron.ts +74 -0
  185. package/lib/rate-limit-retry.ts +55 -0
  186. package/lib/reader-base-path.ts +24 -0
  187. package/lib/reader-cache-lifetime.ts +5 -0
  188. package/lib/reader-static-params.ts +85 -4
  189. package/lib/reader-storage-url.ts +9 -12
  190. package/lib/reference-policy.ts +5 -0
  191. package/lib/relevance-decisions.ts +103 -0
  192. package/lib/relevance-status.ts +63 -0
  193. package/lib/renderer.ts +7 -0
  194. package/lib/revalidate-prewarm.ts +53 -0
  195. package/lib/revalidate-secret.ts +59 -0
  196. package/lib/robots-policy.ts +14 -0
  197. package/lib/root-route-redirect.ts +16 -0
  198. package/lib/semantic-graph.ts +7 -0
  199. package/lib/shared-credentials-provider.ts +61 -0
  200. package/lib/signup-csv.ts +24 -0
  201. package/lib/site-brand.ts +59 -0
  202. package/lib/staging-gated-path.ts +23 -0
  203. package/lib/staging-preview-object.ts +21 -5
  204. package/lib/test-editor-override.ts +4 -0
  205. package/lib/video-loading.ts +22 -0
  206. package/lib/video-script.ts +14 -0
  207. package/middleware.ts +11 -19
  208. package/package.json +73 -8
  209. package/renderers/pretext/article-page.tsx +39 -3
  210. package/renderers/pretext/presentation-shared.tsx +113 -0
  211. package/renderers/pretext/presentation-shell.tsx +15 -102
  212. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  213. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  214. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  215. package/routes.manifest.json +123 -12
  216. package/src/sync.mjs +30 -6
  217. package/src/with-papyrus.d.mts +9 -0
  218. package/src/with-papyrus.mjs +48 -3
  219. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  220. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  221. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  222. package/components/ai-elements/message.tsx +0 -360
@@ -0,0 +1,112 @@
1
+ import { Duration, Size } from "aws-cdk-lib";
2
+ import * as events from "aws-cdk-lib/aws-events";
3
+ import * as targets from "aws-cdk-lib/aws-events-targets";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import * as s3 from "aws-cdk-lib/aws-s3";
7
+ import * as sqs from "aws-cdk-lib/aws-sqs";
8
+ import type { Construct } from "constructs";
9
+ import { execSync } from "node:child_process";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { pythonLambdaCode, type SiteBundleFile } from "../functions/shared/python-bundle";
13
+ import {
14
+ RENDITIONS_EPHEMERAL_STORAGE_MB,
15
+ RENDITIONS_MEDIA_PREFIX,
16
+ RENDITIONS_MEMORY_MB,
17
+ RENDITIONS_TIMEOUT_SECONDS,
18
+ renditionsEnvironment,
19
+ renditionsEventPattern,
20
+ } from "./renditions-settings";
21
+
22
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const projectRoot = path.resolve(dirname, "../..");
24
+
25
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
26
+
27
+ export type RenditionsFunctionProps = {
28
+ siteBucket: s3.IBucket;
29
+ mediaBucket: s3.IBucket;
30
+ siteFiles: SiteBundleFile[];
31
+ code?: Code;
32
+ };
33
+
34
+ export type RenditionsFunction = {
35
+ function: LambdaFunction;
36
+ rule: events.Rule;
37
+ deadLetterQueue: sqs.Queue;
38
+ };
39
+
40
+ function installCheckoutRequirements(outputDir: string): void {
41
+ const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
42
+ execSync(
43
+ `python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`,
44
+ { cwd: projectRoot, stdio: "inherit" },
45
+ );
46
+ }
47
+
48
+ export function addRenditionsFunction(scope: Construct, props: RenditionsFunctionProps): RenditionsFunction {
49
+ const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
50
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
51
+ retentionPeriod: Duration.days(14),
52
+ enforceSSL: true,
53
+ });
54
+ const renditions = new LambdaFunction(scope, "RenditionsFunction", {
55
+ runtime: Runtime.PYTHON_3_12,
56
+ architecture: Architecture.ARM_64,
57
+ handler: "handler.handler",
58
+ timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
59
+ memorySize: RENDITIONS_MEMORY_MB,
60
+ ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
61
+ retryAttempts: 2,
62
+ deadLetterQueue,
63
+ environment: renditionsEnvironment(props.siteBucket.bucketName),
64
+ code: props.code ?? pythonLambdaCode(
65
+ {
66
+ functionDir: "renditions",
67
+ modules: ["papyrus_content"],
68
+ requirements: renditionsRequirements,
69
+ noDeps: true,
70
+ checkoutExtras: installCheckoutRequirements,
71
+ siteFiles: props.siteFiles,
72
+ },
73
+ [
74
+ "bash",
75
+ "-c",
76
+ [
77
+ "set -euo pipefail",
78
+ "mkdir -p /asset-output/papyrus_content",
79
+ "cp amplify/functions/renditions/handler.py /asset-output/handler.py",
80
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
81
+ "python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
82
+ ].join(" && "),
83
+ ],
84
+ ),
85
+ });
86
+ renditions.addToRolePolicy(
87
+ new PolicyStatement({
88
+ effect: Effect.ALLOW,
89
+ actions: ["s3:GetObject"],
90
+ resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
91
+ }),
92
+ );
93
+ renditions.addToRolePolicy(
94
+ new PolicyStatement({
95
+ effect: Effect.ALLOW,
96
+ actions: ["s3:PutObject", "s3:GetObject"],
97
+ resources: [props.siteBucket.arnForObjects("*")],
98
+ }),
99
+ );
100
+ renditions.addToRolePolicy(
101
+ new PolicyStatement({
102
+ effect: Effect.ALLOW,
103
+ actions: ["s3:ListBucket"],
104
+ resources: [props.siteBucket.bucketArn],
105
+ }),
106
+ );
107
+ const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
108
+ eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
109
+ });
110
+ rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
111
+ return { function: renditions, rule, deadLetterQueue };
112
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pure planning for the upload-time renditions function of an s3-projection
3
+ * site (no CDK imports, so it can be tested without synthesizing).
4
+ *
5
+ * The function exists only for a site whose `projection.json` carries an
6
+ * `images` section (the one place the image pipeline settings live, shared by
7
+ * the renditions function and the projector) or `media.originals: true`
8
+ * (every media object is also published as is, at its key without `media/`).
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ export const RENDITIONS_MEMORY_MB = 3008;
13
+ export const RENDITIONS_TIMEOUT_SECONDS = 300;
14
+ export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
15
+ export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
16
+ export const RENDITIONS_MEDIA_PREFIX = "media/";
17
+ export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
18
+ export function projectionSettingsHaveImages(settingsPath) {
19
+ if (!fs.existsSync(settingsPath))
20
+ return false;
21
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
22
+ return typeof parsed.images === "object" && parsed.images !== null;
23
+ }
24
+ export function projectionSettingsPublishOriginals(settingsPath) {
25
+ if (!fs.existsSync(settingsPath))
26
+ return false;
27
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
28
+ return parsed.media?.originals === true;
29
+ }
30
+ export function planRenditions(publication, workingDirectory) {
31
+ if (publication?.staticDir === undefined)
32
+ return { enabled: false, siteFiles: [] };
33
+ const settingsFile = path.join(publication.staticDir, "projection.json");
34
+ const resolved = path.resolve(workingDirectory, settingsFile);
35
+ if (!projectionSettingsHaveImages(resolved) && !projectionSettingsPublishOriginals(resolved))
36
+ return { enabled: false, siteFiles: [] };
37
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
38
+ }
39
+ export function renditionsEventPattern(mediaBucketName) {
40
+ return {
41
+ source: ["aws.s3"],
42
+ detailType: ["Object Created"],
43
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
44
+ };
45
+ }
46
+ export function renditionsEnvironment(siteBucketName) {
47
+ return {
48
+ PAPYRUS_SITE_BUCKET: siteBucketName,
49
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
50
+ };
51
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Pure planning for the upload-time renditions function of an s3-projection
3
+ * site (no CDK imports, so it can be tested without synthesizing).
4
+ *
5
+ * The function exists only for a site whose `projection.json` carries an
6
+ * `images` section (the one place the image pipeline settings live, shared by
7
+ * the renditions function and the projector) or `media.originals: true`
8
+ * (every media object is also published as is, at its key without `media/`).
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const RENDITIONS_MEMORY_MB = 3008;
14
+ export const RENDITIONS_TIMEOUT_SECONDS = 300;
15
+ export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
16
+ export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
17
+ export const RENDITIONS_MEDIA_PREFIX = "media/";
18
+ export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
19
+
20
+ export type RenditionsPlan = {
21
+ enabled: boolean;
22
+ siteFiles: Array<{ from: string; to: string }>;
23
+ };
24
+
25
+ export function projectionSettingsHaveImages(settingsPath: string): boolean {
26
+ if (!fs.existsSync(settingsPath)) return false;
27
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
28
+ return typeof parsed.images === "object" && parsed.images !== null;
29
+ }
30
+
31
+ export function projectionSettingsPublishOriginals(settingsPath: string): boolean {
32
+ if (!fs.existsSync(settingsPath)) return false;
33
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as { media?: { originals?: unknown } };
34
+ return parsed.media?.originals === true;
35
+ }
36
+
37
+ export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
38
+ if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
39
+ const settingsFile = path.join(publication.staticDir, "projection.json");
40
+ const resolved = path.resolve(workingDirectory, settingsFile);
41
+ if (!projectionSettingsHaveImages(resolved) && !projectionSettingsPublishOriginals(resolved)) return { enabled: false, siteFiles: [] };
42
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
43
+ }
44
+
45
+ export function renditionsEventPattern(mediaBucketName: string): Record<string, unknown> {
46
+ return {
47
+ source: ["aws.s3"],
48
+ detailType: ["Object Created"],
49
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
50
+ };
51
+ }
52
+
53
+ export function renditionsEnvironment(siteBucketName: string): Record<string, string> {
54
+ return {
55
+ PAPYRUS_SITE_BUCKET: siteBucketName,
56
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
57
+ };
58
+ }
@@ -0,0 +1,73 @@
1
+ import { CustomResource, Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import { Provider } from "aws-cdk-lib/custom-resources";
6
+ export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 900;
7
+ export const DEPLOY_RENDER_METRIC_NAMESPACE = "Papyrus/Projection";
8
+ export const DEPLOY_RENDER_DRIFT_METRIC_NAME = "DeployRenderDrift";
9
+ export const DEPLOY_RENDER_TASK = "deploy-render";
10
+ const FAILURE_MESSAGE_LIMIT = 600;
11
+ const RESULT_SUMMARY_LIMIT = 900;
12
+ export function reprojectDeployToken(environment, now = Date.now) {
13
+ const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
14
+ return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
15
+ }
16
+ /**
17
+ * Runs inside the deploy: invokes the content-actions Lambda synchronously, so a render that fails fails the
18
+ * stack update (and with it the Amplify build) with the failure in the build log. The Lambda's result, which
19
+ * includes the drift check, is written to its log and returned as the resource's data.
20
+ */
21
+ export const DEPLOY_RENDER_HANDLER_SOURCE = `
22
+ const { LambdaClient, InvokeCommand } = require("@aws-sdk/client-lambda");
23
+
24
+ exports.handler = async (event) => {
25
+ if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
26
+ const properties = event.ResourceProperties;
27
+ const client = new LambdaClient({ maxAttempts: 1 });
28
+ const response = await client.send(new InvokeCommand({
29
+ FunctionName: properties.FunctionName,
30
+ InvocationType: "RequestResponse",
31
+ Payload: JSON.stringify({ projectionTask: { task: properties.Task, deployToken: properties.DeployToken } }),
32
+ }));
33
+ const text = Buffer.from(response.Payload || []).toString("utf8");
34
+ console.log("deploy render result: " + text);
35
+ if (response.FunctionError) {
36
+ throw new Error("The post-deploy render failed (" + response.FunctionError + "): " + text.slice(0, ${FAILURE_MESSAGE_LIMIT}));
37
+ }
38
+ return { PhysicalResourceId: "reproject-" + properties.DeployToken, Data: { Result: text.slice(0, ${RESULT_SUMMARY_LIMIT}) } };
39
+ };
40
+ `;
41
+ /**
42
+ * Renders every published page after each backend deploy and checks the site store for drift (a dry run:
43
+ * nothing is deleted). The token is a property, so CloudFormation runs the resource again on every deploy.
44
+ */
45
+ export function addReprojectOnDeploy(stack, contentActions, deployToken) {
46
+ const handler = new lambda.Function(stack, "DeployRenderHandler", {
47
+ runtime: lambda.Runtime.NODEJS_20_X,
48
+ handler: "index.handler",
49
+ code: lambda.Code.fromInline(DEPLOY_RENDER_HANDLER_SOURCE),
50
+ timeout: Duration.seconds(REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS),
51
+ description: "Starts the post-deploy render of the site and fails the deploy if it cannot finish.",
52
+ });
53
+ handler.addToRolePolicy(new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }));
54
+ const provider = new Provider(stack, "DeployRenderProvider", { onEventHandler: handler });
55
+ new cloudwatch.Alarm(stack, "DeployRenderDriftAlarm", {
56
+ metric: new cloudwatch.Metric({
57
+ namespace: DEPLOY_RENDER_METRIC_NAMESPACE,
58
+ metricName: DEPLOY_RENDER_DRIFT_METRIC_NAME,
59
+ dimensionsMap: { FunctionName: contentActions.functionName },
60
+ statistic: "Maximum",
61
+ period: Duration.days(1),
62
+ }),
63
+ evaluationPeriods: 1,
64
+ threshold: 1,
65
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
66
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
67
+ alarmDescription: "The last post-deploy render found drift between the published content and the site store (stale pages, orphan pages or redirects). Nothing was deleted; run papyrus ops content project --reconcile to repair.",
68
+ });
69
+ return new CustomResource(stack, "ReprojectOnDeploy", {
70
+ serviceToken: provider.serviceToken,
71
+ properties: { FunctionName: contentActions.functionName, Task: DEPLOY_RENDER_TASK, DeployToken: deployToken },
72
+ });
73
+ }
@@ -0,0 +1,78 @@
1
+ import { CustomResource, Duration, Stack } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import { Provider } from "aws-cdk-lib/custom-resources";
6
+
7
+ export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 900;
8
+ export const DEPLOY_RENDER_METRIC_NAMESPACE = "Papyrus/Projection";
9
+ export const DEPLOY_RENDER_DRIFT_METRIC_NAME = "DeployRenderDrift";
10
+ export const DEPLOY_RENDER_TASK = "deploy-render";
11
+ const FAILURE_MESSAGE_LIMIT = 600;
12
+ const RESULT_SUMMARY_LIMIT = 900;
13
+
14
+ export function reprojectDeployToken(environment: Record<string, string | undefined>, now: () => number = Date.now): string {
15
+ const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
16
+ return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
17
+ }
18
+
19
+ /**
20
+ * Runs inside the deploy: invokes the content-actions Lambda synchronously, so a render that fails fails the
21
+ * stack update (and with it the Amplify build) with the failure in the build log. The Lambda's result, which
22
+ * includes the drift check, is written to its log and returned as the resource's data.
23
+ */
24
+ export const DEPLOY_RENDER_HANDLER_SOURCE = `
25
+ const { LambdaClient, InvokeCommand } = require("@aws-sdk/client-lambda");
26
+
27
+ exports.handler = async (event) => {
28
+ if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
29
+ const properties = event.ResourceProperties;
30
+ const client = new LambdaClient({ maxAttempts: 1 });
31
+ const response = await client.send(new InvokeCommand({
32
+ FunctionName: properties.FunctionName,
33
+ InvocationType: "RequestResponse",
34
+ Payload: JSON.stringify({ projectionTask: { task: properties.Task, deployToken: properties.DeployToken } }),
35
+ }));
36
+ const text = Buffer.from(response.Payload || []).toString("utf8");
37
+ console.log("deploy render result: " + text);
38
+ if (response.FunctionError) {
39
+ throw new Error("The post-deploy render failed (" + response.FunctionError + "): " + text.slice(0, ${FAILURE_MESSAGE_LIMIT}));
40
+ }
41
+ return { PhysicalResourceId: "reproject-" + properties.DeployToken, Data: { Result: text.slice(0, ${RESULT_SUMMARY_LIMIT}) } };
42
+ };
43
+ `;
44
+
45
+ /**
46
+ * Renders every published page after each backend deploy and checks the site store for drift (a dry run:
47
+ * nothing is deleted). The token is a property, so CloudFormation runs the resource again on every deploy.
48
+ */
49
+ export function addReprojectOnDeploy(stack: Stack, contentActions: lambda.IFunction, deployToken: string): CustomResource {
50
+ const handler = new lambda.Function(stack, "DeployRenderHandler", {
51
+ runtime: lambda.Runtime.NODEJS_20_X,
52
+ handler: "index.handler",
53
+ code: lambda.Code.fromInline(DEPLOY_RENDER_HANDLER_SOURCE),
54
+ timeout: Duration.seconds(REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS),
55
+ description: "Starts the post-deploy render of the site and fails the deploy if it cannot finish.",
56
+ });
57
+ handler.addToRolePolicy(new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }));
58
+ const provider = new Provider(stack, "DeployRenderProvider", { onEventHandler: handler });
59
+ new cloudwatch.Alarm(stack, "DeployRenderDriftAlarm", {
60
+ metric: new cloudwatch.Metric({
61
+ namespace: DEPLOY_RENDER_METRIC_NAMESPACE,
62
+ metricName: DEPLOY_RENDER_DRIFT_METRIC_NAME,
63
+ dimensionsMap: { FunctionName: contentActions.functionName },
64
+ statistic: "Maximum",
65
+ period: Duration.days(1),
66
+ }),
67
+ evaluationPeriods: 1,
68
+ threshold: 1,
69
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
70
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
71
+ alarmDescription:
72
+ "The last post-deploy render found drift between the published content and the site store (stale pages, orphan pages or redirects). Nothing was deleted; run papyrus ops content project --reconcile to repair.",
73
+ });
74
+ return new CustomResource(stack, "ReprojectOnDeploy", {
75
+ serviceToken: provider.serviceToken,
76
+ properties: { FunctionName: contentActions.functionName, Task: DEPLOY_RENDER_TASK, DeployToken: deployToken },
77
+ });
78
+ }
@@ -0,0 +1,67 @@
1
+ export const S3_PROJECTION_PROJECTOR_MODULE = "site_projector";
2
+ export const S3_PROJECTION_STATIC_DIRECTORY = "/var/task/site_static";
3
+ export function distributionIdFromArn(distributionArn) {
4
+ return distributionArn.slice(distributionArn.lastIndexOf("/") + 1);
5
+ }
6
+ export function s3ProjectionEnvironment(input) {
7
+ const environment = {
8
+ PAPYRUS_PUBLICATION_KIND: "s3-projection",
9
+ PAPYRUS_SITE_BUCKET: input.bucketName,
10
+ };
11
+ const distributionId = input.distributionId
12
+ ?? (input.distributionArn !== undefined ? distributionIdFromArn(input.distributionArn) : undefined);
13
+ if (distributionId !== undefined)
14
+ environment.PAPYRUS_EDGE_DISTRIBUTION_ID = distributionId;
15
+ environment.PAPYRUS_REDIRECTS_KVS_ARN = input.keyValueStoreArn;
16
+ environment.PAPYRUS_PROJECTION_DLQ_URL = input.deadLetterQueueUrl;
17
+ if (input.cache?.htmlSMaxAgeSeconds !== undefined)
18
+ environment.PAPYRUS_HTML_S_MAXAGE_SECONDS = String(input.cache.htmlSMaxAgeSeconds);
19
+ if (input.cache?.invalidation === "off")
20
+ environment.PAPYRUS_EDGE_INVALIDATION = "off";
21
+ if (input.publicHostName !== undefined)
22
+ environment.PAPYRUS_SITE_PUBLIC_URL = `https://${input.publicHostName}`;
23
+ if (input.projector !== undefined)
24
+ environment.PAPYRUS_SITE_PROJECTOR = S3_PROJECTION_PROJECTOR_MODULE;
25
+ if (input.staticDir !== undefined)
26
+ environment.PAPYRUS_SITE_STATIC_DIR = S3_PROJECTION_STATIC_DIRECTORY;
27
+ return environment;
28
+ }
29
+ export function s3ProjectionPolicyStatements(input) {
30
+ const statements = [
31
+ { actions: ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"], resources: [`${input.bucketArn}/*`] },
32
+ { actions: ["s3:ListBucket"], resources: [input.bucketArn] },
33
+ ];
34
+ if (input.distributionArn !== undefined && input.cache?.invalidation !== "off") {
35
+ statements.push({ actions: ["cloudfront:CreateInvalidation"], resources: [input.distributionArn] });
36
+ }
37
+ statements.push({
38
+ actions: [
39
+ "cloudfront-keyvaluestore:DescribeKeyValueStore",
40
+ "cloudfront-keyvaluestore:GetKey",
41
+ "cloudfront-keyvaluestore:PutKey",
42
+ "cloudfront-keyvaluestore:DeleteKey",
43
+ "cloudfront-keyvaluestore:UpdateKeys",
44
+ "cloudfront-keyvaluestore:ListKeys",
45
+ ],
46
+ resources: [input.keyValueStoreArn],
47
+ }, { actions: ["sqs:SendMessage"], resources: [input.deadLetterQueueArn] });
48
+ return statements;
49
+ }
50
+ export const S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB = 2048;
51
+ export function planS3ProjectionBackend(publication) {
52
+ if (publication === undefined)
53
+ return { enabled: false, siteFiles: [], contentActionsMemorySize: undefined };
54
+ const siteFiles = [];
55
+ if (publication.projector !== undefined) {
56
+ siteFiles.push({ from: publication.projector, to: `${S3_PROJECTION_PROJECTOR_MODULE}.py` });
57
+ }
58
+ if (publication.staticDir !== undefined) {
59
+ siteFiles.push({ from: publication.staticDir, to: "site_static" });
60
+ }
61
+ return { enabled: true, siteFiles, contentActionsMemorySize: S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB };
62
+ }
63
+ export function contentActionsPublicationWiring(publication) {
64
+ if (publication === undefined)
65
+ return { environment: {}, policyStatements: [] };
66
+ return { environment: publication.environment, policyStatements: publication.policyStatements };
67
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Environment contract and IAM statements for the s3-projection publication.
3
+ * Pure (no CDK imports) so both can be tested without synthesizing.
4
+ */
5
+ export type S3ProjectionSettingsInput = {
6
+ bucketName: string;
7
+ bucketArn: string;
8
+ keyValueStoreArn: string;
9
+ deadLetterQueueUrl: string;
10
+ deadLetterQueueArn: string;
11
+ distributionArn?: string;
12
+ distributionId?: string;
13
+ publicHostName?: string;
14
+ cache?: { htmlSMaxAgeSeconds?: number; invalidation?: "targeted" | "off" };
15
+ projector?: string;
16
+ staticDir?: string;
17
+ };
18
+
19
+ export type S3ProjectionPolicyStatementSpec = {
20
+ actions: string[];
21
+ resources: string[];
22
+ };
23
+
24
+ export const S3_PROJECTION_PROJECTOR_MODULE = "site_projector";
25
+ export const S3_PROJECTION_STATIC_DIRECTORY = "/var/task/site_static";
26
+
27
+ export function distributionIdFromArn(distributionArn: string): string {
28
+ return distributionArn.slice(distributionArn.lastIndexOf("/") + 1);
29
+ }
30
+
31
+ export function s3ProjectionEnvironment(input: S3ProjectionSettingsInput): Record<string, string> {
32
+ const environment: Record<string, string> = {
33
+ PAPYRUS_PUBLICATION_KIND: "s3-projection",
34
+ PAPYRUS_SITE_BUCKET: input.bucketName,
35
+ };
36
+ const distributionId = input.distributionId
37
+ ?? (input.distributionArn !== undefined ? distributionIdFromArn(input.distributionArn) : undefined);
38
+ if (distributionId !== undefined) environment.PAPYRUS_EDGE_DISTRIBUTION_ID = distributionId;
39
+ environment.PAPYRUS_REDIRECTS_KVS_ARN = input.keyValueStoreArn;
40
+ environment.PAPYRUS_PROJECTION_DLQ_URL = input.deadLetterQueueUrl;
41
+ if (input.cache?.htmlSMaxAgeSeconds !== undefined) environment.PAPYRUS_HTML_S_MAXAGE_SECONDS = String(input.cache.htmlSMaxAgeSeconds);
42
+ if (input.cache?.invalidation === "off") environment.PAPYRUS_EDGE_INVALIDATION = "off";
43
+ if (input.publicHostName !== undefined) environment.PAPYRUS_SITE_PUBLIC_URL = `https://${input.publicHostName}`;
44
+ if (input.projector !== undefined) environment.PAPYRUS_SITE_PROJECTOR = S3_PROJECTION_PROJECTOR_MODULE;
45
+ if (input.staticDir !== undefined) environment.PAPYRUS_SITE_STATIC_DIR = S3_PROJECTION_STATIC_DIRECTORY;
46
+ return environment;
47
+ }
48
+
49
+ export function s3ProjectionPolicyStatements(input: S3ProjectionSettingsInput): S3ProjectionPolicyStatementSpec[] {
50
+ const statements: S3ProjectionPolicyStatementSpec[] = [
51
+ { actions: ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"], resources: [`${input.bucketArn}/*`] },
52
+ { actions: ["s3:ListBucket"], resources: [input.bucketArn] },
53
+ ];
54
+ if (input.distributionArn !== undefined && input.cache?.invalidation !== "off") {
55
+ statements.push({ actions: ["cloudfront:CreateInvalidation"], resources: [input.distributionArn] });
56
+ }
57
+ statements.push(
58
+ {
59
+ actions: [
60
+ "cloudfront-keyvaluestore:DescribeKeyValueStore",
61
+ "cloudfront-keyvaluestore:GetKey",
62
+ "cloudfront-keyvaluestore:PutKey",
63
+ "cloudfront-keyvaluestore:DeleteKey",
64
+ "cloudfront-keyvaluestore:UpdateKeys",
65
+ "cloudfront-keyvaluestore:ListKeys",
66
+ ],
67
+ resources: [input.keyValueStoreArn],
68
+ },
69
+ { actions: ["sqs:SendMessage"], resources: [input.deadLetterQueueArn] },
70
+ );
71
+ return statements;
72
+ }
73
+
74
+ export type S3ProjectionBackendPlan = {
75
+ enabled: boolean;
76
+ siteFiles: Array<{ from: string; to: string }>;
77
+ contentActionsMemorySize: number | undefined;
78
+ };
79
+
80
+ export const S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB = 2048;
81
+
82
+ export function planS3ProjectionBackend(
83
+ publication: { projector?: string; staticDir?: string } | undefined,
84
+ ): S3ProjectionBackendPlan {
85
+ if (publication === undefined) return { enabled: false, siteFiles: [], contentActionsMemorySize: undefined };
86
+ const siteFiles: Array<{ from: string; to: string }> = [];
87
+ if (publication.projector !== undefined) {
88
+ siteFiles.push({ from: publication.projector, to: `${S3_PROJECTION_PROJECTOR_MODULE}.py` });
89
+ }
90
+ if (publication.staticDir !== undefined) {
91
+ siteFiles.push({ from: publication.staticDir, to: "site_static" });
92
+ }
93
+ return { enabled: true, siteFiles, contentActionsMemorySize: S3_PROJECTION_CONTENT_ACTIONS_MEMORY_MB };
94
+ }
95
+
96
+ export function contentActionsPublicationWiring<Statement>(
97
+ publication: { environment: Record<string, string>; policyStatements: Statement[] } | undefined,
98
+ ): { environment: Record<string, string>; policyStatements: Statement[] } {
99
+ if (publication === undefined) return { environment: {}, policyStatements: [] };
100
+ return { environment: publication.environment, policyStatements: publication.policyStatements };
101
+ }