@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
@@ -4,8 +4,17 @@ import json
4
4
  import os
5
5
  from typing import Any
6
6
 
7
+ from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
7
8
  from papyrus_content.graphql_authoring import create_authoring_client
8
9
  from papyrus_content.markus_renderer.derive import derive_body
10
+ from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
11
+ from papyrus_content.projection.engine import project, project_all, project_item_pages
12
+ from papyrus_content.projection.runtime import (
13
+ DEAD_LETTER_QUEUE_ENV,
14
+ context_from_environment,
15
+ projection_configured,
16
+ public_item_url,
17
+ )
9
18
  from papyrus_content.rebuild_trigger import (
10
19
  DEFAULT_STAGING_BRANCH,
11
20
  reader_target_from_environment,
@@ -56,6 +65,127 @@ def derive_markus_action(arguments: dict) -> dict:
56
65
  return response
57
66
 
58
67
 
68
+ PROJECTION_TASK_KEY = "projectionTask"
69
+
70
+
71
+ class ProjectionTaskFailed(Exception):
72
+ """Raised by an asynchronous projection task so Lambda retries it and, finally, sends it to the dead letter queue."""
73
+
74
+
75
+ def send_to_projection_dead_letter_queue(task: dict, error: str) -> None:
76
+ queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
77
+ if not queue_url:
78
+ return
79
+ try:
80
+ import boto3
81
+
82
+ boto3.client("sqs").send_message(
83
+ QueueUrl=queue_url, MessageBody=json.dumps({PROJECTION_TASK_KEY: task, "error": error})
84
+ )
85
+ except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
86
+ pass
87
+
88
+
89
+ def schedule_projection_task(task: dict, error: str) -> bool:
90
+ """Run a projection task later, in this same function, as an asynchronous invocation.
91
+
92
+ The function has an on-failure destination (the dead letter queue), so a task
93
+ that keeps failing ends up there for ``papyrus ops content project --reconcile``.
94
+ """
95
+ function_name = (os.environ.get("AWS_LAMBDA_FUNCTION_NAME") or "").strip()
96
+ if not function_name:
97
+ send_to_projection_dead_letter_queue(task, error)
98
+ return False
99
+ try:
100
+ import boto3
101
+
102
+ boto3.client("lambda").invoke(
103
+ FunctionName=function_name, InvocationType="Event", Payload=json.dumps({PROJECTION_TASK_KEY: task}).encode("utf-8")
104
+ )
105
+ return True
106
+ except Exception as scheduling_error: # noqa: BLE001 - fall back to the dead letter queue
107
+ send_to_projection_dead_letter_queue(task, f"{error}; scheduling failed: {scheduling_error}")
108
+ return False
109
+
110
+
111
+ def projection_context(client):
112
+ context = context_from_environment(client)
113
+ context.defer_follow_up = True
114
+ return context
115
+
116
+
117
+ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
118
+ if not projection_configured():
119
+ return {}
120
+ liveness = {} if unpublished else {"live": False}
121
+ task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
122
+ try:
123
+ report = project(projection_context(client), item_id, unpublished=unpublished)
124
+ except Exception as error: # noqa: BLE001 - a projection failure never fails the save
125
+ message = f"{type(error).__name__}: {error}"
126
+ schedule_projection_task(task, message)
127
+ return {**liveness, "projected": False, "paths": [], "error": message}
128
+ response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
129
+ if not unpublished:
130
+ response["live"] = report.ok
131
+ url = public_item_url(os.environ, report.item_paths)
132
+ if url:
133
+ response["url"] = url
134
+ if not report.ok:
135
+ message = "; ".join(report.errors)
136
+ schedule_projection_task(task, message)
137
+ response["error"] = message
138
+ if report.unpurged:
139
+ response["edgePurged"] = False
140
+ schedule_unpurged_invalidation(report.unpurged)
141
+ if report.follow_up_item_pages:
142
+ schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
143
+ return response
144
+
145
+
146
+ def schedule_unpurged_invalidation(keys: list[str]) -> None:
147
+ schedule_projection_task({"task": "invalidate", "keys": keys}, "edge invalidation failed")
148
+
149
+
150
+ def run_deploy_render_task(client) -> dict:
151
+ """Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
152
+ context = projection_context(client)
153
+ report = deploy_render(context)
154
+ print(drift_metric_line(report))
155
+ if report.render.unpurged:
156
+ schedule_unpurged_invalidation(report.render.unpurged)
157
+ if not report.ok:
158
+ raise ProjectionTaskFailed("; ".join([*report.render.errors, *report.drift.errors]))
159
+ return report.to_dict()
160
+
161
+
162
+ def run_projection_task(task: dict) -> dict:
163
+ """An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
164
+ client, _claims = create_authoring_client()
165
+ if task.get("task") == "item-pages":
166
+ report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
167
+ elif task.get("task") == "item":
168
+ report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
169
+ if report.follow_up_item_pages:
170
+ schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
171
+ elif task.get("task") == "all":
172
+ report = project_all(projection_context(client))
173
+ elif task.get("task") == "deploy-render":
174
+ return run_deploy_render_task(client)
175
+ elif task.get("task") == "invalidate":
176
+ invalidate = projection_context(client).invalidate
177
+ if invalidate is None:
178
+ return {"ok": True, "paths": []}
179
+ return {"ok": True, "paths": invalidate(list(task["keys"]))}
180
+ else:
181
+ raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
182
+ if report.unpurged:
183
+ schedule_unpurged_invalidation(report.unpurged)
184
+ if not report.ok:
185
+ raise ProjectionTaskFailed("; ".join(report.errors))
186
+ return {"ok": True, "paths": [*report.written, *report.deleted]}
187
+
188
+
59
189
  def save_item_draft_action(arguments: dict, actor: str) -> dict:
60
190
  client, _claims = create_authoring_client()
61
191
  fields = ItemFields(
@@ -68,7 +198,7 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
68
198
  id=arguments.get("id"),
69
199
  )
70
200
  record = save_item(client, fields, actor=actor, expected_content_hash=arguments.get("expectedContentHash"))
71
- return {
201
+ response = {
72
202
  "ok": True,
73
203
  "item": {
74
204
  "id": record["id"],
@@ -78,6 +208,11 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
78
208
  "versionNumber": record.get("versionNumber"),
79
209
  },
80
210
  }
211
+ if record["status"] == "published" and projection_configured():
212
+ published = publish_item(client, record["id"], actor=actor)
213
+ response["item"]["versionNumber"] = published.version_number
214
+ response.update(project_item_action(client, record["id"], unpublished=False))
215
+ return response
81
216
 
82
217
 
83
218
  def site_triggers_for(client, item_id: str) -> dict:
@@ -90,6 +225,9 @@ def site_triggers_for(client, item_id: str) -> dict:
90
225
  slugs = [item["slug"]] if item.get("slug") else []
91
226
  revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
92
227
  triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
228
+ distribution_ids = distribution_ids_from_environment(os.environ)
229
+ if distribution_ids and revalidated is not None:
230
+ triggers["edgeInvalidated"] = invalidate_revalidated_paths(distribution_ids, revalidated)
93
231
  except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
94
232
  triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
95
233
  if reader_app_id:
@@ -108,6 +246,7 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
108
246
  }
109
247
  if result.changed:
110
248
  response.update(site_triggers_for(client, arguments["id"]))
249
+ response.update(project_item_action(client, arguments["id"], unpublished=False))
111
250
  return response
112
251
 
113
252
 
@@ -117,6 +256,7 @@ def unpublish_item_action(arguments: dict, actor: str) -> dict:
117
256
  response = {"ok": True, "changed": result.changed}
118
257
  if result.changed:
119
258
  response.update(site_triggers_for(client, arguments["id"]))
259
+ response.update(project_item_action(client, arguments["id"], unpublished=True))
120
260
  return response
121
261
 
122
262
 
@@ -130,6 +270,8 @@ def request_staging_build_action() -> dict:
130
270
 
131
271
 
132
272
  def handler(event, context):
273
+ if PROJECTION_TASK_KEY in event:
274
+ return run_projection_task(event[PROJECTION_TASK_KEY])
133
275
  field = event["fieldName"]
134
276
  arguments = parse_input(event["arguments"]["input"])
135
277
  actor = actor_from_event(event)
@@ -1,4 +1,5 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
3
  anthus-markus==0.5.1
4
- Pillow>=10,<12
4
+ Pillow>=11.3,<12
5
+ awscrt>=0.23,<1
@@ -7,11 +7,15 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  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" });
14
14
  }
