@anthusai/papyrus 1.0.0-next.6 → 1.0.0-next.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +38 -80
  6. package/amplify/data/resource.ts +38 -80
  7. package/amplify/data/schema.js +41 -60
  8. package/amplify/data/schema.ts +43 -60
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +144 -2
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/media/[...path]/route.ts +5 -16
  76. package/app/api/revalidate/route.ts +15 -7
  77. package/app/archive/page.tsx +1 -1
  78. package/app/articles/[slug]/page.tsx +7 -5
  79. package/app/blog-rhythm.css +278 -0
  80. package/app/globals.css +2 -0
  81. package/app/layout.tsx +16 -0
  82. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  83. package/app/newsroom/[section]/page.tsx +6 -3
  84. package/app/newsroom/layout.tsx +4 -9
  85. package/app/newsroom/settings/page.tsx +6 -0
  86. package/app/newsroom/work/page.tsx +10 -0
  87. package/app/page.tsx +22 -8
  88. package/app/robots.ts +2 -5
  89. package/app/tailwind.css +1 -0
  90. package/bin/papyrus-infra.mjs +3 -3
  91. package/components/amplify-client-provider.tsx +5 -22
  92. package/components/archive-shell.tsx +2 -22
  93. package/components/article-video.tsx +152 -5
  94. package/components/blog-page-background.tsx +23 -6
  95. package/components/markus-ir-preview.tsx +22 -1
  96. package/components/news-desk-page.tsx +4 -15
  97. package/components/news-desk-taxonomy-client.ts +53 -2
  98. package/components/newspaper.tsx +6 -2
  99. package/components/newsroom-access-gate.tsx +2 -1
  100. package/components/newsroom-article-editor.tsx +109 -5
  101. package/components/newsroom-board-view.tsx +100 -0
  102. package/components/newsroom-client-shell.tsx +12 -0
  103. package/components/newsroom-curation-provider.tsx +17 -0
  104. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  106. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  109. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  110. package/components/newsroom-newsprint-board.css +48 -0
  111. package/components/newsroom-ops-overview.tsx +28 -11
  112. package/components/newsroom-ops-shell.tsx +7 -1
  113. package/components/newsroom-organizer-shell.tsx +130 -0
  114. package/components/newsroom-organizer.css +63 -0
  115. package/components/newsroom-references-view.tsx +92 -8
  116. package/components/papyrus-console-shell.tsx +4 -2
  117. package/components/pictogram-figure.tsx +9 -2
  118. package/components/presentation-header.tsx +135 -0
  119. package/components/presentation-rhythm-hrule.tsx +17 -0
  120. package/components/publication-cyclotron-panel.tsx +57 -0
  121. package/components/reader-auth-control.tsx +8 -5
  122. package/components/reference-curation-controls.tsx +77 -0
  123. package/components/reference-cyclotron-status.tsx +109 -0
  124. package/components/reference-priming.tsx +18 -0
  125. package/components/reference-relevance-review.tsx +75 -0
  126. package/components/settings-page.tsx +2 -1
  127. package/components/signup-entries-panel.tsx +94 -0
  128. package/components/topic-steering-workspace.tsx +74 -6
  129. package/components/use-blog-header-obstacles.ts +81 -0
  130. package/components/use-brand-fonts-ready.ts +28 -0
  131. package/components/use-near-viewport.ts +30 -0
  132. package/components/use-rhythm-overlay.ts +29 -0
  133. package/infra/amplify-app-shell.js +202 -71
  134. package/infra/amplify-app-shell.ts +211 -77
  135. package/infra/build-specs.js +23 -8
  136. package/infra/build-specs.ts +27 -11
  137. package/infra/site-config.js +259 -34
  138. package/infra/site-config.ts +314 -43
  139. package/lib/amplify-outputs-path.ts +5 -1
  140. package/lib/amplify-server-runtime.ts +36 -1
  141. package/lib/blog-feature-solver.ts +459 -0
  142. package/lib/blog-rhythm.ts +134 -0
  143. package/lib/brand-fonts-watch.ts +43 -0
  144. package/lib/brand-fonts.ts +112 -0
  145. package/lib/cached-content-repository.ts +12 -0
  146. package/lib/category-repository.ts +42 -93
  147. package/lib/content-actions-demo.ts +19 -1
  148. package/lib/content-repository.ts +3 -0
  149. package/lib/content-source-context.ts +12 -2
  150. package/lib/content-types.ts +4 -0
  151. package/lib/curation-client.ts +61 -0
  152. package/lib/edition-routes.ts +31 -29
  153. package/lib/graphql-content-repository.ts +67 -24
  154. package/lib/media-route-response.ts +45 -0
  155. package/lib/newspaper-layout.ts +3 -2
  156. package/lib/newsroom-base-path.ts +12 -3
  157. package/lib/newsroom-board.ts +60 -0
  158. package/lib/newsroom-category-drilldown.ts +3 -2
  159. package/lib/newsroom-forum-routes.ts +6 -4
  160. package/lib/newsroom-index-filters.ts +14 -10
  161. package/lib/newsroom-media-backend.ts +142 -0
  162. package/lib/newsroom-media.ts +182 -0
  163. package/lib/newsroom-nav.ts +1 -0
  164. package/lib/newsroom-organizer-nav.ts +34 -0
  165. package/lib/newsroom-section-routing.ts +20 -0
  166. package/lib/oauth-redirect-priority.ts +40 -0
  167. package/lib/papyrus-web-locations.ts +7 -6
  168. package/lib/plugin-settings.ts +11 -0
  169. package/lib/presentation-footer.ts +3 -2
  170. package/lib/pretext-layout.ts +12 -3
  171. package/lib/publication-cyclotron.ts +74 -0
  172. package/lib/rate-limit-retry.ts +55 -0
  173. package/lib/reader-base-path.ts +24 -0
  174. package/lib/reader-cache-lifetime.ts +5 -0
  175. package/lib/reader-static-params.ts +85 -4
  176. package/lib/reader-storage-url.ts +9 -12
  177. package/lib/reference-policy.ts +5 -0
  178. package/lib/relevance-decisions.ts +103 -0
  179. package/lib/relevance-status.ts +63 -0
  180. package/lib/renderer.ts +7 -0
  181. package/lib/revalidate-prewarm.ts +53 -0
  182. package/lib/revalidate-secret.ts +59 -0
  183. package/lib/robots-policy.ts +14 -0
  184. package/lib/root-route-redirect.ts +16 -0
  185. package/lib/semantic-graph.ts +7 -0
  186. package/lib/shared-credentials-provider.ts +61 -0
  187. package/lib/signup-csv.ts +24 -0
  188. package/lib/site-brand.ts +40 -0
  189. package/lib/staging-gated-path.ts +23 -0
  190. package/lib/staging-preview-object.ts +21 -5
  191. package/lib/test-editor-override.ts +4 -0
  192. package/lib/video-loading.ts +22 -0
  193. package/lib/video-script.ts +14 -0
  194. package/middleware.ts +11 -19
  195. package/package.json +54 -1
  196. package/renderers/pretext/article-page.tsx +39 -3
  197. package/renderers/pretext/presentation-shared.tsx +113 -0
  198. package/renderers/pretext/presentation-shell.tsx +15 -102
  199. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  200. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  201. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  202. package/routes.manifest.json +52 -12
  203. package/src/sync.mjs +30 -6
  204. package/src/with-papyrus.d.mts +9 -0
  205. package/src/with-papyrus.mjs +48 -3
  206. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  207. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  208. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,86 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as route53 from "aws-cdk-lib/aws-route53";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sesActions from "aws-cdk-lib/aws-ses-actions";
