@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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 (227) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +64 -3
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1589 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +487 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -1,12 +1,13 @@
1
1
  import { a } from "@aws-amplify/backend";
2
2
  import { editorialChecks } from "./resource";
3
+ import { functionHandler } from "../../data/function-handler";
3
4
 
4
5
  const editors = ["editor", "admin"];
5
6
 
6
7
  /**
7
- * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Limatus checks
8
+ * Core to every site's data API. Policies, settings and editors' decisions live in Papyrus; Humanus checks
8
9
  * content against them. Editors read policies and record decisions; admins
9
- * manage policies and settings. Only the function (IAM) calls Limatus.
10
+ * manage policies and settings. Only the function (IAM) calls Humanus.
10
11
  */
11
12
  export const editorialChecksSchemaDefinitions = {
12
13
  EditorialPolicy: a
@@ -49,12 +50,41 @@ export const editorialChecksSchemaDefinitions = {
49
50
  .disableOperations(["subscriptions"])
50
51
  .secondaryIndexes((index) => [index("itemId").queryField("listEditorialFeedbackByItem")])
51
52
  .authorization((allow) => [allow.group("editor").to(["create", "read"]), allow.group("admin")]),
53
+ EditorialCheckRun: a
54
+ .model({
55
+ id: a.id().required(),
56
+ itemId: a.string().required(),
57
+ lineageId: a.string().required(),
58
+ versionNumber: a.integer().required(),
59
+ section: a.string(),
60
+ contentHash: a.string(),
61
+ packVersion: a.string(),
62
+ violations: a.integer().required(),
63
+ reviews: a.integer().required(),
64
+ findings: a.json().required(),
65
+ lanes: a.json(),
66
+ checkedAt: a.datetime().required(),
67
+ })
68
+ .disableOperations(["subscriptions"])
69
+ .secondaryIndexes((index) => [index("lineageId").sortKeys(["versionNumber"]).queryField("listEditorialCheckRunsByLineage")])
70
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
71
+ listEditorialStarterPacks: a
72
+ .query()
73
+ .returns(a.json())
74
+ .authorization((allow) => [allow.groups(editors)])
75
+ .handler(functionHandler("editorialChecks", editorialChecks)),
76
+ checkItemVersion: a
77
+ .mutation()
78
+ .arguments({ itemId: a.id().required() })
79
+ .returns(a.json())
80
+ .authorization((allow) => [allow.groups(editors)])
81
+ .handler(functionHandler("editorialChecks", editorialChecks)),
52
82
  checkEditorial: a
53
83
  .query()
54
84
  .arguments({ body: a.string().required(), format: a.string(), section: a.string() })
55
85
  .returns(a.json())
56
86
  .authorization((allow) => [allow.groups(editors)])
57
- .handler(a.handler.function(editorialChecks)),
87
+ .handler(functionHandler("editorialChecks", editorialChecks)),
58
88
  suggestEditorialRewrite: a
59
89
  .query()
60
90
  .arguments({
@@ -67,28 +97,28 @@ export const editorialChecksSchemaDefinitions = {
67
97
  })
68
98
  .returns(a.json())
69
99
  .authorization((allow) => [allow.groups(editors)])
70
- .handler(a.handler.function(editorialChecks)),
100
+ .handler(functionHandler("editorialChecks", editorialChecks)),
71
101
  draftEditorialQuestions: a
72
102
  .query()
73
103
  .arguments({ title: a.string().required(), guidance: a.string().required(), examples: a.json() })
74
104
  .returns(a.json())
75
105
  .authorization((allow) => [allow.group("admin")])
76
- .handler(a.handler.function(editorialChecks)),
106
+ .handler(functionHandler("editorialChecks", editorialChecks)),
77
107
  tryEditorialPolicy: a
78
108
  .query()
79
109
  .arguments({ policy: a.json().required(), text: a.string().required(), format: a.string() })
80
110
  .returns(a.json())
81
111
  .authorization((allow) => [allow.group("admin")])
82
- .handler(a.handler.function(editorialChecks)),
112
+ .handler(functionHandler("editorialChecks", editorialChecks)),
83
113
  importEditorialStarterPack: a
84
114
  .mutation()
85
115
  .arguments({ name: a.string().required() })
86
116
  .returns(a.json())
87
117
  .authorization((allow) => [allow.group("admin")])
88
- .handler(a.handler.function(editorialChecks)),
118
+ .handler(functionHandler("editorialChecks", editorialChecks)),
89
119
  recalibrateEditorialPolicies: a
90
120
  .mutation()
91
121
  .returns(a.json())
92
122
  .authorization((allow) => [allow.group("admin")])
93
- .handler(a.handler.function(editorialChecks)),
123
+ .handler(functionHandler("editorialChecks", editorialChecks)),
94
124
  };
@@ -10,6 +10,10 @@ export const EDITORIAL_CHECKS_FUNCTION_FIELDS = [
10
10
  "Query/fields/listEditorialFeedbacks",
11
11
  "Mutation/fields/createEditorialPolicy",
12
12
  "Mutation/fields/updateEditorialPolicy",
13
+ "Query/fields/getItem",
14
+ "Query/fields/getEditorialCheckRun",
15
+ "Mutation/fields/createEditorialCheckRun",
16
+ "Mutation/fields/updateEditorialCheckRun",
13
17
  ];
14
18
  export const EDITORIAL_CHECKS_SECRETS = ["TYPESAFE_API_KEY", "OPENAI_API_KEY"];
15
19
  /**
@@ -12,6 +12,10 @@ export const EDITORIAL_CHECKS_FUNCTION_FIELDS = [
12
12
  "Query/fields/listEditorialFeedbacks",
13
13
  "Mutation/fields/createEditorialPolicy",
14
14
  "Mutation/fields/updateEditorialPolicy",
15
+ "Query/fields/getItem",
16
+ "Query/fields/getEditorialCheckRun",
17
+ "Mutation/fields/createEditorialCheckRun",
18
+ "Mutation/fields/updateEditorialCheckRun",
15
19
  ];
16
20
 
17
21
  export const EDITORIAL_CHECKS_SECRETS = ["TYPESAFE_API_KEY", "OPENAI_API_KEY"];
@@ -0,0 +1,11 @@
1
+ """The monitoring-view function: `monitoringOverview` for editors and admins, read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from papyrus_content.monitoring_view import handle
8
+
9
+
10
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
11
+ return handle(event)
@@ -0,0 +1,24 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import { pythonLambdaCode } from "../shared/python-bundle.js";
5
+ /** AppSync gives a resolver 30 seconds; a read of one small table needs a few. */
6
+ export const MONITORING_VIEW_TIMEOUT_SECONDS = 15;
7
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
8
+ export const monitoringView = defineFunction((scope) => new Function(scope, "papyrus-monitoring-view", {
9
+ runtime: Runtime.PYTHON_3_12,
10
+ architecture: Architecture.ARM_64,
11
+ handler: "handler.handler",
12
+ timeout: Duration.seconds(MONITORING_VIEW_TIMEOUT_SECONDS),
13
+ memorySize: 256,
14
+ code: pythonLambdaCode({ functionDir: "monitoring-view", modules: ["papyrus_content"], noDeps: true }, [
15
+ "bash",
16
+ "-c",
17
+ [
18
+ "set -euo pipefail",
19
+ "mkdir -p /asset-output/papyrus_content",
20
+ "cp amplify/functions/monitoring-view/handler.py /asset-output/handler.py",
21
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
22
+ ].join(" && "),
23
+ ]),
24
+ }), { resourceGroupName: "data" });
@@ -0,0 +1,34 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import type { Construct } from "constructs";
5
+ import { pythonLambdaCode } from "../shared/python-bundle";
6
+
7
+ /** AppSync gives a resolver 30 seconds; a read of one small table needs a few. */
8
+ export const MONITORING_VIEW_TIMEOUT_SECONDS = 15;
9
+
10
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
11
+ export const monitoringView = defineFunction(
12
+ (scope: Construct) =>
13
+ new Function(scope, "papyrus-monitoring-view", {
14
+ runtime: Runtime.PYTHON_3_12,
15
+ architecture: Architecture.ARM_64,
16
+ handler: "handler.handler",
17
+ timeout: Duration.seconds(MONITORING_VIEW_TIMEOUT_SECONDS),
18
+ memorySize: 256,
19
+ code: pythonLambdaCode(
20
+ { functionDir: "monitoring-view", modules: ["papyrus_content"], noDeps: true },
21
+ [
22
+ "bash",
23
+ "-c",
24
+ [
25
+ "set -euo pipefail",
26
+ "mkdir -p /asset-output/papyrus_content",
27
+ "cp amplify/functions/monitoring-view/handler.py /asset-output/handler.py",
28
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
29
+ ].join(" && "),
30
+ ],
31
+ ),
32
+ }),
33
+ { resourceGroupName: "data" },
34
+ );
@@ -0,0 +1,29 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { monitoringView } from "./resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
4
+ const editors = ["editor", "admin"];
5
+ /**
6
+ * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
7
+ * this one query. AppSync admits editors and admins only; the function checks the groups again,
8
+ * and returns the whole shared pipeline to admins alone.
9
+ */
10
+ export const monitoringViewSchemaDefinitions = {
11
+ monitoringOverview: a
12
+ .query()
13
+ .arguments({ scope: a.string() })
14
+ .returns(a.json())
15
+ .authorization((allow) => [allow.groups(editors)])
16
+ .handler(functionHandler("monitoringView", monitoringView)),
17
+ monitoringEmailSubmission: a
18
+ .query()
19
+ .arguments({ receiptId: a.string().required() })
20
+ .returns(a.json())
21
+ .authorization((allow) => [allow.groups(editors)])
22
+ .handler(functionHandler("monitoringView", monitoringView)),
23
+ monitoringResolveEmailArticleLink: a
24
+ .mutation()
25
+ .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
26
+ .returns(a.json())
27
+ .authorization((allow) => [allow.groups(editors)])
28
+ .handler(functionHandler("monitoringView", monitoringView)),
29
+ };
@@ -0,0 +1,31 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { monitoringView } from "./resource";
3
+ import { functionHandler } from "../../data/function-handler";
4
+
5
+ const editors = ["editor", "admin"];
6
+
7
+ /**
8
+ * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
9
+ * this one query. AppSync admits editors and admins only; the function checks the groups again,
10
+ * and returns the whole shared pipeline to admins alone.
11
+ */
12
+ export const monitoringViewSchemaDefinitions = {
13
+ monitoringOverview: a
14
+ .query()
15
+ .arguments({ scope: a.string() })
16
+ .returns(a.json())
17
+ .authorization((allow) => [allow.groups(editors)])
18
+ .handler(functionHandler("monitoringView", monitoringView)),
19
+ monitoringEmailSubmission: a
20
+ .query()
21
+ .arguments({ receiptId: a.string().required() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
25
+ monitoringResolveEmailArticleLink: a
26
+ .mutation()
27
+ .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
28
+ .returns(a.json())
29
+ .authorization((allow) => [allow.groups(editors)])
30
+ .handler(functionHandler("monitoringView", monitoringView)),
31
+ };
@@ -0,0 +1,64 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ /** The monitoring stack writes the table's name here (infra/monitoring/papyrus-monitoring.yaml). */
4
+ export const MONITORING_TABLE_PARAMETER = "/papyrus/monitoring/table-name";
5
+ /** Reads only: a single item or a key-range query. No Scan, no writes, no other table. */
6
+ export const MONITORING_VIEW_TABLE_ACTIONS = ["dynamodb:GetItem", "dynamodb:Query"];
7
+ /**
8
+ * The monitoring table is auto-named by CloudFormation (`papyrus-monitoring-MonitoringTable-<id>`), so
9
+ * the grant is the stack's table name prefix, not a wildcard on the account.
10
+ */
11
+ export function monitoringViewTableArns(region, account) {
12
+ const table = `arn:aws:dynamodb:${region}:${account}:table/papyrus-monitoring-MonitoringTable-*`;
13
+ return [table, `${table}/index/routing`];
14
+ }
15
+ export function monitoringViewParameterArn(region, account) {
16
+ return `arn:aws:ssm:${region}:${account}:parameter${MONITORING_TABLE_PARAMETER}`;
17
+ }
18
+ /**
19
+ * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
20
+ * account simply finds no parameter and the page says monitoring is not configured.
21
+ */
22
+ export function wireMonitoringView(backend, emailMonitorId = "hn") {
23
+ const lambda = backend.monitoringView.resources.lambda;
24
+ const stack = Stack.of(lambda);
25
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
26
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
27
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
28
+ lambda.addToRolePolicy(new PolicyStatement({
29
+ effect: Effect.ALLOW,
30
+ actions: ["ssm:GetParameter"],
31
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
32
+ }));
33
+ lambda.addToRolePolicy(new PolicyStatement({
34
+ effect: Effect.ALLOW,
35
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
36
+ resources: monitoringViewTableArns(stack.region, stack.account),
37
+ }));
38
+ // Resolving a selected newsletter article records its destination in the
39
+ // same receipt partition. The editor-facing resolver validates the receipt
40
+ // and candidate before this single-key write.
41
+ lambda.addToRolePolicy(new PolicyStatement({
42
+ effect: Effect.ALLOW,
43
+ actions: ["dynamodb:PutItem"],
44
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
45
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
46
+ }));
47
+ }
48
+ /** Lets the authenticated Reference-intake action verify a selected email receipt. */
49
+ export function wireEmailReferenceIntake(lambda, emailMonitorId = "hn") {
50
+ const stack = Stack.of(lambda);
51
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
52
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
53
+ lambda.addToRolePolicy(new PolicyStatement({
54
+ effect: Effect.ALLOW,
55
+ actions: ["ssm:GetParameter"],
56
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
57
+ }));
58
+ lambda.addToRolePolicy(new PolicyStatement({
59
+ effect: Effect.ALLOW,
60
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
61
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
62
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
63
+ }));
64
+ }
@@ -0,0 +1,81 @@
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
+
5
+ /** The monitoring stack writes the table's name here (infra/monitoring/papyrus-monitoring.yaml). */
6
+ export const MONITORING_TABLE_PARAMETER = "/papyrus/monitoring/table-name";
7
+
8
+ /** Reads only: a single item or a key-range query. No Scan, no writes, no other table. */
9
+ export const MONITORING_VIEW_TABLE_ACTIONS = ["dynamodb:GetItem", "dynamodb:Query"];
10
+
11
+ /**
12
+ * The monitoring table is auto-named by CloudFormation (`papyrus-monitoring-MonitoringTable-<id>`), so
13
+ * the grant is the stack's table name prefix, not a wildcard on the account.
14
+ */
15
+ export function monitoringViewTableArns(region: string, account: string): string[] {
16
+ const table = `arn:aws:dynamodb:${region}:${account}:table/papyrus-monitoring-MonitoringTable-*`;
17
+ return [table, `${table}/index/routing`];
18
+ }
19
+
20
+ export function monitoringViewParameterArn(region: string, account: string): string {
21
+ return `arn:aws:ssm:${region}:${account}:parameter${MONITORING_TABLE_PARAMETER}`;
22
+ }
23
+
24
+ /**
25
+ * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
26
+ * account simply finds no parameter and the page says monitoring is not configured.
27
+ */
28
+ export function wireMonitoringView(backend: any, emailMonitorId = "hn"): void {
29
+ const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
30
+ const stack = Stack.of(lambda);
31
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
32
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
33
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
34
+ lambda.addToRolePolicy(
35
+ new PolicyStatement({
36
+ effect: Effect.ALLOW,
37
+ actions: ["ssm:GetParameter"],
38
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
39
+ }),
40
+ );
41
+ lambda.addToRolePolicy(
42
+ new PolicyStatement({
43
+ effect: Effect.ALLOW,
44
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
45
+ resources: monitoringViewTableArns(stack.region, stack.account),
46
+ }),
47
+ );
48
+ // Resolving a selected newsletter article records its destination in the
49
+ // same receipt partition. The editor-facing resolver validates the receipt
50
+ // and candidate before this single-key write.
51
+ lambda.addToRolePolicy(
52
+ new PolicyStatement({
53
+ effect: Effect.ALLOW,
54
+ actions: ["dynamodb:PutItem"],
55
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
56
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
57
+ }),
58
+ );
59
+ }
60
+
61
+ /** Lets the authenticated Reference-intake action verify a selected email receipt. */
62
+ export function wireEmailReferenceIntake(lambda: LambdaFunction, emailMonitorId = "hn"): void {
63
+ const stack = Stack.of(lambda);
64
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
65
+ lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
66
+ lambda.addToRolePolicy(
67
+ new PolicyStatement({
68
+ effect: Effect.ALLOW,
69
+ actions: ["ssm:GetParameter"],
70
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
71
+ }),
72
+ );
73
+ lambda.addToRolePolicy(
74
+ new PolicyStatement({
75
+ effect: Effect.ALLOW,
76
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
77
+ resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
78
+ conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": [`MONITOR#${emailMonitorId}`] } },
79
+ }),
80
+ );
81
+ }
@@ -0,0 +1,247 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ /**
3
+ * The public form receiving endpoint, without any AWS in it: `handlePublicFormRequest` takes the Function URL
4
+ * event plus a store and a rate limiter, so every rule here is exercised by `scripts/test-public-forms.ts`.
5
+ *
6
+ * Contract (docs/public-forms.md): POST JSON from one of the site's own origins; the answer never reflects input.
7
+ */
8
+ export const FORM_SUBMISSION_MESSAGE_KIND = "form_submission";
9
+ export const FORM_SUBMISSION_MESSAGE_DOMAIN = "inbox";
10
+ export const FORM_SUBMISSION_FEED_KEY = "messages";
11
+ export const NEWSLETTER_FORM_ID = "newsletter";
12
+ export const NEWSLETTER_MESSAGE_TYPE = "newsletter_signup";
13
+ export const DEFAULT_FORM_ID = "contact";
14
+ export const DEFAULT_HONEYPOT_FIELDS = ["website"];
15
+ export const DEFAULT_PUBLIC_FORMS_CONFIG = {
16
+ allowedOrigins: [],
17
+ honeypotFields: DEFAULT_HONEYPOT_FIELDS,
18
+ maxBodyBytes: 16 * 1024,
19
+ maxFields: 20,
20
+ maxValueLength: 5000,
21
+ ipLimit: 10,
22
+ ipWindowSeconds: 600,
23
+ formLimitPerHour: 120,
24
+ siteLimitPerHour: 400,
25
+ };
26
+ const FORM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
+ const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
28
+ const EMAIL = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
29
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
30
+ export function parseOrigins(value) {
31
+ const origins = [];
32
+ for (const entry of (value ?? "").split(",")) {
33
+ const trimmed = entry.trim();
34
+ if (trimmed === "")
35
+ continue;
36
+ try {
37
+ const url = new URL(trimmed);
38
+ if (url.protocol === "https:" || url.hostname === "localhost")
39
+ origins.push(url.origin);
40
+ }
41
+ catch {
42
+ // An unparsable origin is dropped: the allow-list can only get stricter.
43
+ }
44
+ }
45
+ return [...new Set(origins)];
46
+ }
47
+ function positiveInteger(value, fallback) {
48
+ const parsed = Number.parseInt(value ?? "", 10);
49
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
50
+ }
51
+ export function configFromEnvironment(env) {
52
+ const honeypots = (env.PAPYRUS_PUBLIC_FORMS_HONEYPOTS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
53
+ return {
54
+ ...DEFAULT_PUBLIC_FORMS_CONFIG,
55
+ allowedOrigins: parseOrigins(env.PAPYRUS_PUBLIC_FORMS_ORIGINS),
56
+ honeypotFields: [...new Set([...DEFAULT_HONEYPOT_FIELDS, ...honeypots])],
57
+ ipLimit: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_IP_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.ipLimit),
58
+ formLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_FORM_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.formLimitPerHour),
59
+ siteLimitPerHour: positiveInteger(env.PAPYRUS_PUBLIC_FORMS_SITE_LIMIT, DEFAULT_PUBLIC_FORMS_CONFIG.siteLimitPerHour),
60
+ };
61
+ }
62
+ function reply(statusCode, body, headers = {}) {
63
+ return {
64
+ statusCode,
65
+ headers: { "Cache-Control": "no-store", "Content-Type": "application/json", "X-Content-Type-Options": "nosniff", ...headers },
66
+ body: body === null ? "" : JSON.stringify(body),
67
+ };
68
+ }
69
+ function header(event, name) {
70
+ const headers = event.headers ?? {};
71
+ for (const [key, value] of Object.entries(headers)) {
72
+ if (key.toLowerCase() === name && typeof value === "string")
73
+ return value;
74
+ }
75
+ return "";
76
+ }
77
+ function clean(value, multiline) {
78
+ const stripped = value.replace(CONTROL, "").replace(/\r\n?/g, "\n");
79
+ return (multiline ? stripped : stripped.replace(/\n/g, " ")).trim();
80
+ }
81
+ function parseSubmission(event, config) {
82
+ const raw = event.isBase64Encoded === true ? Buffer.from(event.body ?? "", "base64").toString("utf8") : (event.body ?? "");
83
+ let payload;
84
+ try {
85
+ payload = JSON.parse(raw);
86
+ }
87
+ catch {
88
+ return { ok: false, error: "invalid-json" };
89
+ }
90
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
91
+ return { ok: false, error: "invalid-body" };
92
+ const entries = Object.entries(payload);
93
+ if (entries.length > config.maxFields)
94
+ return { ok: false, error: "too-many-fields" };
95
+ const fields = {};
96
+ let formId = "";
97
+ let page = "";
98
+ let honeypotFilled = false;
99
+ for (const [name, value] of entries) {
100
+ if (!FIELD_NAME.test(name))
101
+ return { ok: false, error: "invalid-field" };
102
+ if (value === null || value === undefined)
103
+ continue;
104
+ if (typeof value !== "string")
105
+ return { ok: false, error: "invalid-field" };
106
+ if (value.length > config.maxValueLength)
107
+ return { ok: false, error: "field-too-long" };
108
+ if (config.honeypotFields.includes(name)) {
109
+ if (value.trim() !== "")
110
+ honeypotFilled = true;
111
+ continue;
112
+ }
113
+ if (name === "form")
114
+ formId = value.trim().toLowerCase();
115
+ else if (name === "page")
116
+ page = clean(value, false).slice(0, 500);
117
+ else
118
+ fields[name] = clean(value, name === "message");
119
+ }
120
+ return { ok: true, formId, fields, page, honeypotFilled };
121
+ }
122
+ function queryFormId(event) {
123
+ const match = /(?:^|&)form=([^&]*)/.exec(event.rawQueryString ?? "");
124
+ if (match === null)
125
+ return "";
126
+ try {
127
+ return decodeURIComponent(match[1]).trim().toLowerCase();
128
+ }
129
+ catch {
130
+ return "";
131
+ }
132
+ }
133
+ function ipSubject(ip) {
134
+ return createHash("sha256").update(`papyrus-public-forms:${ip}`).digest("hex").slice(0, 32);
135
+ }
136
+ export function buildSubmissionRecord(input) {
137
+ const { formId, fields } = input;
138
+ const newsletter = formId === NEWSLETTER_FORM_ID;
139
+ const name = fields.name ?? "";
140
+ const email = fields.email ?? "";
141
+ const topic = fields.topic ?? "";
142
+ const message = fields.message ?? "";
143
+ const who = name !== "" ? (email !== "" ? `${name} <${email}>` : name) : email;
144
+ const headline = newsletter ? "Newsletter signup" : topic !== "" ? `${formId}: ${topic}` : formId;
145
+ const lines = [`Form: ${formId}`];
146
+ for (const [key, value] of Object.entries(fields)) {
147
+ if (key === "message" || value === "")
148
+ continue;
149
+ lines.push(`${key}: ${value}`);
150
+ }
151
+ if (input.page !== "")
152
+ lines.push(`page: ${input.page}`);
153
+ const content = message !== "" ? `${lines.join("\n")}\n\n${message}` : lines.join("\n");
154
+ const summarySource = message !== "" ? message.replace(/\s+/g, " ") : "";
155
+ const summary = `${headline}${who !== "" ? ` from ${who}` : ""}${summarySource !== "" ? `: ${summarySource}` : ""}`.slice(0, 200);
156
+ return {
157
+ id: input.id,
158
+ messageKind: FORM_SUBMISSION_MESSAGE_KIND,
159
+ messageDomain: FORM_SUBMISSION_MESSAGE_DOMAIN,
160
+ messageType: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
161
+ status: "active",
162
+ summary,
163
+ content,
164
+ source: "public-form",
165
+ authorLabel: who !== "" ? who.slice(0, 200) : "Anonymous visitor",
166
+ searchVisibility: "private",
167
+ responseTarget: "none",
168
+ responseStatus: "COMPLETED",
169
+ metadata: JSON.stringify({
170
+ kind: newsletter ? NEWSLETTER_MESSAGE_TYPE : FORM_SUBMISSION_MESSAGE_KIND,
171
+ formId,
172
+ topic,
173
+ fields,
174
+ page: input.page,
175
+ origin: input.origin,
176
+ receivedAt: input.receivedAt,
177
+ }),
178
+ createdAt: input.receivedAt,
179
+ updatedAt: input.receivedAt,
180
+ newsroomFeedKey: FORM_SUBMISSION_FEED_KEY,
181
+ };
182
+ }
183
+ export async function handlePublicFormRequest(event, config, deps) {
184
+ const method = (event.requestContext?.http?.method ?? "").toUpperCase();
185
+ const origin = header(event, "origin");
186
+ const allowed = origin !== "" && config.allowedOrigins.includes(origin);
187
+ const cors = allowed
188
+ ? {
189
+ "Access-Control-Allow-Origin": origin,
190
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
191
+ "Access-Control-Allow-Headers": "Content-Type",
192
+ "Access-Control-Max-Age": "600",
193
+ Vary: "Origin",
194
+ }
195
+ : { Vary: "Origin" };
196
+ if (method === "OPTIONS")
197
+ return allowed ? reply(204, null, cors) : reply(403, { ok: false, error: "origin-not-allowed" }, cors);
198
+ if (method !== "POST")
199
+ return reply(405, { ok: false, error: "method-not-allowed" }, { ...cors, Allow: "POST, OPTIONS" });
200
+ if (!allowed)
201
+ return reply(403, { ok: false, error: "origin-not-allowed" }, cors);
202
+ if (!/^application\/json\s*(;|$)/i.test(header(event, "content-type")))
203
+ return reply(415, { ok: false, error: "unsupported-media-type" }, cors);
204
+ const bodyBytes = Buffer.byteLength(event.body ?? "", event.isBase64Encoded === true ? "base64" : "utf8");
205
+ if (bodyBytes > config.maxBodyBytes)
206
+ return reply(413, { ok: false, error: "payload-too-large" }, cors);
207
+ const parsed = parseSubmission(event, config);
208
+ if (!parsed.ok)
209
+ return reply(400, { ok: false, error: parsed.error }, cors);
210
+ // A filled honeypot looks like success to the bot and stores nothing.
211
+ if (parsed.honeypotFilled)
212
+ return reply(200, { ok: true }, cors);
213
+ const formId = parsed.formId || queryFormId(event) || DEFAULT_FORM_ID;
214
+ if (!FORM_ID.test(formId))
215
+ return reply(400, { ok: false, error: "invalid-form" }, cors);
216
+ const { fields } = parsed;
217
+ const email = fields.email ?? "";
218
+ if (email !== "" && (email.length > 254 || !EMAIL.test(email)))
219
+ return reply(400, { ok: false, error: "invalid-email" }, cors);
220
+ if (formId === NEWSLETTER_FORM_ID && email === "")
221
+ return reply(400, { ok: false, error: "email-required" }, cors);
222
+ if (email === "" && (fields.message ?? "") === "")
223
+ return reply(400, { ok: false, error: "empty-submission" }, cors);
224
+ const subject = ipSubject(event.requestContext?.http?.sourceIp ?? "unknown");
225
+ let within;
226
+ try {
227
+ within =
228
+ (await deps.limiter.hit("ip", subject, config.ipWindowSeconds, config.ipLimit))
229
+ && (await deps.limiter.hit("form", formId, 3600, config.formLimitPerHour))
230
+ && (await deps.limiter.hit("site", "all", 3600, config.siteLimitPerHour));
231
+ }
232
+ catch {
233
+ return reply(503, { ok: false, error: "unavailable" }, cors);
234
+ }
235
+ if (!within)
236
+ return reply(429, { ok: false, error: "rate-limited" }, { ...cors, "Retry-After": "600" });
237
+ const receivedAt = (deps.now ?? (() => new Date()))().toISOString();
238
+ const id = (deps.newId ?? (() => `form-${randomUUID().replace(/-/g, "")}`))();
239
+ try {
240
+ await deps.store.save(buildSubmissionRecord({ id, formId, fields, page: parsed.page, origin, receivedAt }));
241
+ }
242
+ catch {
243
+ // Never pretend a submission was received when it was not.
244
+ return reply(500, { ok: false, error: "store-failed" }, cors);
245
+ }
246
+ return reply(200, { ok: true }, cors);
247
+ }