@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,35 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
3
+ import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import type { Construct } from "constructs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ export const signupSubmit = defineFunction(
13
+ (scope: Construct) => {
14
+ const rateTable = new Table(scope, "PapyrusSignupRateCounters", {
15
+ partitionKey: { name: "pk", type: AttributeType.STRING },
16
+ billingMode: BillingMode.PAY_PER_REQUEST,
17
+ timeToLiveAttribute: "expiresAt",
18
+ removalPolicy: RemovalPolicy.DESTROY,
19
+ });
20
+ const lambda = new Function(scope, "papyrus-signup-submit", {
21
+ runtime: Runtime.PYTHON_3_12,
22
+ architecture: Architecture.ARM_64,
23
+ handler: "handler.handler",
24
+ timeout: Duration.seconds(10),
25
+ memorySize: 256,
26
+ code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
27
+ environment: { PAPYRUS_SIGNUP_RATE_TABLE: rateTable.tableName },
28
+ });
29
+ lambda.addToRolePolicy(
30
+ new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [rateTable.tableArn] }),
31
+ );
32
+ return lambda;
33
+ },
34
+ { resourceGroupName: "data" },
35
+ );
@@ -0,0 +1,59 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import { signupSubmit } from "./function/resource.js";
4
+ import { signupSchemaDefinitions } from "./schema.js";
5
+ import { SIGNUP_MUTATION_FIELD, WAF_MINIMUM_RATE_LIMIT, addSignupRateLimit } from "./waf.js";
6
+ const FORM_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
7
+ const KNOWN_KEYS = new Set(["name", "forms", "addressLimitPerMinute", "waf"]);
8
+ export const signupManifest = {
9
+ name: "signup",
10
+ contract: 1,
11
+ models: ["SignupEntry"],
12
+ mutations: [SIGNUP_MUTATION_FIELD],
13
+ guest: { mutations: [SIGNUP_MUTATION_FIELD] },
14
+ newsroomPanels: [{ id: "signups", label: "Signups" }],
15
+ staticAssets: ["_plugins/papyrus-signup@1.js"],
16
+ };
17
+ export function resolveSignupConfig(request) {
18
+ const raw = typeof request === "string" ? { name: request } : request;
19
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
20
+ if (unknown.length > 0)
21
+ throw new Error(`Unknown signup plugin settings: ${unknown.join(", ")}`);
22
+ const forms = raw.forms ?? ["newsletter"];
23
+ if (!Array.isArray(forms) || forms.length === 0 || !forms.every((form) => typeof form === "string" && FORM_NAME.test(form))) {
24
+ throw new Error("signup plugin forms must be a non-empty list of lowercase names (letters, digits, hyphens; at most 40 characters)");
25
+ }
26
+ const addressLimitPerMinute = raw.addressLimitPerMinute ?? 20;
27
+ if (!Number.isInteger(addressLimitPerMinute) || addressLimitPerMinute < WAF_MINIMUM_RATE_LIMIT || addressLimitPerMinute > 1000) {
28
+ throw new Error(`signup plugin addressLimitPerMinute must be an integer from ${WAF_MINIMUM_RATE_LIMIT} to 1000`);
29
+ }
30
+ const waf = raw.waf ?? true;
31
+ if (typeof waf !== "boolean")
32
+ throw new Error("signup plugin waf must be a boolean");
33
+ return { forms: [...new Set(forms)], addressLimitPerMinute, waf };
34
+ }
35
+ export const signupPlugin = {
36
+ manifest: signupManifest,
37
+ resolveConfig: resolveSignupConfig,
38
+ functions: () => ({ signupSubmit }),
39
+ schemaDefinitions: signupSchemaDefinitions,
40
+ publicSettings: (config) => ({ contract: signupManifest.contract, forms: config.forms }),
41
+ wire(backend, config, context) {
42
+ const lambda = backend.signupSubmit.resources.lambda;
43
+ const stack = Stack.of(lambda);
44
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
45
+ lambda.addEnvironment("PAPYRUS_SIGNUP_FORMS", config.forms.join(","));
46
+ lambda.addEnvironment("PAPYRUS_SIGNUP_ADDRESS_LIMIT", String(config.addressLimitPerMinute));
47
+ lambda.addToRolePolicy(new PolicyStatement({
48
+ effect: Effect.ALLOW,
49
+ actions: ["appsync:GraphQL"],
50
+ resources: [`arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/Mutation/fields/createSignupEntry`],
51
+ }));
52
+ if (config.waf) {
53
+ addSignupRateLimit(context.createStack("signup-protection"), {
54
+ graphqlApiArn: `arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}`,
55
+ limit: config.addressLimitPerMinute,
56
+ });
57
+ }
58
+ },
59
+ };
@@ -0,0 +1,74 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
4
+ import type { PapyrusPlugin, PluginManifest, PluginRequest, PluginWiringContext } from "../types";
5
+ import { signupSubmit } from "./function/resource";
6
+ import { signupSchemaDefinitions } from "./schema";
7
+ import { SIGNUP_MUTATION_FIELD, WAF_MINIMUM_RATE_LIMIT, addSignupRateLimit } from "./waf";
8
+
9
+ export type SignupPluginConfig = {
10
+ /** Form names the site accepts. A submission naming any other form is rejected. */
11
+ forms: string[];
12
+ /** Most submissions one address may make in a minute: enforced by the function and, with `waf`, by the web ACL. */
13
+ addressLimitPerMinute: number;
14
+ /** A regional web ACL with a rate-based rule on the API (about 6 USD a month). Off: the function's counters only. */
15
+ waf: boolean;
16
+ };
17
+
18
+ const FORM_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
19
+ const KNOWN_KEYS = new Set(["name", "forms", "addressLimitPerMinute", "waf"]);
20
+
21
+ export const signupManifest: PluginManifest = {
22
+ name: "signup",
23
+ contract: 1,
24
+ models: ["SignupEntry"],
25
+ mutations: [SIGNUP_MUTATION_FIELD],
26
+ guest: { mutations: [SIGNUP_MUTATION_FIELD] },
27
+ newsroomPanels: [{ id: "signups", label: "Signups" }],
28
+ staticAssets: ["_plugins/papyrus-signup@1.js"],
29
+ };
30
+
31
+ export function resolveSignupConfig(request: PluginRequest): SignupPluginConfig {
32
+ const raw = typeof request === "string" ? { name: request } : request;
33
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
34
+ if (unknown.length > 0) throw new Error(`Unknown signup plugin settings: ${unknown.join(", ")}`);
35
+ const forms = (raw.forms as string[] | undefined) ?? ["newsletter"];
36
+ if (!Array.isArray(forms) || forms.length === 0 || !forms.every((form) => typeof form === "string" && FORM_NAME.test(form))) {
37
+ throw new Error("signup plugin forms must be a non-empty list of lowercase names (letters, digits, hyphens; at most 40 characters)");
38
+ }
39
+ const addressLimitPerMinute = (raw.addressLimitPerMinute as number | undefined) ?? 20;
40
+ if (!Number.isInteger(addressLimitPerMinute) || addressLimitPerMinute < WAF_MINIMUM_RATE_LIMIT || addressLimitPerMinute > 1000) {
41
+ throw new Error(`signup plugin addressLimitPerMinute must be an integer from ${WAF_MINIMUM_RATE_LIMIT} to 1000`);
42
+ }
43
+ const waf = (raw.waf as boolean | undefined) ?? true;
44
+ if (typeof waf !== "boolean") throw new Error("signup plugin waf must be a boolean");
45
+ return { forms: [...new Set(forms)], addressLimitPerMinute, waf };
46
+ }
47
+
48
+ export const signupPlugin: PapyrusPlugin<SignupPluginConfig, { signupSubmit: typeof signupSubmit }> = {
49
+ manifest: signupManifest,
50
+ resolveConfig: resolveSignupConfig,
51
+ functions: () => ({ signupSubmit }),
52
+ schemaDefinitions: signupSchemaDefinitions,
53
+ publicSettings: (config) => ({ contract: signupManifest.contract, forms: config.forms }),
54
+ wire(backend, config, context: PluginWiringContext) {
55
+ const lambda = backend.signupSubmit.resources.lambda as LambdaFunction;
56
+ const stack = Stack.of(lambda);
57
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
58
+ lambda.addEnvironment("PAPYRUS_SIGNUP_FORMS", config.forms.join(","));
59
+ lambda.addEnvironment("PAPYRUS_SIGNUP_ADDRESS_LIMIT", String(config.addressLimitPerMinute));
60
+ lambda.addToRolePolicy(
61
+ new PolicyStatement({
62
+ effect: Effect.ALLOW,
63
+ actions: ["appsync:GraphQL"],
64
+ resources: [`arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/Mutation/fields/createSignupEntry`],
65
+ }),
66
+ );
67
+ if (config.waf) {
68
+ addSignupRateLimit(context.createStack("signup-protection"), {
69
+ graphqlApiArn: `arn:aws:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}`,
70
+ limit: config.addressLimitPerMinute,
71
+ });
72
+ }
73
+ },
74
+ };
@@ -0,0 +1,40 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { signupSubmit } from "./function/resource.js";
3
+ /**
4
+ * SignupEntry is never guest-readable and has no create rule for anyone: only the
5
+ * submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
6
+ */
7
+ export function signupSchemaDefinitions() {
8
+ return {
9
+ SignupEntry: a
10
+ .model({
11
+ id: a.id().required(),
12
+ form: a.string().required(),
13
+ value: a.string().required(),
14
+ submittedAt: a.datetime().required(),
15
+ })
16
+ .disableOperations(["subscriptions"])
17
+ .secondaryIndexes((index) => [
18
+ index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
19
+ ])
20
+ .authorization((allow) => [
21
+ allow.group("editor").to(["read"]),
22
+ allow.group("admin").to(["read", "delete"]),
23
+ ]),
24
+ SignupReceipt: a.customType({
25
+ status: a.string().required(),
26
+ message: a.string(),
27
+ }),
28
+ submitSignup: a
29
+ .mutation()
30
+ .arguments({
31
+ form: a.string().required(),
32
+ value: a.string().required(),
33
+ renderedAt: a.string().required(),
34
+ website: a.string(),
35
+ })
36
+ .returns(a.ref("SignupReceipt"))
37
+ .authorization((allow) => [allow.guest()])
38
+ .handler(a.handler.function(signupSubmit)),
39
+ };
40
+ }
@@ -0,0 +1,41 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { signupSubmit } from "./function/resource";
3
+
4
+ /**
5
+ * SignupEntry is never guest-readable and has no create rule for anyone: only the
6
+ * submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
7
+ */
8
+ export function signupSchemaDefinitions() {
9
+ return {
10
+ SignupEntry: a
11
+ .model({
12
+ id: a.id().required(),
13
+ form: a.string().required(),
14
+ value: a.string().required(),
15
+ submittedAt: a.datetime().required(),
16
+ })
17
+ .disableOperations(["subscriptions"])
18
+ .secondaryIndexes((index) => [
19
+ index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
20
+ ])
21
+ .authorization((allow) => [
22
+ allow.group("editor").to(["read"]),
23
+ allow.group("admin").to(["read", "delete"]),
24
+ ]),
25
+ SignupReceipt: a.customType({
26
+ status: a.string().required(),
27
+ message: a.string(),
28
+ }),
29
+ submitSignup: a
30
+ .mutation()
31
+ .arguments({
32
+ form: a.string().required(),
33
+ value: a.string().required(),
34
+ renderedAt: a.string().required(),
35
+ website: a.string(),
36
+ })
37
+ .returns(a.ref("SignupReceipt"))
38
+ .authorization((allow) => [allow.guest()])
39
+ .handler(a.handler.function(signupSubmit)),
40
+ };
41
+ }
@@ -0,0 +1,48 @@
1
+ import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
2
+ export const SIGNUP_MUTATION_FIELD = "submitSignup";
3
+ export const WAF_MINIMUM_RATE_LIMIT = 10;
4
+ export const WAF_EVALUATION_WINDOW_SECONDS = 60;
5
+ /**
6
+ * A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
7
+ * submitSignup requests from one address in a minute are blocked with a 429 before
8
+ * the function runs. Only requests whose body names the mutation count, so reads,
9
+ * newsroom saves and CLI imports from the same address are never limited.
10
+ */
11
+ export function addSignupRateLimit(scope, input) {
12
+ if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
13
+ throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
14
+ }
15
+ const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
16
+ scope: "REGIONAL",
17
+ defaultAction: { allow: {} },
18
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
19
+ rules: [
20
+ {
21
+ name: "SignupRateLimitPerAddress",
22
+ priority: 0,
23
+ action: { block: { customResponse: { responseCode: 429 } } },
24
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
25
+ statement: {
26
+ rateBasedStatement: {
27
+ limit: input.limit,
28
+ evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
29
+ aggregateKeyType: "IP",
30
+ scopeDownStatement: {
31
+ byteMatchStatement: {
32
+ searchString: SIGNUP_MUTATION_FIELD,
33
+ positionalConstraint: "CONTAINS",
34
+ fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
35
+ textTransformations: [{ priority: 0, type: "NONE" }],
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ ],
42
+ });
43
+ new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
44
+ resourceArn: input.graphqlApiArn,
45
+ webAclArn: acl.attrArn,
46
+ });
47
+ return acl;
48
+ }
@@ -0,0 +1,51 @@
1
+ import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
2
+ import type { Construct } from "constructs";
3
+
4
+ export const SIGNUP_MUTATION_FIELD = "submitSignup";
5
+ export const WAF_MINIMUM_RATE_LIMIT = 10;
6
+ export const WAF_EVALUATION_WINDOW_SECONDS = 60;
7
+
8
+ /**
9
+ * A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
10
+ * submitSignup requests from one address in a minute are blocked with a 429 before
11
+ * the function runs. Only requests whose body names the mutation count, so reads,
12
+ * newsroom saves and CLI imports from the same address are never limited.
13
+ */
14
+ export function addSignupRateLimit(scope: Construct, input: { graphqlApiArn: string; limit: number }): CfnWebACL {
15
+ if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
16
+ throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
17
+ }
18
+ const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
19
+ scope: "REGIONAL",
20
+ defaultAction: { allow: {} },
21
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
22
+ rules: [
23
+ {
24
+ name: "SignupRateLimitPerAddress",
25
+ priority: 0,
26
+ action: { block: { customResponse: { responseCode: 429 } } },
27
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
28
+ statement: {
29
+ rateBasedStatement: {
30
+ limit: input.limit,
31
+ evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
32
+ aggregateKeyType: "IP",
33
+ scopeDownStatement: {
34
+ byteMatchStatement: {
35
+ searchString: SIGNUP_MUTATION_FIELD,
36
+ positionalConstraint: "CONTAINS",
37
+ fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
38
+ textTransformations: [{ priority: 0, type: "NONE" }],
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ ],
45
+ });
46
+ new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
47
+ resourceArn: input.graphqlApiArn,
48
+ webAclArn: acl.attrArn,
49
+ });
50
+ return acl;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { Construct } from "constructs";
2
+
3
+ /** How a site asks for a plugin in `papyrus.config.ts`: `plugins: ["signup"]` or `plugins: [{ name: "signup", forms: ["news"] }]`. */
4
+ export type PluginRequest = string | ({ name: string } & Record<string, unknown>);
5
+
6
+ /** What a plugin declares about itself. A test pins the schema, the guest rules and the static files to this. */
7
+ export type PluginManifest = {
8
+ name: string;
9
+ /** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
10
+ contract: number;
11
+ models: string[];
12
+ mutations: string[];
13
+ /** What an anonymous visitor may call. Everything else is closed to guests. */
14
+ guest: { mutations: string[] };
15
+ newsroomPanels: { id: string; label: string }[];
16
+ /** Keys the projector writes into the site bucket when the plugin is enabled. */
17
+ staticAssets: string[];
18
+ };
19
+
20
+ /** Where the deploy-time wiring puts its resources. Filled in by `defineSiteBackend`. */
21
+ export type PluginWiringContext = {
22
+ createStack: (name: string) => Construct;
23
+ graphqlApiId: string;
24
+ graphqlEndpoint: string;
25
+ };
26
+
27
+ export type PapyrusPlugin<Config, Functions = unknown> = {
28
+ manifest: PluginManifest;
29
+ /** Validates the request and fills in defaults. Throws a readable error. */
30
+ resolveConfig: (request: PluginRequest) => Config;
31
+ /** Amplify function resources the plugin adds to `defineBackend`. */
32
+ functions: () => Functions;
33
+ /** Models and custom operations merged into the site schema. */
34
+ schemaDefinitions: () => Record<string, unknown>;
35
+ /** The settings the newsroom and the page component may read from `amplify_outputs.json` (`custom.papyrusPlugins.<name>`). Never secrets. */
36
+ publicSettings: (config: Config) => Record<string, unknown>;
37
+ /** Everything that needs the backend to exist: IAM, environment, WAF, outputs. */
38
+ wire: (backend: any, config: Config, context: PluginWiringContext) => void;
39
+ };
@@ -0,0 +1,24 @@
1
+ export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
2
+ export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
3
+ /**
4
+ * Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
5
+ * nothing is added to the stacks, the environment or the outputs.
6
+ */
7
+ export function wirePlugins(backend, plugins, contentActionsLambda) {
8
+ if (plugins.length === 0)
9
+ return;
10
+ const graphqlApi = backend.data.resources.graphqlApi;
11
+ const context = {
12
+ createStack: (name) => backend.createStack(name),
13
+ graphqlApiId: graphqlApi.apiId,
14
+ graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
15
+ };
16
+ for (const { plugin, config } of plugins)
17
+ plugin.wire(backend, config, context);
18
+ contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
19
+ backend.addOutput({
20
+ custom: {
21
+ [PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
22
+ },
23
+ });
24
+ }
@@ -0,0 +1,26 @@
1
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
2
+ import type { ResolvedPlugin } from "./registry";
3
+
4
+ export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
5
+ export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
6
+
7
+ /**
8
+ * Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
9
+ * nothing is added to the stacks, the environment or the outputs.
10
+ */
11
+ export function wirePlugins(backend: any, plugins: ResolvedPlugin[], contentActionsLambda: LambdaFunction): void {
12
+ if (plugins.length === 0) return;
13
+ const graphqlApi = backend.data.resources.graphqlApi;
14
+ const context = {
15
+ createStack: (name: string) => backend.createStack(name),
16
+ graphqlApiId: graphqlApi.apiId as string,
17
+ graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string,
18
+ };
19
+ for (const { plugin, config } of plugins) plugin.wire(backend, config, context);
20
+ contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
21
+ backend.addOutput({
22
+ custom: {
23
+ [PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_MARKUS_PUBLICATION = { kind: "s3-projection", reprojectOnDeploy: true };
2
+ /**
3
+ * The publication a site backend is built with. A Markus site that names a `reader` Amplify app
4
+ * keeps publishing through it (`amplify-static`); every other Markus site gets the s3-projection
5
+ * default unless it writes `publication: false`. Sites with another renderer get nothing unless
6
+ * they write a `publication`.
7
+ */
8
+ export function resolveSitePublication(backend, rendererKind) {
9
+ if (backend.publication === false)
10
+ return undefined;
11
+ if (backend.publication !== undefined)
12
+ return backend.publication;
13
+ if (rendererKind !== "markus")
14
+ return undefined;
15
+ if (backend.reader !== undefined)
16
+ return undefined;
17
+ return DEFAULT_MARKUS_PUBLICATION;
18
+ }
@@ -0,0 +1,20 @@
1
+ import type { PapyrusPublicationConfig, PapyrusSiteBackendConfig } from "../site-backend-config";
2
+
3
+ export const DEFAULT_MARKUS_PUBLICATION: PapyrusPublicationConfig = { kind: "s3-projection", reprojectOnDeploy: true };
4
+
5
+ /**
6
+ * The publication a site backend is built with. A Markus site that names a `reader` Amplify app
7
+ * keeps publishing through it (`amplify-static`); every other Markus site gets the s3-projection
8
+ * default unless it writes `publication: false`. Sites with another renderer get nothing unless
9
+ * they write a `publication`.
10
+ */
11
+ export function resolveSitePublication(
12
+ backend: Pick<PapyrusSiteBackendConfig, "publication" | "reader">,
13
+ rendererKind: string | undefined,
14
+ ): PapyrusPublicationConfig | undefined {
15
+ if (backend.publication === false) return undefined;
16
+ if (backend.publication !== undefined) return backend.publication;
17
+ if (rendererKind !== "markus") return undefined;
18
+ if (backend.reader !== undefined) return undefined;
19
+ return DEFAULT_MARKUS_PUBLICATION;
20
+ }
@@ -0,0 +1,26 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ export const FREE_INVALIDATION_PATHS_PER_MONTH = 1000;
4
+ export const DAYS_PER_MONTH = 30.4;
5
+ export const ALARM_WINDOW_DAYS = 7;
6
+ export const INVALIDATION_METRIC_NAMESPACE = "Papyrus/Edge";
7
+ export const INVALIDATION_METRIC_NAME = "InvalidatedPaths";
8
+ /** CloudWatch cannot alarm across more than seven days, so the monthly free allowance is checked as its weekly pace. */
9
+ export const WEEKLY_INVALIDATION_PATH_THRESHOLD = Math.round((FREE_INVALIDATION_PATHS_PER_MONTH * ALARM_WINDOW_DAYS) / DAYS_PER_MONTH);
10
+ export function addInvalidationPathAlarm(scope, id, distributionId) {
11
+ const metric = new cloudwatch.Metric({
12
+ namespace: INVALIDATION_METRIC_NAMESPACE,
13
+ metricName: INVALIDATION_METRIC_NAME,
14
+ dimensionsMap: { DistributionId: distributionId },
15
+ statistic: "Sum",
16
+ period: Duration.days(ALARM_WINDOW_DAYS),
17
+ });
18
+ return new cloudwatch.Alarm(scope, id, {
19
+ metric,
20
+ evaluationPeriods: 1,
21
+ threshold: WEEKLY_INVALIDATION_PATH_THRESHOLD,
22
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
23
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
24
+ alarmDescription: `CloudFront invalidation paths on ${distributionId} are on pace to pass the ${FREE_INVALIDATION_PATHS_PER_MONTH} free paths a month.`,
25
+ });
26
+ }
@@ -0,0 +1,30 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ import type { Construct } from "constructs";
4
+
5
+ export const FREE_INVALIDATION_PATHS_PER_MONTH = 1000;
6
+ export const DAYS_PER_MONTH = 30.4;
7
+ export const ALARM_WINDOW_DAYS = 7;
8
+ export const INVALIDATION_METRIC_NAMESPACE = "Papyrus/Edge";
9
+ export const INVALIDATION_METRIC_NAME = "InvalidatedPaths";
10
+
11
+ /** CloudWatch cannot alarm across more than seven days, so the monthly free allowance is checked as its weekly pace. */
12
+ export const WEEKLY_INVALIDATION_PATH_THRESHOLD = Math.round((FREE_INVALIDATION_PATHS_PER_MONTH * ALARM_WINDOW_DAYS) / DAYS_PER_MONTH);
13
+
14
+ export function addInvalidationPathAlarm(scope: Construct, id: string, distributionId: string): cloudwatch.Alarm {
15
+ const metric = new cloudwatch.Metric({
16
+ namespace: INVALIDATION_METRIC_NAMESPACE,
17
+ metricName: INVALIDATION_METRIC_NAME,
18
+ dimensionsMap: { DistributionId: distributionId },
19
+ statistic: "Sum",
20
+ period: Duration.days(ALARM_WINDOW_DAYS),
21
+ });
22
+ return new cloudwatch.Alarm(scope, id, {
23
+ metric,
24
+ evaluationPeriods: 1,
25
+ threshold: WEEKLY_INVALIDATION_PATH_THRESHOLD,
26
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
27
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
28
+ alarmDescription: `CloudFront invalidation paths on ${distributionId} are on pace to pass the ${FREE_INVALIDATION_PATHS_PER_MONTH} free paths a month.`,
29
+ });
30
+ }
@@ -0,0 +1,37 @@
1
+ import cf from "cloudfront";
2
+
3
+ const redirectStore = cf.kvs();
4
+
5
+ async function lookUpRedirectTarget(uri) {
6
+ try {
7
+ const target = await redirectStore.get(uri);
8
+ return target ? String(target) : null;
9
+ } catch (error) {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function rewriteToIndexDocument(uri) {
15
+ if (uri.endsWith("/")) {
16
+ return uri + "index.html";
17
+ }
18
+ const lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
19
+ if (lastSegment.indexOf(".") === -1) {
20
+ return uri + "/index.html";
21
+ }
22
+ return uri;
23
+ }
24
+
25
+ async function handler(event) {
26
+ const request = event.request;
27
+ const redirectTarget = await lookUpRedirectTarget(request.uri);
28
+ if (redirectTarget !== null) {
29
+ return {
30
+ statusCode: 301,
31
+ statusDescription: "Moved Permanently",
32
+ headers: { location: { value: redirectTarget } },
33
+ };
34
+ }
35
+ request.uri = rewriteToIndexDocument(request.uri);
36
+ return request;
37
+ }
@@ -0,0 +1,74 @@
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, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import * as sqs from "aws-cdk-lib/aws-sqs";
7
+ import { execSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
11
+ import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
12
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const projectRoot = path.resolve(dirname, "../..");
14
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
15
+ function installCheckoutRequirements(outputDir) {
16
+ const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
17
+ execSync(`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`, { cwd: projectRoot, stdio: "inherit" });
18
+ }
19
+ export function addRenditionsFunction(scope, props) {
20
+ const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
21
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
22
+ retentionPeriod: Duration.days(14),
23
+ enforceSSL: true,
24
+ });
25
+ const renditions = new LambdaFunction(scope, "RenditionsFunction", {
26
+ runtime: Runtime.PYTHON_3_12,
27
+ architecture: Architecture.ARM_64,
28
+ handler: "handler.handler",
29
+ timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
30
+ memorySize: RENDITIONS_MEMORY_MB,
31
+ ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
32
+ retryAttempts: 2,
33
+ deadLetterQueue,
34
+ environment: renditionsEnvironment(props.siteBucket.bucketName),
35
+ code: props.code ?? pythonLambdaCode({
36
+ functionDir: "renditions",
37
+ modules: ["papyrus_content"],
38
+ requirements: renditionsRequirements,
39
+ noDeps: true,
40
+ checkoutExtras: installCheckoutRequirements,
41
+ siteFiles: props.siteFiles,
42
+ }, [
43
+ "bash",
44
+ "-c",
45
+ [
46
+ "set -euo pipefail",
47
+ "mkdir -p /asset-output/papyrus_content",
48
+ "cp amplify/functions/renditions/handler.py /asset-output/handler.py",
49
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
50
+ "python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
51
+ ].join(" && "),
52
+ ]),
53
+ });
54
+ renditions.addToRolePolicy(new PolicyStatement({
55
+ effect: Effect.ALLOW,
56
+ actions: ["s3:GetObject"],
57
+ resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
58
+ }));
59
+ renditions.addToRolePolicy(new PolicyStatement({
60
+ effect: Effect.ALLOW,
61
+ actions: ["s3:PutObject", "s3:GetObject"],
62
+ resources: [props.siteBucket.arnForObjects("*")],
63
+ }));
64
+ renditions.addToRolePolicy(new PolicyStatement({
65
+ effect: Effect.ALLOW,
66
+ actions: ["s3:ListBucket"],
67
+ resources: [props.siteBucket.bucketArn],
68
+ }));
69
+ const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
70
+ eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
71
+ });
72
+ rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
73
+ return { function: renditions, rule, deadLetterQueue };
74
+ }