5
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
6
+ /**
7
+ * Adds the SES receipt resources directly to `scope` (no wrapper construct) so
8
+ * the logical ids of the live p.apyr.us stack stay unchanged.
9
+ */
10
+ export function addInboundEmailSesIntake(scope, props) {
11
+ if (props.domainIdentity) {
12
+ const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
13
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
14
+ hostedZoneId,
15
+ zoneName: hostedZoneName,
16
+ });
17
+ const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
18
+ onCreate: {
19
+ service: "SES",
20
+ action: "verifyDomainIdentity",
21
+ parameters: { Domain: domain },
22
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
23
+ },
24
+ onUpdate: {
25
+ service: "SES",
26
+ action: "verifyDomainIdentity",
27
+ parameters: { Domain: domain },
28
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
29
+ },
30
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
31
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
32
+ }),
33
+ timeout: Duration.minutes(2),
34
+ });
35
+ new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
36
+ zone,
37
+ recordName: `_amazonses.${recordName}`,
38
+ ttl: Duration.minutes(5),
39
+ values: [verifyDomain.getResponseField("VerificationToken")],
40
+ });
41
+ }
42
+ const ruleSet = props.existingRuleSetName
43
+ ? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
44
+ : new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
45
+ receiptRuleSetName: props.ruleSetName,
46
+ });
47
+ ruleSet.addRule("PapyrusInboundSubmissions", {
48
+ receiptRuleName: props.ruleName,
49
+ recipients: props.recipients,
50
+ enabled: true,
51
+ scanEnabled: true,
52
+ tlsPolicy: ses.TlsPolicy.REQUIRE,
53
+ actions: [
54
+ new sesActions.S3({
55
+ bucket: props.storageBucket,
56
+ objectKeyPrefix: "inbound-email/",
57
+ }),
58
+ ],
59
+ });
60
+ if (props.activateRuleSet && !props.existingRuleSetName) {
61
+ new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
62
+ onCreate: {
63
+ service: "SES",
64
+ action: "setActiveReceiptRuleSet",
65
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
66
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
67
+ },
68
+ onUpdate: {
69
+ service: "SES",
70
+ action: "setActiveReceiptRuleSet",
71
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
72
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
73
+ },
74
+ onDelete: {
75
+ service: "SES",
76
+ action: "setActiveReceiptRuleSet",
77
+ parameters: { RuleSetName: null },
78
+ },
79
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
80
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
81
+ }),
82
+ timeout: Duration.minutes(2),
83
+ });
84
+ }
85
+ return { ruleSet };
86
+ }
@@ -0,0 +1,110 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as route53 from "aws-cdk-lib/aws-route53";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sesActions from "aws-cdk-lib/aws-ses-actions";
5
+ import type * as s3 from "aws-cdk-lib/aws-s3";
6
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
7
+ import type { Construct } from "constructs";
8
+
9
+ export type InboundEmailSesIntakeProps = {
10
+ storageBucket: s3.IBucket;
11
+ recipients: string[];
12
+ ruleSetName: string;
13
+ /** Adds the rule to a rule set another stack owns instead of creating one. */
14
+ existingRuleSetName?: string;
15
+ /** `undefined` lets CloudFormation generate the name (the live p.apyr.us rule). */
16
+ ruleName?: string;
17
+ /** Only the legacy p.apyr.us pipeline sets this; a new site never activates a rule set. */
18
+ activateRuleSet: boolean;
19
+ domainIdentity?: {
20
+ domain: string;
21
+ hostedZoneId: string;
22
+ hostedZoneName: string;
23
+ recordName: string;
24
+ };
25
+ };
26
+
27
+ /**
28
+ * Adds the SES receipt resources directly to `scope` (no wrapper construct) so
29
+ * the logical ids of the live p.apyr.us stack stay unchanged.
30
+ */
31
+ export function addInboundEmailSesIntake(scope: Construct, props: InboundEmailSesIntakeProps) {
32
+ if (props.domainIdentity) {
33
+ const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
34
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
35
+ hostedZoneId,
36
+ zoneName: hostedZoneName,
37
+ });
38
+ const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
39
+ onCreate: {
40
+ service: "SES",
41
+ action: "verifyDomainIdentity",
42
+ parameters: { Domain: domain },
43
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
44
+ },
45
+ onUpdate: {
46
+ service: "SES",
47
+ action: "verifyDomainIdentity",
48
+ parameters: { Domain: domain },
49
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
50
+ },
51
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
52
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
53
+ }),
54
+ timeout: Duration.minutes(2),
55
+ });
56
+ new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
57
+ zone,
58
+ recordName: `_amazonses.${recordName}`,
59
+ ttl: Duration.minutes(5),
60
+ values: [verifyDomain.getResponseField("VerificationToken")],
61
+ });
62
+ }
63
+
64
+ const ruleSet = props.existingRuleSetName
65
+ ? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
66
+ : new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
67
+ receiptRuleSetName: props.ruleSetName,
68
+ });
69
+ ruleSet.addRule("PapyrusInboundSubmissions", {
70
+ receiptRuleName: props.ruleName,
71
+ recipients: props.recipients,
72
+ enabled: true,
73
+ scanEnabled: true,
74
+ tlsPolicy: ses.TlsPolicy.REQUIRE,
75
+ actions: [
76
+ new sesActions.S3({
77
+ bucket: props.storageBucket,
78
+ objectKeyPrefix: "inbound-email/",
79
+ }),
80
+ ],
81
+ });
82
+
83
+ if (props.activateRuleSet && !props.existingRuleSetName) {
84
+ new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
85
+ onCreate: {
86
+ service: "SES",
87
+ action: "setActiveReceiptRuleSet",
88
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
89
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
90
+ },
91
+ onUpdate: {
92
+ service: "SES",
93
+ action: "setActiveReceiptRuleSet",
94
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
95
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
96
+ },
97
+ onDelete: {
98
+ service: "SES",
99
+ action: "setActiveReceiptRuleSet",
100
+ parameters: { RuleSetName: null },
101
+ },
102
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
103
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
104
+ }),
105
+ timeout: Duration.minutes(2),
106
+ });
107
+ }
108
+
109
+ return { ruleSet };
110
+ }
@@ -0,0 +1,19 @@
1
+ import { signupPlugin } from "./signup/papyrus.plugin.js";
2
+ /** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
3
+ export const KNOWN_PLUGINS = {
4
+ signup: signupPlugin,
5
+ };
6
+ export function resolvePlugins(requests) {
7
+ const resolved = [];
8
+ for (const request of requests ?? []) {
9
+ const name = typeof request === "string" ? request : request.name;
10
+ const plugin = KNOWN_PLUGINS[name];
11
+ if (plugin === undefined) {
12
+ throw new Error(`Unknown plugin "${name}". Known plugins: ${Object.keys(KNOWN_PLUGINS).join(", ")}`);
13
+ }
14
+ if (resolved.some((entry) => entry.plugin.manifest.name === name))
15
+ throw new Error(`Plugin "${name}" is listed twice.`);
16
+ resolved.push({ plugin, config: plugin.resolveConfig(request) });
17
+ }
18
+ return resolved;
19
+ }
@@ -0,0 +1,23 @@
1
+ import type { PapyrusPlugin, PluginRequest } from "./types";
2
+ import { signupPlugin } from "./signup/papyrus.plugin";
3
+
4
+ /** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
5
+ export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
6
+ signup: signupPlugin,
7
+ };
8
+
9
+ export type ResolvedPlugin = { plugin: PapyrusPlugin<any, any>; config: unknown };
10
+
11
+ export function resolvePlugins(requests: PluginRequest[] | undefined): ResolvedPlugin[] {
12
+ const resolved: ResolvedPlugin[] = [];
13
+ for (const request of requests ?? []) {
14
+ const name = typeof request === "string" ? request : request.name;
15
+ const plugin = KNOWN_PLUGINS[name];
16
+ if (plugin === undefined) {
17
+ throw new Error(`Unknown plugin "${name}". Known plugins: ${Object.keys(KNOWN_PLUGINS).join(", ")}`);
18
+ }
19
+ if (resolved.some((entry) => entry.plugin.manifest.name === name)) throw new Error(`Plugin "${name}" is listed twice.`);
20
+ resolved.push({ plugin, config: plugin.resolveConfig(request) });
21
+ }
22
+ return resolved;
23
+ }
@@ -0,0 +1,200 @@
1
+ """submitSignup: the only operation of the signup plugin that a guest can call.
2
+
3
+ A guest sends a form name and an email-like value. This handler throttles the
4
+ caller, validates and length-limits every argument, drops honeypot and too-fast
5
+ submissions without telling the sender, and writes one SignupEntry through the
6
+ GraphQL API under an id derived from the content, so repeating a submission
7
+ changes nothing. Guests never read entries back.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Callable
22
+
23
+ STATUS_ACCEPTED = "accepted"
24
+ STATUS_INVALID = "invalid"
25
+ STATUS_EXPIRED = "expired"
26
+ STATUS_THROTTLED = "throttled"
27
+
28
+ MAXIMUM_ARGUMENT_CHARACTERS = 1000
29
+ MAXIMUM_VALUE_CHARACTERS = 254
30
+ MINIMUM_FILL_SECONDS = 2
31
+ MAXIMUM_AGE_SECONDS = 24 * 60 * 60
32
+ RATE_WINDOW_SECONDS = 60
33
+ COUNTER_LIFETIME_SECONDS = 5 * 60
34
+ DEFAULT_ADDRESS_LIMIT_PER_WINDOW = 20
35
+ DEFAULT_IDENTITY_LIMIT_PER_WINDOW = 5
36
+ FORBIDDEN_FIRST_CHARACTERS = "=+-@"
37
+ FORM_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,39}")
38
+ RENDERED_AT = re.compile(r"[0-9]{1,16}")
39
+ LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
40
+ EMAIL_LIKE = re.compile(rf"[A-Za-z0-9.!#$%&'*+/=?^_`{{|}}~-]{{1,64}}@{LABEL}(?:\.{LABEL})+")
41
+ CREATE_ENTRY_MUTATION = (
42
+ "mutation CreateSignupEntry($input: CreateSignupEntryInput!) { createSignupEntry(input: $input) { id } }"
43
+ )
44
+ ALREADY_STORED_MARKERS = ("ConditionalCheckFailed", "conditional request failed")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class SignupSettings:
49
+ forms: frozenset[str]
50
+ address_limit: int = DEFAULT_ADDRESS_LIMIT_PER_WINDOW
51
+ identity_limit: int = DEFAULT_IDENTITY_LIMIT_PER_WINDOW
52
+
53
+
54
+ def settings_from_environment(environment: dict[str, str] | None = None) -> SignupSettings:
55
+ environment = os.environ if environment is None else environment
56
+ forms = frozenset(name.strip() for name in environment.get("PAPYRUS_SIGNUP_FORMS", "").split(",") if name.strip())
57
+ return SignupSettings(
58
+ forms=forms,
59
+ address_limit=int(environment.get("PAPYRUS_SIGNUP_ADDRESS_LIMIT", DEFAULT_ADDRESS_LIMIT_PER_WINDOW)),
60
+ identity_limit=int(environment.get("PAPYRUS_SIGNUP_IDENTITY_LIMIT", DEFAULT_IDENTITY_LIMIT_PER_WINDOW)),
61
+ )
62
+
63
+
64
+ def result(status: str, message: str = "") -> dict[str, str]:
65
+ return {"status": status, "message": message}
66
+
67
+
68
+ def normalized_value(value: str) -> str:
69
+ local, _, domain = value.partition("@")
70
+ return f"{local}@{domain.lower()}"
71
+
72
+
73
+ def entry_identifier(form: str, value: str) -> str:
74
+ return hashlib.sha256(f"{form}\n{normalized_value(value)}".encode("utf-8")).hexdigest()[:32]
75
+
76
+
77
+ def valid_value(value: str) -> bool:
78
+ return (
79
+ 0 < len(value) <= MAXIMUM_VALUE_CHARACTERS
80
+ and value[0] not in FORBIDDEN_FIRST_CHARACTERS
81
+ and EMAIL_LIKE.fullmatch(value) is not None
82
+ )
83
+
84
+
85
+ def caller_address(identity: dict[str, Any] | None) -> str:
86
+ """The last of AppSync's sourceIp list: the connection's address. A sender-supplied x-forwarded-for only adds earlier entries."""
87
+ addresses = [str(address).strip() for address in (identity or {}).get("sourceIp") or [] if str(address).strip()]
88
+ return addresses[-1] if addresses else "unknown"
89
+
90
+
91
+ def rate_key(kind: str, subject: str, now_seconds: float) -> str:
92
+ window = int(now_seconds // RATE_WINDOW_SECONDS)
93
+ return f"{kind}#{hashlib.sha256(subject.encode('utf-8')).hexdigest()[:24]}#{window}"
94
+
95
+
96
+ def parse_rendered_at(text: str) -> float | None:
97
+ return int(text) / 1000.0 if RENDERED_AT.fullmatch(text) else None
98
+
99
+
100
+ def submit_signup(
101
+ arguments: dict[str, Any],
102
+ *,
103
+ address: str,
104
+ identity_id: str | None,
105
+ now_seconds: float,
106
+ settings: SignupSettings,
107
+ count_hit: Callable[[str, int], int],
108
+ store_entry: Callable[[dict[str, str]], bool],
109
+ ) -> dict[str, str]:
110
+ if count_hit(rate_key("address", address, now_seconds), COUNTER_LIFETIME_SECONDS) > settings.address_limit:
111
+ return result(STATUS_THROTTLED, "Too many requests. Try again in a minute.")
112
+ if identity_id and count_hit(rate_key("identity", identity_id, now_seconds), COUNTER_LIFETIME_SECONDS) > settings.identity_limit:
113
+ return result(STATUS_THROTTLED, "Too many requests. Try again in a minute.")
114
+
115
+ form, value = arguments.get("form"), arguments.get("value")
116
+ honeypot, rendered_at = arguments.get("website"), arguments.get("renderedAt")
117
+ texts = [form, value, honeypot, rendered_at]
118
+ if any(text is not None and not isinstance(text, str) for text in texts):
119
+ return result(STATUS_INVALID, "Malformed request.")
120
+ if any(text is not None and len(text) > MAXIMUM_ARGUMENT_CHARACTERS for text in texts):
121
+ return result(STATUS_INVALID, "Request too large.")
122
+ if not isinstance(form, str) or not FORM_NAME.fullmatch(form) or form not in settings.forms:
123
+ return result(STATUS_INVALID, "Unknown form.")
124
+ if not isinstance(value, str) or not valid_value(value):
125
+ return result(STATUS_INVALID, "Enter a valid email address.")
126
+ rendered_seconds = parse_rendered_at(rendered_at) if isinstance(rendered_at, str) else None
127
+ if rendered_seconds is None:
128
+ return result(STATUS_INVALID, "Malformed request.")
129
+ age_seconds = now_seconds - rendered_seconds
130
+ if age_seconds > MAXIMUM_AGE_SECONDS:
131
+ return result(STATUS_EXPIRED, "This form has expired. Reload the page and try again.")
132
+ if honeypot or age_seconds < MINIMUM_FILL_SECONDS:
133
+ return result(STATUS_ACCEPTED)
134
+
135
+ submitted_at = datetime.fromtimestamp(now_seconds, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
136
+ store_entry(
137
+ {
138
+ "id": entry_identifier(form, value),
139
+ "form": form,
140
+ "value": normalized_value(value),
141
+ "submittedAt": submitted_at,
142
+ }
143
+ )
144
+ return result(STATUS_ACCEPTED)
145
+
146
+
147
+ def counting_hits(table_name: str, client: Any = None) -> Callable[[str, int], int]:
148
+ import boto3
149
+
150
+ table_client = client or boto3.client("dynamodb")
151
+
152
+ def count_hit(key: str, lifetime_seconds: int) -> int:
153
+ response = table_client.update_item(
154
+ TableName=table_name,
155
+ Key={"pk": {"S": key}},
156
+ UpdateExpression="ADD hits :one SET expiresAt = :expires",
157
+ ExpressionAttributeValues={
158
+ ":one": {"N": "1"},
159
+ ":expires": {"N": str(int(time.time()) + lifetime_seconds)},
160
+ },
161
+ ReturnValues="UPDATED_NEW",
162
+ )
163
+ return int(response["Attributes"]["hits"]["N"])
164
+
165
+ return count_hit
166
+
167
+
168
+ def storing_entries(endpoint: str, region: str) -> Callable[[dict[str, str]], bool]:
169
+ import boto3
170
+ from botocore.auth import SigV4Auth
171
+ from botocore.awsrequest import AWSRequest
172
+
173
+ def store_entry(entry: dict[str, str]) -> bool:
174
+ body = json.dumps({"query": CREATE_ENTRY_MUTATION, "variables": {"input": entry}}).encode("utf-8")
175
+ request = AWSRequest(method="POST", url=endpoint, data=body, headers={"content-type": "application/json"})
176
+ SigV4Auth(boto3.Session().get_credentials().get_frozen_credentials(), "appsync", region).add_auth(request)
177
+ http_request = urllib.request.Request(endpoint, data=body, headers=dict(request.headers.items()), method="POST")
178
+ with urllib.request.urlopen(http_request, timeout=10) as response:
179
+ payload = json.loads(response.read())
180
+ errors = payload.get("errors") or []
181
+ if not errors:
182
+ return True
183
+ if all(any(marker in str(error) for marker in ALREADY_STORED_MARKERS) for error in errors):
184
+ return False
185
+ raise RuntimeError(f"createSignupEntry failed: {errors}")
186
+
187
+ return store_entry
188
+
189
+
190
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, str]:
191
+ identity = event.get("identity") or {}
192
+ return submit_signup(
193
+ event.get("arguments") or {},
194
+ address=caller_address(identity),
195
+ identity_id=identity.get("cognitoIdentityId"),
196
+ now_seconds=time.time(),
197
+ settings=settings_from_environment(),
198
+ count_hit=counting_hits(os.environ["PAPYRUS_SIGNUP_RATE_TABLE"]),
199
+ store_entry=storing_entries(os.environ["PAPYRUS_GRAPHQL_ENDPOINT"], os.environ["AWS_REGION"]),
200
+ )
@@ -0,0 +1,27 @@
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 path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+ export const signupSubmit = defineFunction((scope) => {
10
+ const rateTable = new Table(scope, "PapyrusSignupRateCounters", {
11
+ partitionKey: { name: "pk", type: AttributeType.STRING },
12
+ billingMode: BillingMode.PAY_PER_REQUEST,
13
+ timeToLiveAttribute: "expiresAt",
14
+ removalPolicy: RemovalPolicy.DESTROY,
15
+ });
16
+ const lambda = new Function(scope, "papyrus-signup-submit", {
17
+ runtime: Runtime.PYTHON_3_12,
18
+ architecture: Architecture.ARM_64,
19
+ handler: "handler.handler",
20
+ timeout: Duration.seconds(10),
21
+ memorySize: 256,
22
+ code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
23
+ environment: { PAPYRUS_SIGNUP_RATE_TABLE: rateTable.tableName },
24
+ });
25
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [rateTable.tableArn] }));
26
+ return lambda;
27
+ }, { resourceGroupName: "data" });
@@ -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
+ };