15
+ let siteFilesForNextContentActionsBundle;
16
+ export function setSiteFilesForContentActionsBundle(siteFiles) {
17
+ siteFilesForNextContentActionsBundle = siteFiles;
18
+ }
15
19
  export const contentActions = defineFunction((scope) => {
16
20
  return new Function(scope, "papyrus-content-actions", {
17
21
  runtime: Runtime.PYTHON_3_12,
@@ -25,6 +29,7 @@ export const contentActions = defineFunction((scope) => {
25
29
  requirements: contentActionsRequirements,
26
30
  noDeps: true,
27
31
  checkoutExtras: installCheckoutRequirements,
32
+ ...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
28
33
  }, [
29
34
  "bash",
30
35
  "-c",
@@ -5,12 +5,12 @@ import { Construct } from "constructs";
5
5
  import { execSync } from "node:child_process";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { pythonLambdaCode } from "../shared/python-bundle";
8
+ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
9
9
 
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -20,6 +20,12 @@ function installCheckoutRequirements(outputDir: string): void {
20
20
  );
21
21
  }
22
22
 
23
+ let siteFilesForNextContentActionsBundle: SiteBundleFile[] | undefined;
24
+
25
+ export function setSiteFilesForContentActionsBundle(siteFiles: SiteBundleFile[] | undefined): void {
26
+ siteFilesForNextContentActionsBundle = siteFiles;
27
+ }
28
+
23
29
  export const contentActions = defineFunction(
24
30
  (scope: Construct) => {
25
31
  return new Function(scope, "papyrus-content-actions", {
@@ -35,6 +41,7 @@ export const contentActions = defineFunction(
35
41
  requirements: contentActionsRequirements,
36
42
  noDeps: true,
37
43
  checkoutExtras: installCheckoutRequirements,
44
+ ...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
38
45
  },
39
46
  [
40
47
  "bash",
@@ -30,8 +30,10 @@ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
30
30
  event.get("corpusKey")
31
31
  or event.get("corpus_key")
32
32
  or os.environ.get("PAPYRUS_INBOUND_EMAIL_CORPUS_KEY")
33
- or "AI-ML-research"
33
+ or ""
34
34
  ).strip()
35
+ if not corpus_key:
36
+ raise ValueError("corpusKey or PAPYRUS_INBOUND_EMAIL_CORPUS_KEY is required.")
35
37
 
36
38
  from papyrus_content.graphql_authoring import PapyrusGraphQLAuthoringClient
37
39
  from papyrus_newsroom.email_submissions import process_email_submission_message
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
10
+ from papyrus_content.renditions import (
11
+ RenditionSettings,
12
+ S3RenditionStore,
13
+ make_renditions,
14
+ media_originals_enabled,
15
+ meta_key,
16
+ original_site_key,
17
+ ORIGINAL_CACHE_CONTROL,
18
+ settings_from_static_directory,
19
+ source_from_media_key,
20
+ )
21
+
22
+ _loaded: dict[str, Any] = {}
23
+
24
+
25
+ def configured_settings() -> RenditionSettings | None:
26
+ if "settings" not in _loaded:
27
+ static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
28
+ _loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
29
+ return _loaded["settings"]
30
+
31
+
32
+ def originals_enabled() -> bool:
33
+ if "originals" not in _loaded:
34
+ static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
35
+ _loaded["originals"] = media_originals_enabled(Path(static_directory) if static_directory else None)
36
+ return _loaded["originals"]
37
+
38
+
39
+ def site_store() -> S3RenditionStore:
40
+ if "store" not in _loaded:
41
+ _loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
42
+ return _loaded["store"]
43
+
44
+
45
+ def s3_client():
46
+ if "s3" not in _loaded:
47
+ import boto3
48
+
49
+ _loaded["s3"] = boto3.client("s3")
50
+ return _loaded["s3"]
51
+
52
+
53
+ def media_objects(event: dict) -> list[tuple[str, str]]:
54
+ if event.get("source") == "aws.s3" and "detail" in event:
55
+ detail = event["detail"]
56
+ return [(detail["bucket"]["name"], detail["object"]["key"])]
57
+ if "bucket" in event and "key" in event:
58
+ return [(event["bucket"], event["key"])]
59
+ raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
60
+
61
+
62
+ def render_media_object(
63
+ settings: RenditionSettings | None,
64
+ bucket: str,
65
+ key: str,
66
+ *,
67
+ originals: bool = False,
68
+ skip_existing: bool = False,
69
+ dry_run: bool = False,
70
+ ) -> dict:
71
+ source = source_from_media_key(key) if settings is not None else None
72
+ original_key = original_site_key(key) if originals else None
73
+ if source is None and original_key is None:
74
+ return {"key": key, "action": "ignored", "reason": "not an image under media/"}
75
+ if skip_existing:
76
+ renditions_exist = source is None or site_store().get(meta_key(settings.pipeline, source)) is not None
77
+ original_exists = original_key is None or site_store().get(original_key) is not None
78
+ if renditions_exist and original_exists:
79
+ return {"key": key, "source": source or original_key, "action": "skipped-existing"}
80
+ if dry_run:
81
+ return {"key": key, "source": source or original_key, "action": "would-render" if source else "would-copy"}
82
+ started = time.perf_counter()
83
+ data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
84
+ downloaded = time.perf_counter()
85
+ if original_key is not None:
86
+ site_store().put(original_key, data, cache_control=ORIGINAL_CACHE_CONTROL)
87
+ if source is None:
88
+ return {"key": key, "source": original_key, "action": "copied", "written": 1, "sourceBytes": len(data)}
89
+ result = make_renditions(settings, site_store(), source, data)
90
+ finished = time.perf_counter()
91
+ return {
92
+ "key": key,
93
+ "source": source,
94
+ "action": "rendered",
95
+ "kind": result["kind"],
96
+ "written": result["written"] + (1 if original_key is not None else 0),
97
+ "sourceBytes": len(data),
98
+ "downloadMilliseconds": round((downloaded - started) * 1000),
99
+ "renditionMilliseconds": round((finished - downloaded) * 1000),
100
+ }
101
+
102
+
103
+ def handler(event, context):
104
+ settings = configured_settings()
105
+ originals = originals_enabled()
106
+ if settings is None and not originals:
107
+ return {"ok": True, "results": [], "skipped": "this site has no images or media settings in projection.json"}
108
+ results = [
109
+ render_media_object(
110
+ settings,
111
+ bucket,
112
+ key,
113
+ originals=originals,
114
+ skip_existing=event.get("skipExisting") is True,
115
+ dry_run=event.get("dryRun") is True,
116
+ )
117
+ for bucket, key in media_objects(event)
118
+ ]
119
+ print(json.dumps({"renditions": results}))
120
+ return {"ok": True, "results": results}
@@ -0,0 +1,4 @@
1
+ PyYAML>=6.0.2,<7
2
+ citeproc-py>=0.8.2,<1
3
+ anthus-markus==0.5.1
4
+ Pillow>=11.3,<12
@@ -2,6 +2,13 @@ import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
2
2
  import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
3
3
  import {
4
4
  buildEmailSubmissionMessageInput,
5
+ isInboundSenderAuthenticated,
6
+ readInboundSenderAuthenticationFromRawMime,
7
+ readInboundScanVerdictsFromSesReceipt,
8
+ REJECTION_KIND_UNAUTHENTICATED_SENDER,
9
+ UNAUTHENTICATED_SENDER_RESPONSE_ERROR,
10
+ UNVERIFIED_SENDER_AUTHENTICATION,
11
+ type InboundSenderAuthentication,
5
12
  classifyInboundEmailIntake,
6
13
  countInboundAttachments,
7
14
  extractDirectCitations,
@@ -49,6 +56,7 @@ type InboundPayload = {
49
56
  attachmentCount: number;
50
57
  userComposedText: string;
51
58
  parentSubmissionMessageId: string | null;
59
+ senderAuthentication: InboundSenderAuthentication;
52
60
  };
53
61
 
54
62
  type ExistingMessage = {
@@ -123,7 +131,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
123
131
  const resolvedMessageId = messageId ?? `message-email-submission-${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
124
132
  const dataClient = await getLambdaDataClient();
125
133
  const profileId = await lookupRegisteredUserProfileId(dataClient, inbound.senderEmail);
126
- const authorized = Boolean(profileId);
134
+ const senderAuthenticated = isInboundSenderAuthenticated(inbound.senderAuthentication, inbound.senderEmail);
135
+ const authorized = Boolean(profileId) && senderAuthenticated;
127
136
  const citations = authorized
128
137
  ? (inbound.citations.length > 0
129
138
  ? inbound.citations
@@ -143,7 +152,9 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
143
152
 
144
153
  let status = authorized ? "received" : "rejected";
145
154
  let responseStatus = authorized ? "PENDING" : "REJECTED";
146
- let responseError: string | null = authorized ? null : UNREGISTERED_SENDER_RESPONSE_ERROR;
155
+ let responseError: string | null = authorized
156
+ ? null
157
+ : (senderAuthenticated ? UNREGISTERED_SENDER_RESPONSE_ERROR : UNAUTHENTICATED_SENDER_RESPONSE_ERROR);
147
158
 
148
159
  if (authorized && looksLikeResearchAssignmentRequest(inbound.bodyText, citations.length)) {
149
160
  status = "rejected";
@@ -173,7 +184,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
173
184
  s3Bucket: inbound.s3Bucket,
174
185
  s3Key: inbound.s3Key,
175
186
  authorized,
176
- authorUserProfileId: profileId,
187
+ rejectionKind: senderAuthenticated ? undefined : REJECTION_KIND_UNAUTHENTICATED_SENDER,
188
+ authorUserProfileId: authorized ? profileId : null,
177
189
  authorLabel: inbound.senderEmail || "unknown-sender",
178
190
  citations,
179
191
  status,
@@ -205,7 +217,7 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
205
217
 
206
218
  if (authorized && responseStatus === "PENDING" && PROCESSOR_FUNCTION_NAME) {
207
219
  await invokeEmailProcessor(resolvedMessageId);
208
- } else if (responseStatus === "REJECTED" && PROCESSOR_FUNCTION_NAME) {
220
+ } else if (responseStatus === "REJECTED" && senderAuthenticated && PROCESSOR_FUNCTION_NAME) {
209
221
  await invokeEmailProcessor(resolvedMessageId, { sendFeedbackOnly: true });
210
222
  }
211
223
 
@@ -390,6 +402,7 @@ async function resolveFromSesMail(
390
402
  senderEmail: loaded.senderEmail || senderEmail,
391
403
  recipients: loaded.recipients.length > 0 ? loaded.recipients : recipients,
392
404
  subject: loaded.subject !== "(no subject)" ? loaded.subject : subject,
405
+ senderAuthentication: mergeScanVerdicts(loaded.senderAuthentication, readInboundScanVerdictsFromSesReceipt(receipt)),
393
406
  };
394
407
  }
395
408
 
@@ -407,6 +420,7 @@ async function resolveFromSesMail(
407
420
  attachmentCount: 0,
408
421
  userComposedText: "",
409
422
  parentSubmissionMessageId: null,
423
+ senderAuthentication: { ...UNVERIFIED_SENDER_AUTHENTICATION, ...readInboundScanVerdictsFromSesReceipt(receipt) },
410
424
  });
411
425
  }
412
426
 
@@ -433,6 +447,7 @@ async function loadInboundFromS3Object(
433
447
  attachmentCount: countInboundAttachments(rawBytes),
434
448
  userComposedText: extractUserComposedReplyText(intake.bodyText),
435
449
  parentSubmissionMessageId: null,
450
+ senderAuthentication: readInboundSenderAuthenticationFromRawMime(rawBytes),
436
451
  });
437
452
  }
438
453
 
@@ -446,6 +461,19 @@ function enrichInboundPayload(payload: InboundPayload): InboundPayload {
446
461
  };
447
462
  }
448
463
 
464
+ function mergeScanVerdicts(
465
+ authentication: InboundSenderAuthentication,
466
+ receiptVerdicts: Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict">,
467
+ ): InboundSenderAuthentication {
468
+ const worst = (fromMime: string, fromReceipt: string) =>
469
+ fromMime === "fail" || fromReceipt === "fail" ? "fail" : fromMime !== "unknown" ? fromMime : fromReceipt;
470
+ return {
471
+ ...authentication,
472
+ spamVerdict: worst(authentication.spamVerdict, receiptVerdicts.spamVerdict),
473
+ virusVerdict: worst(authentication.virusVerdict, receiptVerdicts.virusVerdict),
474
+ };
475
+ }
476
+
449
477
  function sesMessageIdFromObjectKey(key: string): string | null {
450
478
  const basename = key.split("/").pop() ?? "";
451
479
  const match = basename.match(/^[A-Za-z0-9_-]+$/);
@@ -11,6 +11,113 @@ export const REJECTION_KIND_UNREGISTERED_SENDER = "unregistered_sender";
11
11
  export const UNREGISTERED_SENDER_RESPONSE_ERROR =
12
12
  "This submission was not accepted because only registered Papyrus users may send reference submissions by email.";
13
13
 
14
+ export const UNAUTHENTICATED_SENDER_RESPONSE_ERROR =
15
+ "This submission was not accepted because the sender could not be authenticated (SPF or DKIM must pass).";
16
+ export const REJECTION_KIND_UNAUTHENTICATED_SENDER = "unauthenticated_sender";
17
+
18
+ export type InboundSenderAuthentication = {
19
+ spfPassDomains: string[];
20
+ dkimPassDomains: string[];
21
+ spamVerdict: string;
22
+ virusVerdict: string;
23
+ };
24
+
25
+ const TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID = "amazonses.com";
26
+
27
+ export const UNVERIFIED_SENDER_AUTHENTICATION: InboundSenderAuthentication = {
28
+ spfPassDomains: [],
29
+ dkimPassDomains: [],
30
+ spamVerdict: "unknown",
31
+ virusVerdict: "unknown",
32
+ };
33
+
34
+ function domainOfAddress(address: string): string {
35
+ return normalizeEmailAddress(address).split("@")[1] ?? "";
36
+ }
37
+
38
+ function domainsAreAligned(authenticatedDomain: string, fromDomain: string): boolean {
39
+ if (!authenticatedDomain || !fromDomain) return false;
40
+ return authenticatedDomain === fromDomain
41
+ || fromDomain.endsWith(`.${authenticatedDomain}`)
42
+ || authenticatedDomain.endsWith(`.${fromDomain}`);
43
+ }
44
+
45
+ export function isInboundSenderAuthenticated(
46
+ authentication: InboundSenderAuthentication,
47
+ fromAddress: string,
48
+ ): boolean {
49
+ if (authentication.spamVerdict === "fail" || authentication.virusVerdict === "fail") return false;
50
+ const fromDomain = domainOfAddress(fromAddress);
51
+ return [...authentication.spfPassDomains, ...authentication.dkimPassDomains]
52
+ .some((domain) => domainsAreAligned(domain, fromDomain));
53
+ }
54
+
55
+ export function readInboundScanVerdictsFromSesReceipt(
56
+ receipt: Record<string, unknown> | undefined,
57
+ ): Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict"> {
58
+ const verdictStatus = (key: string): string => {
59
+ const verdict = receipt?.[key];
60
+ const status = verdict && typeof verdict === "object" ? (verdict as { status?: unknown }).status : null;
61
+ return typeof status === "string" ? status.trim().toLowerCase() : "unknown";
62
+ };
63
+ return { spamVerdict: verdictStatus("spamVerdict"), virusVerdict: verdictStatus("virusVerdict") };
64
+ }
65
+
66
+ function readHeaderBlock(rawBytes: Uint8Array): string[] {
67
+ const raw = Buffer.from(rawBytes).toString("utf8");
68
+ const headerBlockEnd = raw.search(/\r?\n\r?\n/);
69
+ const headerBlock = headerBlockEnd === -1 ? raw : raw.slice(0, headerBlockEnd);
70
+ return headerBlock.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/);
71
+ }
72
+
73
+ function firstHeaderValue(headerLines: string[], name: string): string | null {
74
+ const prefix = new RegExp(`^${name}:\\s*`, "i");
75
+ const line = headerLines.find((entry) => prefix.test(entry));
76
+ return line ? line.replace(prefix, "") : null;
77
+ }
78
+
79
+ export function readInboundSenderAuthenticationFromRawMime(rawBytes: Uint8Array): InboundSenderAuthentication {
80
+ const headerLines = readHeaderBlock(rawBytes);
81
+ const verdictHeader = (name: string): string =>
82
+ (firstHeaderValue(headerLines, name) ?? "unknown").trim().toLowerCase();
83
+ const scanVerdicts = {
84
+ spamVerdict: verdictHeader("X-SES-Spam-Verdict"),
85
+ virusVerdict: verdictHeader("X-SES-Virus-Verdict"),
86
+ };
87
+ const topmostResults = firstHeaderValue(headerLines, "Authentication-Results");
88
+ if (!topmostResults) return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
89
+ const [serverId, ...parts] = topmostResults.replace(/\([^)]*\)/g, "").split(";").map((part) => part.trim());
90
+ if (serverId.toLowerCase() !== TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID) {
91
+ return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
92
+ }
93
+
94
+ const spfPassDomains: string[] = [];
95
+ const dkimPassDomains: string[] = [];
96
+ let spfPassed = false;
97
+ let envelopeFromDomain = "";
98
+ let heloDomain = "";
99
+ for (const part of parts) {
100
+ const result = /^(spf|dkim)\s*=\s*([a-z]+)/i.exec(part);
101
+ if (result) {
102
+ const passed = result[2].toLowerCase() === "pass";
103
+ if (result[1].toLowerCase() === "spf") spfPassed = spfPassed || passed;
104
+ else if (passed) {
105
+ const signingDomain = /header\.(?:i|d)\s*=\s*@?([^\s;]+)/i.exec(part)?.[1];
106
+ if (signingDomain) dkimPassDomains.push(signingDomain.toLowerCase().replace(/^.*@/, ""));
107
+ }
108
+ }
109
+ const envelopeFrom = /^(?:envelope-from|smtp\.mailfrom)\s*=\s*(\S+)/i.exec(part)?.[1];
110
+ if (envelopeFrom) envelopeFromDomain = domainOfAddress(envelopeFrom.replace(/^<|>$/g, ""));
111
+ const helo = /^(?:helo|smtp\.helo)\s*=\s*(\S+)/i.exec(part)?.[1];
112
+ if (helo) heloDomain = helo.toLowerCase();
113
+ }
114
+ if (spfPassed) {
115
+ const spfDomain = envelopeFromDomain || heloDomain;
116
+ if (spfDomain) spfPassDomains.push(spfDomain);
117
+ }
118
+ return { spfPassDomains, dkimPassDomains, ...scanVerdicts };
119
+ }
120
+
14
121
  const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi;
15
122
  const DOI_PATTERN = /\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+/gi;
16
123
  const RESEARCH_ASSIGNMENT_PHRASES = [
@@ -337,6 +444,7 @@ export function buildEmailSubmissionMessageInput(input: {
337
444
  s3Bucket: string | null;
338
445
  s3Key: string | null;
339
446
  authorized: boolean;
447
+ rejectionKind?: string;
340
448
  authorUserProfileId: string | null;
341
449
  authorLabel: string;
342
450
  citations: Array<Record<string, unknown>>;
@@ -378,7 +486,7 @@ export function buildEmailSubmissionMessageInput(input: {
378
486
  ...(input.authorized
379
487
  ? {}
380
488
  : {
381
- rejectionKind: REJECTION_KIND_UNREGISTERED_SENDER,
489
+ rejectionKind: input.rejectionKind ?? REJECTION_KIND_UNREGISTERED_SENDER,
382
490
  }),
383
491
  directCitationCount: input.citations.length,
384
492
  directCitations: input.citations,